View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle;
21  
22  import java.io.OutputStream;
23  import java.io.OutputStreamWriter;
24  import java.io.PrintWriter;
25  import java.io.StringWriter;
26  import java.nio.charset.StandardCharsets;
27  import java.util.ArrayList;
28  import java.util.Collections;
29  import java.util.HashMap;
30  import java.util.List;
31  import java.util.Map;
32  
33  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
34  import com.puppycrawl.tools.checkstyle.api.AuditListener;
35  import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
36  import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
37  
38  /**
39   * Simple XML logger.
40   * It outputs everything in UTF-8 (default XML encoding is UTF-8) in case
41   * we want to localize error messages or simply that file names are
42   * localized and takes care about escaping as well.
43   */
44  // -@cs[AbbreviationAsWordInName] We can not change it as,
45  // check's name is part of API (used in configurations).
46  public class XMLLogger
47      extends AbstractAutomaticBean
48      implements AuditListener {
49  
50      /** Decimal radix. */
51      private static final int BASE_10 = 10;
52  
53      /** Hex radix. */
54      private static final int BASE_16 = 16;
55  
56      /** Initial capacity for StringBuilder to "source" attribute. */
57      private static final int SOURCE_BUILDER_CAPACITY = 128;
58  
59      /** Some known entities to detect. */
60      private static final String[] ENTITIES = {"gt", "amp", "lt", "apos",
61                                                "quot", };
62  
63      /** Close output stream in auditFinished. */
64      private final boolean closeStream;
65  
66      /** Holds all messages for the given file. */
67      private final Map<String, FileMessages> fileMessages =
68              new HashMap<>();
69  
70      /**
71       * Helper writer that allows easy encoding and printing.
72       */
73      private final PrintWriter writer;
74  
75      /**
76       * Creates a new {@code XMLLogger} instance.
77       * Sets the output to a defined stream.
78       *
79       * @param outputStream the stream to write logs to.
80       * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
81       * @throws IllegalArgumentException if outputStreamOptions is null.
82       * @noinspection deprecation
83       * @noinspectionreason We are forced to keep AutomaticBean compatability
84       *     because of maven-checkstyle-plugin. Until #12873.
85       */
86      public XMLLogger(OutputStream outputStream,
87                       AutomaticBean.OutputStreamOptions outputStreamOptions) {
88          this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
89      }
90  
91      /**
92       * Creates a new {@code XMLLogger} instance.
93       * Sets the output to a defined stream.
94       *
95       * @param outputStream the stream to write logs to.
96       * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
97       * @throws IllegalArgumentException if outputStreamOptions is null.
98       */
99      public XMLLogger(OutputStream outputStream, OutputStreamOptions outputStreamOptions) {
100         writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
101         if (outputStreamOptions == null) {
102             throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
103         }
104         closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
105     }
106 
107     @Override
108     protected void finishLocalSetup() {
109         // No code by default
110     }
111 
112     /**
113      * Returns the version string printed.
114      *
115      */
116     private void printVersionString() {
117         final String version = XMLLogger.class.getPackage().getImplementationVersion();
118         writer.println("<checkstyle version=\"" + version + "\">");
119     }
120 
121     @Override
122     public void auditStarted(AuditEvent event) {
123         writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
124 
125         printVersionString();
126     }
127 
128     @Override
129     public void auditFinished(AuditEvent event) {
130         writer.println("</checkstyle>");
131         if (closeStream) {
132             writer.close();
133         }
134         else {
135             writer.flush();
136         }
137     }
138 
139     @Override
140     public void fileStarted(AuditEvent event) {
141         fileMessages.put(event.getFileName(), new FileMessages());
142     }
143 
144     @Override
145     public void fileFinished(AuditEvent event) {
146         final String fileName = event.getFileName();
147         final FileMessages messages = fileMessages.remove(fileName);
148         writeFileMessages(fileName, messages);
149     }
150 
151     /**
152      * Prints the file section with all file errors and exceptions.
153      *
154      * @param fileName The file name, as should be printed in the opening file tag.
155      * @param messages The file messages.
156      */
157     private void writeFileMessages(String fileName, FileMessages messages) {
158         writeFileOpeningTag(fileName);
159         if (messages != null) {
160             for (AuditEvent errorEvent : messages.getErrors()) {
161                 writeFileError(errorEvent);
162             }
163             for (Throwable exception : messages.getExceptions()) {
164                 writeException(exception);
165             }
166         }
167         writeFileClosingTag();
168     }
169 
170     /**
171      * Prints the "file" opening tag with the given filename.
172      *
173      * @param fileName The filename to output.
174      */
175     private void writeFileOpeningTag(String fileName) {
176         writer.println("<file name=\"" + encode(fileName) + "\">");
177     }
178 
179     /**
180      * Prints the "file" closing tag.
181      */
182     private void writeFileClosingTag() {
183         writer.println("</file>");
184     }
185 
186     @Override
187     public void addError(AuditEvent event) {
188         if (event.getSeverityLevel() != SeverityLevel.IGNORE) {
189             final String fileName = event.getFileName();
190             final FileMessages messages = fileMessages.get(fileName);
191             if (messages != null) {
192                 messages.addError(event);
193             }
194             else {
195                 writeFileError(event);
196             }
197         }
198     }
199 
200     /**
201      * Outputs the given event to the writer.
202      *
203      * @param event An event to print.
204      */
205     private void writeFileError(AuditEvent event) {
206         writer.print("<error" + " line=\"" + event.getLine() + "\"");
207         if (event.getColumn() > 0) {
208             writer.print(" column=\"" + event.getColumn() + "\"");
209         }
210         writer.print(" severity=\""
211                 + event.getSeverityLevel().getName()
212                 + "\"");
213         writer.print(" message=\""
214                 + encode(event.getMessage())
215                 + "\"");
216         writer.print(" source=\"");
217         final StringBuilder sourceValueBuilder = new StringBuilder(SOURCE_BUILDER_CAPACITY);
218         sourceValueBuilder.append(event.getSourceName());
219         final String moduleId = event.getModuleId();
220         if (moduleId != null && !moduleId.isBlank()) {
221             sourceValueBuilder
222                     .append('#')
223                     .append(moduleId);
224         }
225         writer.print(encode(sourceValueBuilder.toString()));
226         writer.println("\"/>");
227     }
228 
229     @Override
230     public void addException(AuditEvent event, Throwable throwable) {
231         final String fileName = event.getFileName();
232         final FileMessages messages = fileMessages.get(fileName);
233         if (messages != null) {
234             messages.addException(throwable);
235         }
236         else {
237             writeException(throwable);
238         }
239     }
240 
241     /**
242      * Writes the exception event to the print writer.
243      *
244      * @param throwable The
245      */
246     private void writeException(Throwable throwable) {
247         writer.println("<exception>");
248         writer.println("<![CDATA[");
249 
250         final StringWriter stringWriter = new StringWriter();
251         final PrintWriter printer = new PrintWriter(stringWriter);
252         throwable.printStackTrace(printer);
253         writer.println(encode(stringWriter.toString()));
254 
255         writer.println("]]>");
256         writer.println("</exception>");
257     }
258 
259     /**
260      * Escape &lt;, &gt; &amp; &#39; and &quot; as their entities.
261      *
262      * @param value the value to escape.
263      * @return the escaped value if necessary.
264      */
265     public static String encode(String value) {
266         final StringBuilder sb = new StringBuilder(256);
267         for (int i = 0; i < value.length(); i++) {
268             final char chr = value.charAt(i);
269             switch (chr) {
270                 case '<':
271                     sb.append("&lt;");
272                     break;
273                 case '>':
274                     sb.append("&gt;");
275                     break;
276                 case '\'':
277                     sb.append("&apos;");
278                     break;
279                 case '\"':
280                     sb.append("&quot;");
281                     break;
282                 case '&':
283                     sb.append("&amp;");
284                     break;
285                 case '\r':
286                     break;
287                 case '\n':
288                     sb.append("&#10;");
289                     break;
290                 default:
291                     if (Character.isISOControl(chr)) {
292                         // true escape characters need '&' before, but it also requires XML 1.1
293                         // until https://github.com/checkstyle/checkstyle/issues/5168
294                         sb.append("#x");
295                         sb.append(Integer.toHexString(chr));
296                         sb.append(';');
297                     }
298                     else {
299                         sb.append(chr);
300                     }
301                     break;
302             }
303         }
304         return sb.toString();
305     }
306 
307     /**
308      * Finds whether the given argument is character or entity reference.
309      *
310      * @param ent the possible entity to look for.
311      * @return whether the given argument a character or entity reference
312      */
313     public static boolean isReference(String ent) {
314         boolean reference = false;
315 
316         if (ent.charAt(0) == '&' && ent.endsWith(";")) {
317             if (ent.charAt(1) == '#') {
318                 // prefix is "&#"
319                 int prefixLength = 2;
320 
321                 int radix = BASE_10;
322                 if (ent.charAt(2) == 'x') {
323                     prefixLength++;
324                     radix = BASE_16;
325                 }
326                 try {
327                     Integer.parseInt(
328                         ent.substring(prefixLength, ent.length() - 1), radix);
329                     reference = true;
330                 }
331                 catch (final NumberFormatException ignored) {
332                     reference = false;
333                 }
334             }
335             else {
336                 final String name = ent.substring(1, ent.length() - 1);
337                 for (String element : ENTITIES) {
338                     if (name.equals(element)) {
339                         reference = true;
340                         break;
341                     }
342                 }
343             }
344         }
345 
346         return reference;
347     }
348 
349     /**
350      * The registered file messages.
351      */
352     private static final class FileMessages {
353 
354         /** The file error events. */
355         private final List<AuditEvent> errors = new ArrayList<>();
356 
357         /** The file exceptions. */
358         private final List<Throwable> exceptions = new ArrayList<>();
359 
360         /**
361          * Returns the file error events.
362          *
363          * @return the file error events.
364          */
365         public List<AuditEvent> getErrors() {
366             return Collections.unmodifiableList(errors);
367         }
368 
369         /**
370          * Adds the given error event to the messages.
371          *
372          * @param event the error event.
373          */
374         public void addError(AuditEvent event) {
375             errors.add(event);
376         }
377 
378         /**
379          * Returns the file exceptions.
380          *
381          * @return the file exceptions.
382          */
383         public List<Throwable> getExceptions() {
384             return Collections.unmodifiableList(exceptions);
385         }
386 
387         /**
388          * Adds the given exception to the messages.
389          *
390          * @param throwable the file exception
391          */
392         public void addException(Throwable throwable) {
393             exceptions.add(throwable);
394         }
395 
396     }
397 
398 }