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