1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.util;
7   
8   import static org.jline.jansi.Ansi.ansi;
9   
10  import org.apache.logging.log4j.LogBuilder;
11  import org.apache.logging.log4j.LogManager;
12  import org.apache.logging.log4j.Logger;
13  import org.jline.jansi.Ansi;
14  import org.jline.jansi.Ansi.Color;
15  import org.xml.sax.SAXParseException;
16  
17  import java.net.URI;
18  import java.util.Set;
19  import java.util.stream.Collectors;
20  
21  import dev.metaschema.core.metapath.format.IPathFormatter;
22  import dev.metaschema.core.model.constraint.ConstraintValidationFinding;
23  import dev.metaschema.core.model.constraint.IConstraint.Level;
24  import dev.metaschema.core.model.validation.AbstractValidationResultProcessor;
25  import dev.metaschema.core.model.validation.IValidationFinding;
26  import dev.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
27  import dev.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
28  import dev.metaschema.modules.sarif.SarifValidationHandler;
29  import edu.umd.cs.findbugs.annotations.NonNull;
30  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
31  
32  /**
33   * Supports logging validation findings to the console using ANSI color codes to
34   * improve the visibility of warnings and errors.
35   */
36  public final class LoggingValidationHandler
37      extends AbstractValidationResultProcessor {
38    private static final Logger LOGGER = LogManager.getLogger(LoggingValidationHandler.class);
39  
40    @NonNull
41    private static final LoggingValidationHandler NO_LOG_EXCPTION_INSTANCE = new LoggingValidationHandler(false);
42    @NonNull
43    private static final LoggingValidationHandler LOG_EXCPTION_INSTANCE = new LoggingValidationHandler(true);
44  
45    private final boolean logExceptions;
46    @NonNull
47    private final IPathFormatter pathFormatter;
48  
49    /**
50     * Get a singleton instance of the logging validation handler.
51     * <p>
52     * This instance will not log exceptions.
53     *
54     * @return the instance
55     */
56    @NonNull
57    public static LoggingValidationHandler instance() {
58      return instance(false);
59    }
60  
61    /**
62     * Get a singleton instance of the logging validation handler.
63     *
64     * @param logExceptions
65     *          {@code true} if this instance will log exceptions or {@code false}
66     *          otherwise
67     * @return the instance
68     */
69    @SuppressFBWarnings(value = "SING_SINGLETON_GETTER_NOT_SYNCHRONIZED",
70        justification = "both values are class initialized")
71    @NonNull
72    public static LoggingValidationHandler instance(boolean logExceptions) {
73      return logExceptions ? LOG_EXCPTION_INSTANCE : NO_LOG_EXCPTION_INSTANCE;
74    }
75  
76    /**
77     * Create a new logging validation handler with a custom path formatter.
78     *
79     * @param pathFormatter
80     *          the path formatter to use for constraint validation findings
81     * @return a new handler instance
82     */
83    @NonNull
84    public static LoggingValidationHandler withPathFormatter(@NonNull IPathFormatter pathFormatter) {
85      return new LoggingValidationHandler(false, pathFormatter);
86    }
87  
88    /**
89     * Create a new logging validation handler with custom settings.
90     *
91     * @param logExceptions
92     *          {@code true} if this instance will log exceptions or {@code false}
93     *          otherwise
94     * @param pathFormatter
95     *          the path formatter to use for constraint validation findings
96     * @return a new handler instance
97     */
98    @NonNull
99    public static LoggingValidationHandler withSettings(
100       boolean logExceptions,
101       @NonNull IPathFormatter pathFormatter) {
102     return new LoggingValidationHandler(logExceptions, pathFormatter);
103   }
104 
105   private LoggingValidationHandler(boolean logExceptions) {
106     this(logExceptions, IPathFormatter.METAPATH_PATH_FORMATER);
107   }
108 
109   private LoggingValidationHandler(boolean logExceptions, @NonNull IPathFormatter pathFormatter) {
110     this.logExceptions = logExceptions;
111     this.pathFormatter = pathFormatter;
112   }
113 
114   /**
115    * Determine if exceptions should be logged.
116    *
117    * @return {@code true} if exceptions are logged or {@code false} otherwise
118    */
119   public boolean isLogExceptions() {
120     return logExceptions;
121   }
122 
123   @Override
124   protected void handleJsonValidationFinding(@NonNull JsonValidationFinding finding) {
125     Ansi ansi = generatePreamble(finding.getSeverity());
126 
127     ansi = ansi.a('[')
128         .fgBright(Color.WHITE)
129         .a(finding.getCause().getPointerToViolation())
130         .reset()
131         .a(']');
132 
133     URI documentUri = finding.getDocumentUri();
134     ansi = documentUri == null
135         ? ansi.format(" %s", finding.getMessage())
136         : ansi.format(" %s [%s]", finding.getMessage(), documentUri.toString());
137 
138     getLogger(finding).log(ansi);
139   }
140 
141   @Override
142   protected void handleXmlValidationFinding(XmlValidationFinding finding) {
143     Ansi ansi = generatePreamble(finding.getSeverity());
144     SAXParseException ex = finding.getCause();
145 
146     URI documentUri = finding.getDocumentUri();
147     ansi = documentUri == null
148         ? ansi.format("%s [{%d,%d}]",
149             finding.getMessage(),
150             ex.getLineNumber(),
151             ex.getColumnNumber())
152         : ansi.format("%s [%s{%d,%d}]",
153             finding.getMessage(),
154             documentUri.toString(),
155             ex.getLineNumber(),
156             ex.getColumnNumber());
157 
158     getLogger(finding).log(ansi);
159   }
160 
161   @Override
162   protected void handleConstraintValidationFinding(@NonNull ConstraintValidationFinding finding) {
163     Ansi ansi = generatePreamble(finding.getSeverity());
164 
165     ansi.format("[%s]", finding.getTarget().toPath(pathFormatter));
166 
167     String id = finding.getIdentifier();
168     if (id != null) {
169       ansi.format(" %s:", id);
170     }
171 
172     ansi.format(" %s", finding.getMessage());
173 
174     Set<String> helpUrls = finding.getConstraints().stream()
175         .flatMap(constraint -> constraint.getPropertyValues(SarifValidationHandler.SARIF_HELP_URL_KEY).stream())
176         .collect(Collectors.toSet());
177     if (!helpUrls.isEmpty()) {
178       ansi.format(" (help: %s)",
179           helpUrls.stream().collect(Collectors.joining(", ")));
180     }
181 
182     getLogger(finding).log(ansi);
183   }
184 
185   @NonNull
186   private LogBuilder getLogger(@NonNull IValidationFinding finding) {
187     LogBuilder retval;
188     switch (finding.getSeverity()) {
189     case CRITICAL:
190       retval = LOGGER.atFatal();
191       break;
192     case ERROR:
193       retval = LOGGER.atError();
194       break;
195     case WARNING:
196       retval = LOGGER.atWarn();
197       break;
198     case INFORMATIONAL:
199       retval = LOGGER.atInfo();
200       break;
201     case DEBUG:
202       retval = LOGGER.isDebugEnabled() ? LOGGER.atDebug() : LOGGER.atInfo();
203       break;
204     default:
205       throw new IllegalArgumentException("Unknown level: " + finding.getSeverity().name());
206     }
207 
208     assert retval != null;
209 
210     if (finding.getCause() != null && isLogExceptions()) {
211       retval.withThrowable(finding.getCause());
212     }
213 
214     return retval;
215   }
216 
217   @SuppressWarnings("static-method")
218   @NonNull
219   private Ansi generatePreamble(@NonNull Level level) {
220     Ansi ansi = ansi().fgBright(Color.WHITE).a('[').reset();
221 
222     switch (level) {
223     case CRITICAL:
224       ansi = ansi.fgRed().a("CRITICAL").reset();
225       break;
226     case ERROR:
227       ansi = ansi.fgBrightRed().a("ERROR").reset();
228       break;
229     case WARNING:
230       ansi = ansi.fgBrightYellow().a("WARNING").reset();
231       break;
232     case INFORMATIONAL:
233       ansi = ansi.fgBrightBlue().a("INFO").reset();
234       break;
235     case DEBUG:
236       ansi = ansi.fgBrightCyan().a("DEBUG").reset();
237       break;
238     default:
239       ansi = ansi().fgBright(Color.MAGENTA).a(level.name()).reset();
240       break;
241     }
242     ansi = ansi.fgBright(Color.WHITE).a("] ").reset();
243 
244     assert ansi != null;
245     return ansi;
246   }
247 }