View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2026 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 final 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 '<' -> sb.append("&lt;");
271                 case '>' -> sb.append("&gt;");
272                 case '\'' -> sb.append("&apos;");
273                 case '\"' -> sb.append("&quot;");
274                 case '&' -> sb.append("&amp;");
275                 case '\r' -> {
276                     // Remove \r to make consistent new lines.
277                 }
278                 case '\n' -> sb.append("&#10;");
279                 default -> {
280                     if (Character.isISOControl(chr)) {
281                         // true escape characters need '&' before, but it also requires XML 1.1
282                         // until https://github.com/checkstyle/checkstyle/issues/5168
283                         sb.append("#x");
284                         sb.append(Integer.toHexString(chr));
285                         sb.append(';');
286                     }
287                     else {
288                         sb.append(chr);
289                     }
290                 }
291             }
292         }
293         return sb.toString();
294     }
295 
296     /**
297      * Finds whether the given argument is character or entity reference.
298      *
299      * @param ent the possible entity to look for.
300      * @return whether the given argument a character or entity reference
301      */
302     public static boolean isReference(String ent) {
303         boolean reference = false;
304 
305         if (ent.charAt(0) == '&' && ent.endsWith(";")) {
306             if (ent.charAt(1) == '#') {
307                 // prefix is "&#"
308                 int prefixLength = 2;
309 
310                 int radix = BASE_10;
311                 if (ent.charAt(2) == 'x') {
312                     prefixLength++;
313                     radix = BASE_16;
314                 }
315                 try {
316                     Integer.parseInt(
317                         ent.substring(prefixLength, ent.length() - 1), radix);
318                     reference = true;
319                 }
320                 catch (final NumberFormatException ignored) {
321                     reference = false;
322                 }
323             }
324             else {
325                 final String name = ent.substring(1, ent.length() - 1);
326                 for (String element : ENTITIES) {
327                     if (name.equals(element)) {
328                         reference = true;
329                         break;
330                     }
331                 }
332             }
333         }
334 
335         return reference;
336     }
337 
338     /**
339      * The registered file messages.
340      */
341     private static final class FileMessages {
342 
343         /** The file error events. */
344         private final List<AuditEvent> errors = new ArrayList<>();
345 
346         /** The file exceptions. */
347         private final List<Throwable> exceptions = new ArrayList<>();
348 
349         /**
350          * Returns the file error events.
351          *
352          * @return the file error events.
353          */
354         /* package */ List<AuditEvent> getErrors() {
355             return Collections.unmodifiableList(errors);
356         }
357 
358         /**
359          * Adds the given error event to the messages.
360          *
361          * @param event the error event.
362          */
363         /* package */ void addError(AuditEvent event) {
364             errors.add(event);
365         }
366 
367         /**
368          * Returns the file exceptions.
369          *
370          * @return the file exceptions.
371          */
372         /* package */ List<Throwable> getExceptions() {
373             return Collections.unmodifiableList(exceptions);
374         }
375 
376         /**
377          * Adds the given exception to the messages.
378          *
379          * @param throwable the file exception
380          */
381         /* package */ void addException(Throwable throwable) {
382             exceptions.add(throwable);
383         }
384 
385     }
386 
387 }