001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.cli.util;
007
008import static org.jline.jansi.Ansi.ansi;
009
010import org.apache.logging.log4j.LogBuilder;
011import org.apache.logging.log4j.LogManager;
012import org.apache.logging.log4j.Logger;
013import org.jline.jansi.Ansi;
014import org.jline.jansi.Ansi.Color;
015import org.xml.sax.SAXParseException;
016
017import java.net.URI;
018import java.util.Set;
019import java.util.stream.Collectors;
020
021import dev.metaschema.core.metapath.format.IPathFormatter;
022import dev.metaschema.core.model.constraint.ConstraintValidationFinding;
023import dev.metaschema.core.model.constraint.IConstraint.Level;
024import dev.metaschema.core.model.validation.AbstractValidationResultProcessor;
025import dev.metaschema.core.model.validation.IValidationFinding;
026import dev.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
027import dev.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
028import dev.metaschema.modules.sarif.SarifValidationHandler;
029import edu.umd.cs.findbugs.annotations.NonNull;
030import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
031
032/**
033 * Supports logging validation findings to the console using ANSI color codes to
034 * improve the visibility of warnings and errors.
035 */
036public final class LoggingValidationHandler
037    extends AbstractValidationResultProcessor {
038  private static final Logger LOGGER = LogManager.getLogger(LoggingValidationHandler.class);
039
040  @NonNull
041  private static final LoggingValidationHandler NO_LOG_EXCPTION_INSTANCE = new LoggingValidationHandler(false);
042  @NonNull
043  private static final LoggingValidationHandler LOG_EXCPTION_INSTANCE = new LoggingValidationHandler(true);
044
045  private final boolean logExceptions;
046  @NonNull
047  private final IPathFormatter pathFormatter;
048
049  /**
050   * Get a singleton instance of the logging validation handler.
051   * <p>
052   * This instance will not log exceptions.
053   *
054   * @return the instance
055   */
056  @NonNull
057  public static LoggingValidationHandler instance() {
058    return instance(false);
059  }
060
061  /**
062   * Get a singleton instance of the logging validation handler.
063   *
064   * @param logExceptions
065   *          {@code true} if this instance will log exceptions or {@code false}
066   *          otherwise
067   * @return the instance
068   */
069  @SuppressFBWarnings(value = "SING_SINGLETON_GETTER_NOT_SYNCHRONIZED",
070      justification = "both values are class initialized")
071  @NonNull
072  public static LoggingValidationHandler instance(boolean logExceptions) {
073    return logExceptions ? LOG_EXCPTION_INSTANCE : NO_LOG_EXCPTION_INSTANCE;
074  }
075
076  /**
077   * Create a new logging validation handler with a custom path formatter.
078   *
079   * @param pathFormatter
080   *          the path formatter to use for constraint validation findings
081   * @return a new handler instance
082   */
083  @NonNull
084  public static LoggingValidationHandler withPathFormatter(@NonNull IPathFormatter pathFormatter) {
085    return new LoggingValidationHandler(false, pathFormatter);
086  }
087
088  /**
089   * Create a new logging validation handler with custom settings.
090   *
091   * @param logExceptions
092   *          {@code true} if this instance will log exceptions or {@code false}
093   *          otherwise
094   * @param pathFormatter
095   *          the path formatter to use for constraint validation findings
096   * @return a new handler instance
097   */
098  @NonNull
099  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}