001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2026 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.HashMap;
030import java.util.List;
031import java.util.Map;
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 final 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    /** Initial capacity for StringBuilder to "source" attribute. */
057    private static final int SOURCE_BUILDER_CAPACITY = 128;
058
059    /** Some known entities to detect. */
060    private static final String[] ENTITIES = {"gt", "amp", "lt", "apos",
061                                              "quot", };
062
063    /** Close output stream in auditFinished. */
064    private final boolean closeStream;
065
066    /** Holds all messages for the given file. */
067    private final Map<String, FileMessages> fileMessages =
068            new HashMap<>();
069
070    /**
071     * Helper writer that allows easy encoding and printing.
072     */
073    private final PrintWriter writer;
074
075    /**
076     * Creates a new {@code XMLLogger} instance.
077     * Sets the output to a defined stream.
078     *
079     * @param outputStream the stream to write logs to.
080     * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
081     * @throws IllegalArgumentException if outputStreamOptions is null.
082     * @noinspection deprecation
083     * @noinspectionreason We are forced to keep AutomaticBean compatability
084     *     because of maven-checkstyle-plugin. Until #12873.
085     */
086    public XMLLogger(OutputStream outputStream,
087                     AutomaticBean.OutputStreamOptions outputStreamOptions) {
088        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
089    }
090
091    /**
092     * Creates a new {@code XMLLogger} instance.
093     * Sets the output to a defined stream.
094     *
095     * @param outputStream the stream to write logs to.
096     * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
097     * @throws IllegalArgumentException if outputStreamOptions is null.
098     */
099    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}