001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2025 the original author or authors.
004//
005// This library is free software; you can redistribute it and/or
006// modify it under the terms of the GNU Lesser General Public
007// License as published by the Free Software Foundation; either
008// version 2.1 of the License, or (at your option) any later version.
009//
010// This library is distributed in the hope that it will be useful,
011// but WITHOUT ANY WARRANTY; without even the implied warranty of
012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
013// Lesser General Public License for more details.
014//
015// You should have received a copy of the GNU Lesser General Public
016// License along with this library; if not, write to the Free Software
017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
018///////////////////////////////////////////////////////////////////////////////////////////////
019
020package com.puppycrawl.tools.checkstyle;
021
022import java.io.OutputStream;
023import java.io.OutputStreamWriter;
024import java.io.PrintWriter;
025import java.io.StringWriter;
026import java.nio.charset.StandardCharsets;
027import java.util.ArrayList;
028import java.util.Collections;
029import java.util.List;
030import java.util.Map;
031import java.util.concurrent.ConcurrentHashMap;
032
033import com.puppycrawl.tools.checkstyle.api.AuditEvent;
034import com.puppycrawl.tools.checkstyle.api.AuditListener;
035import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
036import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
037
038/**
039 * Simple XML logger.
040 * It outputs everything in UTF-8 (default XML encoding is UTF-8) in case
041 * we want to localize error messages or simply that file names are
042 * localized and takes care about escaping as well.
043 */
044// -@cs[AbbreviationAsWordInName] We can not change it as,
045// check's name is part of API (used in configurations).
046public class XMLLogger
047    extends AbstractAutomaticBean
048    implements AuditListener {
049
050    /** Decimal radix. */
051    private static final int BASE_10 = 10;
052
053    /** Hex radix. */
054    private static final int BASE_16 = 16;
055
056    /** Some known entities to detect. */
057    private static final String[] ENTITIES = {"gt", "amp", "lt", "apos",
058                                              "quot", };
059
060    /** Close output stream in auditFinished. */
061    private final boolean closeStream;
062
063    /** Holds all messages for the given file. */
064    private final Map<String, FileMessages> fileMessages =
065            new ConcurrentHashMap<>();
066
067    /**
068     * Helper writer that allows easy encoding and printing.
069     */
070    private final PrintWriter writer;
071
072    /**
073     * Creates a new {@code XMLLogger} instance.
074     * Sets the output to a defined stream.
075     *
076     * @param outputStream the stream to write logs to.
077     * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
078     * @throws IllegalArgumentException if outputStreamOptions is null.
079     * @noinspection deprecation
080     * @noinspectionreason We are forced to keep AutomaticBean compatability
081     *     because of maven-checkstyle-plugin. Until #12873.
082     */
083    public XMLLogger(OutputStream outputStream,
084                     AutomaticBean.OutputStreamOptions outputStreamOptions) {
085        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
086    }
087
088    /**
089     * Creates a new {@code XMLLogger} instance.
090     * Sets the output to a defined stream.
091     *
092     * @param outputStream the stream to write logs to.
093     * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
094     * @throws IllegalArgumentException if outputStreamOptions is null.
095     */
096    public XMLLogger(OutputStream outputStream, OutputStreamOptions outputStreamOptions) {
097        writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
098        if (outputStreamOptions == null) {
099            throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
100        }
101        closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
102    }
103
104    @Override
105    protected void finishLocalSetup() {
106        // No code by default
107    }
108
109    /**
110     * Returns the version string printed.
111     *
112     */
113    private void printVersionString() {
114        final String version = XMLLogger.class.getPackage().getImplementationVersion();
115        writer.println("<checkstyle version=\"" + version + "\">");
116    }
117
118    @Override
119    public void auditStarted(AuditEvent event) {
120        writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
121
122        printVersionString();
123    }
124
125    @Override
126    public void auditFinished(AuditEvent event) {
127        writer.println("</checkstyle>");
128        if (closeStream) {
129            writer.close();
130        }
131        else {
132            writer.flush();
133        }
134    }
135
136    @Override
137    public void fileStarted(AuditEvent event) {
138        fileMessages.put(event.getFileName(), new FileMessages());
139    }
140
141    @Override
142    public void fileFinished(AuditEvent event) {
143        final String fileName = event.getFileName();
144        final FileMessages messages = fileMessages.remove(fileName);
145        writeFileMessages(fileName, messages);
146    }
147
148    /**
149     * Prints the file section with all file errors and exceptions.
150     *
151     * @param fileName The file name, as should be printed in the opening file tag.
152     * @param messages The file messages.
153     */
154    private void writeFileMessages(String fileName, FileMessages messages) {
155        writeFileOpeningTag(fileName);
156        if (messages != null) {
157            for (AuditEvent errorEvent : messages.getErrors()) {
158                writeFileError(errorEvent);
159            }
160            for (Throwable exception : messages.getExceptions()) {
161                writeException(exception);
162            }
163        }
164        writeFileClosingTag();
165    }
166
167    /**
168     * Prints the "file" opening tag with the given filename.
169     *
170     * @param fileName The filename to output.
171     */
172    private void writeFileOpeningTag(String fileName) {
173        writer.println("<file name=\"" + encode(fileName) + "\">");
174    }
175
176    /**
177     * Prints the "file" closing tag.
178     */
179    private void writeFileClosingTag() {
180        writer.println("</file>");
181    }
182
183    @Override
184    public void addError(AuditEvent event) {
185        if (event.getSeverityLevel() != SeverityLevel.IGNORE) {
186            final String fileName = event.getFileName();
187            if (fileName == null || !fileMessages.containsKey(fileName)) {
188                writeFileError(event);
189            }
190            else {
191                final FileMessages messages = fileMessages.get(fileName);
192                messages.addError(event);
193            }
194        }
195    }
196
197    /**
198     * Outputs the given event to the writer.
199     *
200     * @param event An event to print.
201     */
202    private void writeFileError(AuditEvent event) {
203        writer.print("<error" + " line=\"" + event.getLine() + "\"");
204        if (event.getColumn() > 0) {
205            writer.print(" column=\"" + event.getColumn() + "\"");
206        }
207        writer.print(" severity=\""
208                + event.getSeverityLevel().getName()
209                + "\"");
210        writer.print(" message=\""
211                + encode(event.getMessage())
212                + "\"");
213        writer.print(" source=\"");
214        final String sourceValue;
215        if (event.getModuleId() == null) {
216            sourceValue = event.getSourceName();
217        }
218        else {
219            sourceValue = event.getModuleId();
220        }
221        writer.print(encode(sourceValue));
222        writer.println("\"/>");
223    }
224
225    @Override
226    public void addException(AuditEvent event, Throwable throwable) {
227        final String fileName = event.getFileName();
228        if (fileName == null || !fileMessages.containsKey(fileName)) {
229            writeException(throwable);
230        }
231        else {
232            final FileMessages messages = fileMessages.get(fileName);
233            messages.addException(throwable);
234        }
235    }
236
237    /**
238     * Writes the exception event to the print writer.
239     *
240     * @param throwable The
241     */
242    private void writeException(Throwable throwable) {
243        writer.println("<exception>");
244        writer.println("<![CDATA[");
245
246        final StringWriter stringWriter = new StringWriter();
247        final PrintWriter printer = new PrintWriter(stringWriter);
248        throwable.printStackTrace(printer);
249        writer.println(encode(stringWriter.toString()));
250
251        writer.println("]]>");
252        writer.println("</exception>");
253    }
254
255    /**
256     * Escape &lt;, &gt; &amp; &#39; and &quot; as their entities.
257     *
258     * @param value the value to escape.
259     * @return the escaped value if necessary.
260     */
261    public static String encode(String value) {
262        final StringBuilder sb = new StringBuilder(256);
263        for (int i = 0; i < value.length(); i++) {
264            final char chr = value.charAt(i);
265            switch (chr) {
266                case '<':
267                    sb.append("&lt;");
268                    break;
269                case '>':
270                    sb.append("&gt;");
271                    break;
272                case '\'':
273                    sb.append("&apos;");
274                    break;
275                case '\"':
276                    sb.append("&quot;");
277                    break;
278                case '&':
279                    sb.append("&amp;");
280                    break;
281                case '\r':
282                    break;
283                case '\n':
284                    sb.append("&#10;");
285                    break;
286                default:
287                    if (Character.isISOControl(chr)) {
288                        // true escape characters need '&' before, but it also requires XML 1.1
289                        // until https://github.com/checkstyle/checkstyle/issues/5168
290                        sb.append("#x");
291                        sb.append(Integer.toHexString(chr));
292                        sb.append(';');
293                    }
294                    else {
295                        sb.append(chr);
296                    }
297                    break;
298            }
299        }
300        return sb.toString();
301    }
302
303    /**
304     * Finds whether the given argument is character or entity reference.
305     *
306     * @param ent the possible entity to look for.
307     * @return whether the given argument a character or entity reference
308     */
309    public static boolean isReference(String ent) {
310        boolean reference = false;
311
312        if (ent.charAt(0) == '&' && ent.endsWith(";")) {
313            if (ent.charAt(1) == '#') {
314                // prefix is "&#"
315                int prefixLength = 2;
316
317                int radix = BASE_10;
318                if (ent.charAt(2) == 'x') {
319                    prefixLength++;
320                    radix = BASE_16;
321                }
322                try {
323                    Integer.parseInt(
324                        ent.substring(prefixLength, ent.length() - 1), radix);
325                    reference = true;
326                }
327                catch (final NumberFormatException ignored) {
328                    reference = false;
329                }
330            }
331            else {
332                final String name = ent.substring(1, ent.length() - 1);
333                for (String element : ENTITIES) {
334                    if (name.equals(element)) {
335                        reference = true;
336                        break;
337                    }
338                }
339            }
340        }
341
342        return reference;
343    }
344
345    /**
346     * The registered file messages.
347     */
348    private static final class FileMessages {
349
350        /** The file error events. */
351        private final List<AuditEvent> errors = new ArrayList<>();
352
353        /** The file exceptions. */
354        private final List<Throwable> exceptions = new ArrayList<>();
355
356        /**
357         * Returns the file error events.
358         *
359         * @return the file error events.
360         */
361        public List<AuditEvent> getErrors() {
362            return Collections.unmodifiableList(errors);
363        }
364
365        /**
366         * Adds the given error event to the messages.
367         *
368         * @param event the error event.
369         */
370        public void addError(AuditEvent event) {
371            errors.add(event);
372        }
373
374        /**
375         * Returns the file exceptions.
376         *
377         * @return the file exceptions.
378         */
379        public List<Throwable> getExceptions() {
380            return Collections.unmodifiableList(exceptions);
381        }
382
383        /**
384         * Adds the given exception to the messages.
385         *
386         * @param throwable the file exception
387         */
388        public void addException(Throwable throwable) {
389            exceptions.add(throwable);
390        }
391
392    }
393
394}