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}