XMLLogger.java

////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2021 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle;

import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import com.puppycrawl.tools.checkstyle.api.AuditEvent;
import com.puppycrawl.tools.checkstyle.api.AuditListener;
import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * Simple XML logger.
 * It outputs everything in UTF-8 (default XML encoding is UTF-8) in case
 * we want to localize error messages or simply that file names are
 * localized and takes care about escaping as well.
 */
// -@cs[AbbreviationAsWordInName] We can not change it as,
// check's name is part of API (used in configurations).
public class XMLLogger
    extends AutomaticBean
    implements AuditListener {

    /** Decimal radix. */
    private static final int BASE_10 = 10;

    /** Hex radix. */
    private static final int BASE_16 = 16;

    /** Some known entities to detect. */
    private static final String[] ENTITIES = {"gt", "amp", "lt", "apos",
                                              "quot", };

    /** Close output stream in auditFinished. */
    private final boolean closeStream;

    /** The writer lock object. */
    private final Object writerLock = new Object();

    /** Holds all messages for the given file. */
    private final Map<String, FileMessages> fileMessages =
            new ConcurrentHashMap<>();

    /**
     * Helper writer that allows easy encoding and printing.
     */
    private final PrintWriter writer;

    /**
     * Creates a new {@code XMLLogger} instance.
     * Sets the output to a defined stream.
     *
     * @param outputStream the stream to write logs to.
     * @param outputStreamOptions if {@code CLOSE} stream should be closed in auditFinished()
     * @throws IllegalArgumentException if outputStreamOptions is null.
     */
    public XMLLogger(OutputStream outputStream, OutputStreamOptions outputStreamOptions) {
        writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
        if (outputStreamOptions == null) {
            throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
        }
        closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
    }

    @Override
    protected void finishLocalSetup() {
        // No code by default
    }

    @Override
    public void auditStarted(AuditEvent event) {
        writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");

        final String version = XMLLogger.class.getPackage().getImplementationVersion();

        writer.println("<checkstyle version=\"" + version + "\">");
    }

    @Override
    public void auditFinished(AuditEvent event) {
        writer.println("</checkstyle>");
        if (closeStream) {
            writer.close();
        }
        else {
            writer.flush();
        }
    }

    @Override
    public void fileStarted(AuditEvent event) {
        fileMessages.put(event.getFileName(), new FileMessages());
    }

    @Override
    public void fileFinished(AuditEvent event) {
        final String fileName = event.getFileName();
        final FileMessages messages = fileMessages.get(fileName);

        synchronized (writerLock) {
            writeFileMessages(fileName, messages);
        }

        fileMessages.remove(fileName);
    }

    /**
     * Prints the file section with all file errors and exceptions.
     *
     * @param fileName The file name, as should be printed in the opening file tag.
     * @param messages The file messages.
     */
    private void writeFileMessages(String fileName, FileMessages messages) {
        writeFileOpeningTag(fileName);
        if (messages != null) {
            for (AuditEvent errorEvent : messages.getErrors()) {
                writeFileError(errorEvent);
            }
            for (Throwable exception : messages.getExceptions()) {
                writeException(exception);
            }
        }
        writeFileClosingTag();
    }

    /**
     * Prints the "file" opening tag with the given filename.
     *
     * @param fileName The filename to output.
     */
    private void writeFileOpeningTag(String fileName) {
        writer.println("<file name=\"" + encode(fileName) + "\">");
    }

    /**
     * Prints the "file" closing tag.
     */
    private void writeFileClosingTag() {
        writer.println("</file>");
    }

    @Override
    public void addError(AuditEvent event) {
        if (event.getSeverityLevel() != SeverityLevel.IGNORE) {
            final String fileName = event.getFileName();
            if (fileName == null || !fileMessages.containsKey(fileName)) {
                synchronized (writerLock) {
                    writeFileError(event);
                }
            }
            else {
                final FileMessages messages = fileMessages.get(fileName);
                messages.addError(event);
            }
        }
    }

    /**
     * Outputs the given event to the writer.
     *
     * @param event An event to print.
     */
    private void writeFileError(AuditEvent event) {
        writer.print("<error" + " line=\"" + event.getLine() + "\"");
        if (event.getColumn() > 0) {
            writer.print(" column=\"" + event.getColumn() + "\"");
        }
        writer.print(" severity=\""
                + event.getSeverityLevel().getName()
                + "\"");
        writer.print(" message=\""
                + encode(event.getMessage())
                + "\"");
        writer.print(" source=\"");
        if (event.getModuleId() == null) {
            writer.print(encode(event.getSourceName()));
        }
        else {
            writer.print(encode(event.getModuleId()));
        }
        writer.println("\"/>");
    }

    @Override
    public void addException(AuditEvent event, Throwable throwable) {
        final String fileName = event.getFileName();
        if (fileName == null || !fileMessages.containsKey(fileName)) {
            synchronized (writerLock) {
                writeException(throwable);
            }
        }
        else {
            final FileMessages messages = fileMessages.get(fileName);
            messages.addException(throwable);
        }
    }

    /**
     * Writes the exception event to the print writer.
     *
     * @param throwable The
     */
    private void writeException(Throwable throwable) {
        writer.println("<exception>");
        writer.println("<![CDATA[");

        final StringWriter stringWriter = new StringWriter();
        final PrintWriter printer = new PrintWriter(stringWriter);
        throwable.printStackTrace(printer);
        writer.println(encode(stringWriter.toString()));

        writer.println("]]>");
        writer.println("</exception>");
    }

    /**
     * Escape &lt;, &gt; &amp; &#39; and &quot; as their entities.
     *
     * @param value the value to escape.
     * @return the escaped value if necessary.
     */
    public static String encode(String value) {
        final StringBuilder sb = new StringBuilder(256);
        for (int i = 0; i < value.length(); i++) {
            final char chr = value.charAt(i);
            switch (chr) {
                case '<':
                    sb.append("&lt;");
                    break;
                case '>':
                    sb.append("&gt;");
                    break;
                case '\'':
                    sb.append("&apos;");
                    break;
                case '\"':
                    sb.append("&quot;");
                    break;
                case '&':
                    sb.append("&amp;");
                    break;
                case '\r':
                    break;
                case '\n':
                    sb.append("&#10;");
                    break;
                default:
                    if (Character.isISOControl(chr)) {
                        // true escape characters need '&' before but it also requires XML 1.1
                        // until https://github.com/checkstyle/checkstyle/issues/5168
                        sb.append("#x");
                        sb.append(Integer.toHexString(chr));
                        sb.append(';');
                    }
                    else {
                        sb.append(chr);
                    }
                    break;
            }
        }
        return sb.toString();
    }

    /**
     * Finds whether the given argument is character or entity reference.
     *
     * @param ent the possible entity to look for.
     * @return whether the given argument a character or entity reference
     */
    public static boolean isReference(String ent) {
        boolean reference = false;

        if (ent.charAt(0) == '&' && CommonUtil.endsWithChar(ent, ';')) {
            if (ent.charAt(1) == '#') {
                // prefix is "&#"
                int prefixLength = 2;

                int radix = BASE_10;
                if (ent.charAt(2) == 'x') {
                    prefixLength++;
                    radix = BASE_16;
                }
                try {
                    Integer.parseInt(
                        ent.substring(prefixLength, ent.length() - 1), radix);
                    reference = true;
                }
                catch (final NumberFormatException ignored) {
                    reference = false;
                }
            }
            else {
                final String name = ent.substring(1, ent.length() - 1);
                for (String element : ENTITIES) {
                    if (name.equals(element)) {
                        reference = true;
                        break;
                    }
                }
            }
        }

        return reference;
    }

    /**
     * The registered file messages.
     */
    private static class FileMessages {

        /** The file error events. */
        private final List<AuditEvent> errors = Collections.synchronizedList(new ArrayList<>());

        /** The file exceptions. */
        private final List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());

        /**
         * Returns the file error events.
         *
         * @return the file error events.
         */
        public List<AuditEvent> getErrors() {
            return Collections.unmodifiableList(errors);
        }

        /**
         * Adds the given error event to the messages.
         *
         * @param event the error event.
         */
        public void addError(AuditEvent event) {
            errors.add(event);
        }

        /**
         * Returns the file exceptions.
         *
         * @return the file exceptions.
         */
        public List<Throwable> getExceptions() {
            return Collections.unmodifiableList(exceptions);
        }

        /**
         * Adds the given exception to the messages.
         *
         * @param throwable the file exception
         */
        public void addException(Throwable throwable) {
            exceptions.add(throwable);
        }

    }

}