001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.modules.sarif;
007
008import org.schemastore.json.sarif.x210.Artifact;
009import org.schemastore.json.sarif.x210.ArtifactLocation;
010import org.schemastore.json.sarif.x210.Invocation;
011import org.schemastore.json.sarif.x210.LetTimingEntry;
012import org.schemastore.json.sarif.x210.Location;
013import org.schemastore.json.sarif.x210.LogicalLocation;
014import org.schemastore.json.sarif.x210.Message;
015import org.schemastore.json.sarif.x210.MultiformatMessageString;
016import org.schemastore.json.sarif.x210.Notification;
017import org.schemastore.json.sarif.x210.PhysicalLocation;
018import org.schemastore.json.sarif.x210.PropertyBag;
019import org.schemastore.json.sarif.x210.Region;
020import org.schemastore.json.sarif.x210.ReportingDescriptor;
021import org.schemastore.json.sarif.x210.Result;
022import org.schemastore.json.sarif.x210.Run;
023import org.schemastore.json.sarif.x210.Sarif;
024import org.schemastore.json.sarif.x210.SarifModule;
025import org.schemastore.json.sarif.x210.TimingData;
026import org.schemastore.json.sarif.x210.Tool;
027import org.schemastore.json.sarif.x210.ToolComponent;
028
029import java.io.IOException;
030import java.io.StringWriter;
031import java.math.BigDecimal;
032import java.math.BigInteger;
033import java.math.RoundingMode;
034import java.net.URI;
035import java.net.URISyntaxException;
036import java.nio.file.Path;
037import java.nio.file.StandardOpenOption;
038import java.time.Instant;
039import java.time.ZoneOffset;
040import java.time.ZonedDateTime;
041import java.util.ArrayList;
042import java.util.Collection;
043import java.util.LinkedHashMap;
044import java.util.LinkedList;
045import java.util.List;
046import java.util.Map;
047import java.util.Set;
048import java.util.UUID;
049import java.util.concurrent.ConcurrentHashMap;
050import java.util.concurrent.atomic.AtomicInteger;
051
052import dev.metaschema.core.datatype.markup.MarkupLine;
053import dev.metaschema.core.datatype.markup.MarkupMultiline;
054import dev.metaschema.core.metapath.item.node.INodeItem;
055import dev.metaschema.core.model.IAttributable;
056import dev.metaschema.core.model.IResourceLocation;
057import dev.metaschema.core.model.MetaschemaException;
058import dev.metaschema.core.model.constraint.ConstraintValidationFinding;
059import dev.metaschema.core.model.constraint.IConstraint;
060import dev.metaschema.core.model.constraint.IConstraint.Level;
061import dev.metaschema.core.model.constraint.ILet;
062import dev.metaschema.core.model.constraint.TimingCollector;
063import dev.metaschema.core.model.constraint.TimingRecord;
064import dev.metaschema.core.model.constraint.ValidationEventListener;
065import dev.metaschema.core.model.constraint.ValidationPhase;
066import dev.metaschema.core.model.validation.IValidationFinding;
067import dev.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
068import dev.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
069import dev.metaschema.core.util.CollectionUtil;
070import dev.metaschema.core.util.IVersionInfo;
071import dev.metaschema.core.util.ObjectUtils;
072import dev.metaschema.core.util.UriUtils;
073import dev.metaschema.databind.IBindingContext;
074import dev.metaschema.databind.io.Format;
075import dev.metaschema.databind.io.SerializationFeature;
076import edu.umd.cs.findbugs.annotations.NonNull;
077import edu.umd.cs.findbugs.annotations.Nullable;
078
079/**
080 * Supports building a Static Analysis Results Interchange Format (SARIF)
081 * document based on a set of validation findings.
082 */
083@SuppressWarnings("PMD.CouplingBetweenObjects")
084public final class SarifValidationHandler implements ValidationEventListener {
085  private enum Kind {
086    NOT_APPLICABLE("notApplicable"),
087    PASS("pass"),
088    FAIL("fail"),
089    REVIEW("review"),
090    OPEN("open"),
091    INFORMATIONAL("informational");
092
093    @NonNull
094    private final String label;
095
096    Kind(@NonNull String label) {
097      this.label = label;
098    }
099
100    @NonNull
101    public String getLabel() {
102      return label;
103    }
104  }
105
106  private enum SeverityLevel {
107    NONE("none"),
108    NOTE("note"),
109    WARNING("warning"),
110    ERROR("error");
111
112    @NonNull
113    private final String label;
114
115    SeverityLevel(@NonNull String label) {
116      this.label = label;
117    }
118
119    @NonNull
120    public String getLabel() {
121      return label;
122    }
123  }
124
125  @NonNull
126  static final String SARIF_NS = "https://docs.oasis-open.org/sarif/sarif/v2.1.0";
127  /**
128   * The property key for specifying a URL that provides help information for a
129   * constraint.
130   */
131  @NonNull
132  public static final IAttributable.Key SARIF_HELP_URL_KEY
133      = IAttributable.key("help-url", SARIF_NS);
134  /**
135   * The property key for specifying plain text help content for a constraint.
136   */
137  @NonNull
138  public static final IAttributable.Key SARIF_HELP_TEXT_KEY
139      = IAttributable.key("help-text", SARIF_NS);
140  /**
141   * The property key for specifying markdown-formatted help content for a
142   * constraint.
143   */
144  @NonNull
145  public static final IAttributable.Key SARIF_HELP_MARKDOWN_KEY
146      = IAttributable.key("help-markdown", SARIF_NS);
147
148  @NonNull
149  private final URI source;
150  @Nullable
151  private final IVersionInfo toolVersion;
152  private final AtomicInteger artifactIndex = new AtomicInteger(-1);
153  private final AtomicInteger ruleIndex = new AtomicInteger(-1);
154
155  @SuppressWarnings("PMD.UseConcurrentHashMap")
156  @NonNull
157  private final Map<URI, ArtifactRecord> artifacts = new LinkedHashMap<>();
158  @NonNull
159  private final List<AbstractRuleRecord> rules = new LinkedList<>();
160  @SuppressWarnings("PMD.UseConcurrentHashMap")
161  @NonNull
162  private final Map<IConstraint, ConstraintRuleRecord> constraintRules = new LinkedHashMap<>();
163  @NonNull
164  private final List<IResult> results = new LinkedList<>();
165  @NonNull
166  private final SchemaRuleRecord schemaRule = new SchemaRuleRecord();
167  private boolean schemaValid = true;
168  @Nullable
169  private TimingCollector timingCollector;
170  @NonNull
171  private final Instant constructionTimestamp = Instant.now();
172  private final ThreadLocal<Long> currentEvaluationStartNanos = new ThreadLocal<>();
173  private final ThreadLocal<List<ConstraintResult>> currentEvaluationResults = new ThreadLocal<>();
174  private final ThreadLocal<Long> currentLetStartNanos = new ThreadLocal<>();
175  @SuppressWarnings("PMD.UseConcurrentHashMap")
176  private final ThreadLocal<Map<ILet, Long>> currentLetDurations = new ThreadLocal<>();
177  @NonNull
178  private final ConcurrentHashMap<IConstraint, EvaluationTimingSnapshot> evaluationTimings
179      = new ConcurrentHashMap<>();
180
181  /**
182   * Construct a new validation handler.
183   *
184   * @param source
185   *          the URI of the content that was validated
186   * @param toolVersion
187   *          the version information for the tool producing the validation
188   *          results
189   */
190  public SarifValidationHandler(
191      @NonNull URI source,
192      @Nullable IVersionInfo toolVersion) {
193    if (!source.isAbsolute()) {
194      throw new IllegalArgumentException(String.format("The source URI '%s' is not absolute.", source.toASCIIString()));
195    }
196
197    this.source = source;
198    this.toolVersion = toolVersion;
199  }
200
201  @NonNull
202  private URI getSource() {
203    return source;
204  }
205
206  private IVersionInfo getToolVersion() {
207    return toolVersion;
208  }
209
210  /**
211   * Set the timing collector to enrich SARIF output with performance data.
212   * <p>
213   * When set, the generated SARIF document will include:
214   * <ul>
215   * <li>An invocation element with start/end timestamps</li>
216   * <li>Phase timing as tool execution notifications</li>
217   * <li>Per-constraint timing in rule properties</li>
218   * </ul>
219   *
220   * @param collector
221   *          the timing collector containing measurement data, or {@code null} to
222   *          disable timing output
223   */
224  public void setTimingCollector(@Nullable TimingCollector collector) {
225    this.timingCollector = collector;
226  }
227
228  @Override
229  public void beforeValidation(@NonNull URI document) {
230    // No-op: always-on timing uses construction timestamp
231  }
232
233  @Override
234  public void afterValidation(@NonNull URI document) {
235    // No-op: always-on timing captures end time at SARIF generation
236  }
237
238  @Override
239  public void beforePhase(@NonNull ValidationPhase phase) {
240    // No-op: phase timing is handled by TimingCollector
241  }
242
243  @Override
244  public void afterPhase(@NonNull ValidationPhase phase) {
245    // No-op: phase timing is handled by TimingCollector
246  }
247
248  @Override
249  public void beforeConstraintEvaluation(@NonNull IConstraint constraint, @NonNull INodeItem target) {
250    currentEvaluationStartNanos.set(System.nanoTime());
251    currentEvaluationResults.set(new ArrayList<>());
252    currentLetDurations.set(new LinkedHashMap<>());
253  }
254
255  @SuppressWarnings("PMD.NullAssignment") // ThreadLocal cleanup
256  @Override
257  public void afterConstraintEvaluation(@NonNull IConstraint constraint, @NonNull INodeItem target) {
258    Long startNanos = currentEvaluationStartNanos.get();
259    List<ConstraintResult> evaluationResults = currentEvaluationResults.get();
260    Map<ILet, Long> letDurations = currentLetDurations.get();
261
262    if (startNanos != null) {
263      long durationNs = System.nanoTime() - startNanos;
264      Map<ILet, Long> snapshotLetDurations = letDurations != null && !letDurations.isEmpty()
265          ? new LinkedHashMap<>(letDurations)
266          : null;
267
268      // Set timing on inline results (added during this evaluation)
269      if (evaluationResults != null) {
270        for (ConstraintResult result : evaluationResults) {
271          result.setEvaluationDurationNs(durationNs);
272          if (snapshotLetDurations != null) {
273            result.setLetDurations(snapshotLetDurations);
274          }
275        }
276      }
277
278      // Store for deferred lookup (when findings are added after validation)
279      evaluationTimings.put(constraint,
280          new EvaluationTimingSnapshot(durationNs, snapshotLetDurations));
281    }
282
283    currentEvaluationStartNanos.remove();
284    currentEvaluationResults.remove();
285    currentLetStartNanos.remove();
286    currentLetDurations.remove();
287  }
288
289  @Override
290  public void beforeLetEvaluation(@NonNull ILet let) {
291    currentLetStartNanos.set(System.nanoTime());
292  }
293
294  @Override
295  public void afterLetEvaluation(@NonNull ILet let) {
296    Long startNanos = currentLetStartNanos.get();
297    Map<ILet, Long> letDurations = currentLetDurations.get();
298    if (startNanos != null && letDurations != null) {
299      long durationNs = System.nanoTime() - startNanos;
300      letDurations.merge(let, durationNs, Long::sum);
301    }
302    currentLetStartNanos.remove();
303  }
304
305  @NonNull
306  private static final BigDecimal NS_PER_MS = BigDecimal.valueOf(1_000_000L);
307
308  /**
309   * Convert nanoseconds to milliseconds as a BigDecimal with 3 decimal places.
310   *
311   * @param nanoseconds
312   *          the duration in nanoseconds
313   * @return the duration in milliseconds
314   */
315  @NonNull
316  private static BigDecimal nsToMs(long nanoseconds) {
317    return ObjectUtils.notNull(
318        BigDecimal.valueOf(nanoseconds).divide(NS_PER_MS, 3, RoundingMode.HALF_UP));
319  }
320
321  /**
322   * Convert a {@link TimingRecord} to a SARIF {@link TimingData} object.
323   *
324   * @param record
325   *          the timing record to convert
326   * @return the SARIF timing data
327   */
328  @NonNull
329  private static TimingData toTimingData(@NonNull TimingRecord record) {
330    TimingData data = new TimingData();
331    data.setTotalMs(nsToMs(record.getTotalTimeNs()));
332    data.setCount(BigInteger.valueOf(record.getCount()));
333    if (record.getCount() > 0) {
334      data.setMinMs(nsToMs(record.getMinTimeNs()));
335      data.setMaxMs(nsToMs(record.getMaxTimeNs()));
336    }
337    return data;
338  }
339
340  /**
341   * Register a collection of validation finding.
342   *
343   * @param findings
344   *          the findings to register
345   */
346  public void addFindings(@NonNull Collection<? extends IValidationFinding> findings) {
347    for (IValidationFinding finding : findings) {
348      assert finding != null;
349      addFinding(finding);
350    }
351  }
352
353  /**
354   * Register a validation finding.
355   *
356   * @param finding
357   *          the finding to register
358   */
359  public void addFinding(@NonNull IValidationFinding finding) {
360    if (finding instanceof JsonValidationFinding) {
361      addJsonValidationFinding((JsonValidationFinding) finding);
362    } else if (finding instanceof XmlValidationFinding) {
363      addXmlValidationFinding((XmlValidationFinding) finding);
364    } else if (finding instanceof ConstraintValidationFinding) {
365      addConstraintValidationFinding((ConstraintValidationFinding) finding);
366    } else {
367      throw new IllegalStateException();
368    }
369  }
370
371  private ConstraintRuleRecord getRuleRecord(@NonNull IConstraint constraint) {
372    ConstraintRuleRecord retval = constraintRules.get(constraint);
373    if (retval == null) {
374      retval = new ConstraintRuleRecord(constraint);
375      constraintRules.put(constraint, retval);
376      rules.add(retval);
377    }
378    return retval;
379  }
380
381  private ArtifactRecord getArtifactRecord(@NonNull URI artifactUri) {
382    ArtifactRecord retval = artifacts.get(artifactUri);
383    if (retval == null) {
384      retval = new ArtifactRecord(artifactUri);
385      artifacts.put(artifactUri, retval);
386    }
387    return retval;
388  }
389
390  private void addJsonValidationFinding(@NonNull JsonValidationFinding finding) {
391    results.add(new SchemaResult(finding));
392    if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
393      schemaValid = false;
394    }
395  }
396
397  private void addXmlValidationFinding(@NonNull XmlValidationFinding finding) {
398    results.add(new SchemaResult(finding));
399    if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
400      schemaValid = false;
401    }
402  }
403
404  private void addConstraintValidationFinding(@NonNull ConstraintValidationFinding finding) {
405    ConstraintResult constraintResult = new ConstraintResult(finding);
406    results.add(constraintResult);
407
408    // Track for per-evaluation timing if within a constraint evaluation (inline)
409    List<ConstraintResult> evaluationResults = currentEvaluationResults.get();
410    if (evaluationResults != null) {
411      evaluationResults.add(constraintResult);
412    } else {
413      // Deferred pattern: look up timing from the most recent evaluation
414      for (IConstraint constraint : finding.getConstraints()) {
415        EvaluationTimingSnapshot snapshot = evaluationTimings.get(constraint);
416        if (snapshot != null) {
417          constraintResult.setEvaluationDurationNs(snapshot.durationNs);
418          if (snapshot.letDurations != null) {
419            constraintResult.setLetDurations(snapshot.letDurations);
420          }
421          break;
422        }
423      }
424    }
425  }
426
427  /**
428   * Generate a SARIF document based on the collected findings.
429   *
430   * @param outputUri
431   *          the URI to use as the base for relative paths in the SARIF document
432   * @return the generated SARIF document
433   * @throws IOException
434   *           if an error occurred while generating the SARIF document
435   */
436  @NonNull
437  private Sarif generateSarif(@NonNull URI outputUri) throws IOException {
438    Sarif sarif = new Sarif();
439    sarif.setVersion("2.1.0");
440
441    Run run = new Run();
442    sarif.addRun(run);
443
444    Artifact artifact = new Artifact();
445    artifact.setLocation(getArtifactRecord(getSource()).generateArtifactLocation(outputUri));
446    run.addArtifact(artifact);
447
448    for (IResult result : results) {
449      result.generateResults(outputUri).forEach(run::addResult);
450    }
451
452    IVersionInfo toolVersion = getToolVersion();
453    if (!rules.isEmpty() || toolVersion != null) {
454      Tool tool = new Tool();
455      ToolComponent driver = new ToolComponent();
456
457      if (toolVersion != null) {
458        driver.setName(toolVersion.getName());
459        driver.setVersion(toolVersion.getVersion());
460      }
461
462      for (AbstractRuleRecord rule : rules) {
463        driver.addRule(rule.generate());
464      }
465
466      tool.setDriver(driver);
467      run.setTool(tool);
468    }
469
470    enrichWithTiming(run);
471
472    return sarif;
473  }
474
475  /**
476   * Enrich the SARIF run with timing data.
477   * <p>
478   * Always creates an invocation with start/end timestamps (always-on timing). If
479   * a timing collector is set, overrides timestamps from the collector and adds
480   * phase/let-statement timing as tool execution notifications.
481   *
482   * @param run
483   *          the SARIF run to enrich
484   */
485  @SuppressWarnings("PMD.CognitiveComplexity")
486  private void enrichWithTiming(@NonNull Run run) {
487    // Always create invocation with timestamps (always-on timing)
488    Invocation invocation = new Invocation();
489    invocation.setExecutionSuccessful(Boolean.TRUE);
490    invocation.setStartTimeUtc(ZonedDateTime.ofInstant(constructionTimestamp, ZoneOffset.UTC));
491    invocation.setEndTimeUtc(ZonedDateTime.ofInstant(Instant.now(), ZoneOffset.UTC));
492
493    TimingCollector collector = this.timingCollector;
494    if (collector != null) {
495      // Override with collector timestamps if available
496      TimingRecord validationTiming = collector.getValidationTiming();
497      if (validationTiming != null) {
498        Instant start = validationTiming.getStartTimestampUtc();
499        if (start != null) {
500          invocation.setStartTimeUtc(ZonedDateTime.ofInstant(start, ZoneOffset.UTC));
501        }
502        Instant end = validationTiming.getEndTimestampUtc();
503        if (end != null) {
504          invocation.setEndTimeUtc(ZonedDateTime.ofInstant(end, ZoneOffset.UTC));
505        }
506      }
507
508      // Add phase timing as notifications
509      for (Map.Entry<ValidationPhase, TimingRecord> entry : collector.getPhaseTimings().entrySet()) {
510        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
511        Notification notification = new Notification();
512        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
513        Message msg = new Message();
514        msg.setText("Phase: " + entry.getKey().name());
515        notification.setMessage(msg);
516
517        TimingRecord phaseRecord = entry.getValue();
518        Instant phaseEnd = phaseRecord.getEndTimestampUtc();
519        if (phaseEnd != null) {
520          notification.setTimeUtc(ZonedDateTime.ofInstant(phaseEnd, ZoneOffset.UTC));
521        }
522
523        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
524        PropertyBag phaseProps = new PropertyBag();
525        phaseProps.setTiming(toTimingData(phaseRecord));
526        notification.setProperties(phaseProps);
527
528        invocation.addToolExecutionNotification(notification);
529      }
530
531      // Add let-statement timing as notifications
532      for (Map.Entry<ILet, TimingRecord> entry : collector.getLetTimings().entrySet()) {
533        ILet let = entry.getKey();
534
535        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
536        Notification notification = new Notification();
537        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
538        Message msg = new Message();
539        msg.setText("$" + let.getName().getLocalName() + " := " + let.getValueExpression().getPath());
540        notification.setMessage(msg);
541
542        TimingRecord letRecord = entry.getValue();
543        Instant letEnd = letRecord.getEndTimestampUtc();
544        if (letEnd != null) {
545          notification.setTimeUtc(ZonedDateTime.ofInstant(letEnd, ZoneOffset.UTC));
546        }
547
548        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
549        PropertyBag letProps = new PropertyBag();
550        letProps.setTiming(toTimingData(letRecord));
551        notification.setProperties(letProps);
552
553        invocation.addToolExecutionNotification(notification);
554      }
555    }
556
557    run.addInvocation(invocation);
558  }
559
560  /**
561   * Write the collection of findings to a string in SARIF format.
562   *
563   * @param bindingContext
564   *          the context used to access Metaschema module information based on
565   *          Java class bindings
566   * @return the SARIF document as a string
567   * @throws IOException
568   *           if an error occurred while generating the SARIF document
569   */
570  @NonNull
571  public String writeToString(@NonNull IBindingContext bindingContext) throws IOException {
572    registerSarifMetaschemaModule(bindingContext);
573    try (StringWriter writer = new StringWriter()) {
574      bindingContext.newSerializer(Format.JSON, Sarif.class)
575          .disableFeature(SerializationFeature.SERIALIZE_ROOT)
576          .serialize(generateSarif(getSource()), writer);
577      return ObjectUtils.notNull(writer.toString());
578    }
579  }
580
581  /**
582   * Write the collection of findings to the provided output file.
583   *
584   * @param outputFile
585   *          the path to the output file to write to
586   * @param bindingContext
587   *          the context used to access Metaschema module information based on
588   *          Java class bindings
589   * @throws IOException
590   *           if an error occurred while writing the SARIF file
591   */
592  public void write(
593      @NonNull Path outputFile,
594      @NonNull IBindingContext bindingContext) throws IOException {
595
596    URI output = ObjectUtils.notNull(outputFile.toUri());
597    Sarif sarif = generateSarif(output);
598
599    registerSarifMetaschemaModule(bindingContext);
600    bindingContext.newSerializer(Format.JSON, Sarif.class)
601        .disableFeature(SerializationFeature.SERIALIZE_ROOT)
602        .serialize(
603            sarif,
604            outputFile,
605            StandardOpenOption.CREATE,
606            StandardOpenOption.WRITE,
607            StandardOpenOption.TRUNCATE_EXISTING);
608  }
609
610  private static void registerSarifMetaschemaModule(@NonNull IBindingContext bindingContext) {
611    try {
612      bindingContext.registerModule(SarifModule.class);
613    } catch (MetaschemaException ex) {
614      throw new IllegalStateException("Unable to register the builtin SARIF module.", ex);
615    }
616  }
617
618  private interface IResult {
619    @NonNull
620    IValidationFinding getFinding();
621
622    @NonNull
623    List<Result> generateResults(@NonNull URI output) throws IOException;
624  }
625
626  private abstract class AbstractResult<T extends IValidationFinding> implements IResult {
627    @NonNull
628    private final T finding;
629
630    protected AbstractResult(@NonNull T finding) {
631      this.finding = finding;
632    }
633
634    @Override
635    public T getFinding() {
636      return finding;
637    }
638
639    @NonNull
640    protected Kind kind(@NonNull IValidationFinding finding) {
641      IValidationFinding.Kind kind = finding.getKind();
642
643      Kind retval;
644      switch (kind) {
645      case FAIL:
646        retval = Kind.FAIL;
647        break;
648      case INFORMATIONAL:
649        retval = Kind.INFORMATIONAL;
650        break;
651      case NOT_APPLICABLE:
652        retval = Kind.NOT_APPLICABLE;
653        break;
654      case PASS:
655        retval = Kind.PASS;
656        break;
657      default:
658        throw new IllegalArgumentException(String.format("Invalid finding kind '%s'.", kind));
659      }
660      return retval;
661    }
662
663    @NonNull
664    protected SeverityLevel level(@NonNull Level severity) {
665      SeverityLevel retval;
666      switch (severity) {
667      case CRITICAL:
668      case ERROR:
669        retval = SeverityLevel.ERROR;
670        break;
671      case INFORMATIONAL:
672      case DEBUG:
673        retval = SeverityLevel.NOTE;
674        break;
675      case WARNING:
676        retval = SeverityLevel.WARNING;
677        break;
678      case NONE:
679        retval = SeverityLevel.NONE;
680        break;
681      default:
682        throw new IllegalArgumentException(String.format("Invalid severity '%s'.", severity));
683      }
684      return retval;
685    }
686
687    protected void message(@NonNull IValidationFinding finding, @NonNull Result result) {
688      String message = finding.getMessage();
689      if (message == null) {
690        message = "";
691      }
692
693      Message msg = new Message();
694      msg.setText(message);
695      result.setMessage(msg);
696    }
697
698    protected void location(@NonNull IValidationFinding finding, @NonNull Result result, @NonNull URI base)
699        throws IOException {
700      IResourceLocation location = finding.getLocation();
701      if (location != null) {
702        // region
703        Region region = new Region();
704
705        if (location.getLine() > -1) {
706          region.setStartLine(BigInteger.valueOf(location.getLine()));
707          region.setEndLine(BigInteger.valueOf(location.getLine()));
708        }
709        if (location.getColumn() > -1) {
710          region.setStartColumn(BigInteger.valueOf(location.getColumn() + 1));
711          region.setEndColumn(BigInteger.valueOf(location.getColumn() + 1));
712        }
713        if (location.getByteOffset() > -1) {
714          region.setByteOffset(BigInteger.valueOf(location.getByteOffset()));
715          region.setByteLength(BigInteger.ZERO);
716        }
717        if (location.getCharOffset() > -1) {
718          region.setCharOffset(BigInteger.valueOf(location.getCharOffset()));
719          region.setCharLength(BigInteger.ZERO);
720        }
721
722        PhysicalLocation physical = new PhysicalLocation();
723
724        URI documentUri = finding.getDocumentUri();
725        if (documentUri != null) {
726          physical.setArtifactLocation(getArtifactRecord(documentUri).generateArtifactLocation(base));
727        }
728        physical.setRegion(region);
729
730        LogicalLocation logical = new LogicalLocation();
731
732        logical.setDecoratedName(finding.getPath());
733
734        Location loc = new Location();
735        loc.setPhysicalLocation(physical);
736        loc.addLogicalLocation(logical);
737        result.addLocation(loc);
738      }
739    }
740  }
741
742  private final class SchemaResult
743      extends AbstractResult<IValidationFinding> {
744
745    protected SchemaResult(@NonNull IValidationFinding finding) {
746      super(finding);
747    }
748
749    @Override
750    public List<Result> generateResults(@NonNull URI output) throws IOException {
751      IValidationFinding finding = getFinding();
752
753      Result result = new Result();
754
755      result.setRuleId(schemaRule.getId());
756      result.setRuleIndex(BigInteger.valueOf(schemaRule.getIndex()));
757      result.setGuid(schemaRule.getGuid());
758
759      result.setKind(kind(finding).getLabel());
760      result.setLevel(level(finding.getSeverity()).getLabel());
761      message(finding, result);
762      location(finding, result, output);
763
764      return CollectionUtil.singletonList(result);
765    }
766  }
767
768  private final class ConstraintResult
769      extends AbstractResult<ConstraintValidationFinding> {
770    @Nullable
771    private Long evaluationDurationNs;
772    @Nullable
773    private Map<ILet, Long> letDurations;
774
775    protected ConstraintResult(@NonNull ConstraintValidationFinding finding) {
776      super(finding);
777    }
778
779    void setEvaluationDurationNs(long durationNs) {
780      this.evaluationDurationNs = durationNs;
781    }
782
783    void setLetDurations(@NonNull Map<ILet, Long> durations) {
784      this.letDurations = durations;
785    }
786
787    @Override
788    public List<Result> generateResults(@NonNull URI output) throws IOException {
789      ConstraintValidationFinding finding = getFinding();
790
791      List<Result> retval = new LinkedList<>();
792
793      Kind kind = kind(finding);
794      SeverityLevel level = level(finding.getSeverity());
795
796      for (IConstraint constraint : finding.getConstraints()) {
797        assert constraint != null;
798        ConstraintRuleRecord rule = getRuleRecord(constraint);
799
800        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
801        Result result = new Result();
802
803        String id = constraint.getId();
804        if (id != null) {
805          result.setRuleId(id);
806        }
807        result.setRuleIndex(BigInteger.valueOf(rule.getIndex()));
808        result.setGuid(rule.getGuid());
809        result.setKind(kind.getLabel());
810        result.setLevel(level.getLabel());
811        message(finding, result);
812        location(finding, result, output);
813        addPerResultTiming(result);
814
815        retval.add(result);
816      }
817      return retval;
818    }
819
820    @SuppressWarnings("PMD.CognitiveComplexity")
821    private void addPerResultTiming(@NonNull Result result) {
822      Long durationNs = this.evaluationDurationNs;
823      if (durationNs == null) {
824        return;
825      }
826
827      PropertyBag props = result.getProperties();
828      if (props == null) {
829        props = new PropertyBag();
830        result.setProperties(props);
831      }
832
833      TimingData timing = new TimingData();
834      timing.setTotalMs(nsToMs(durationNs));
835      timing.setCount(BigInteger.ONE);
836      props.setTiming(timing);
837
838      Map<ILet, Long> letDurs = this.letDurations;
839      if (letDurs != null) {
840        for (Map.Entry<ILet, Long> entry : letDurs.entrySet()) {
841          @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
842          LetTimingEntry letEntry = new LetTimingEntry();
843          letEntry.setName(entry.getKey().getName().getLocalName());
844
845          @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
846          TimingData letTiming = new TimingData();
847          letTiming.setTotalMs(nsToMs(entry.getValue()));
848          letTiming.setCount(BigInteger.ONE);
849          letEntry.setTiming(letTiming);
850
851          props.addLetTimingEntry(letEntry);
852        }
853      }
854    }
855  }
856
857  private abstract class AbstractRuleRecord {
858    private final int index;
859    @NonNull
860    private final UUID guid;
861
862    private AbstractRuleRecord() {
863      this.index = ruleIndex.addAndGet(1);
864      this.guid = ObjectUtils.notNull(UUID.randomUUID());
865    }
866
867    public int getIndex() {
868      return index;
869    }
870
871    @NonNull
872    public UUID getGuid() {
873      return guid;
874    }
875
876    @NonNull
877    protected abstract ReportingDescriptor generate();
878  }
879
880  private final class SchemaRuleRecord
881      extends AbstractRuleRecord {
882
883    @Override
884    protected ReportingDescriptor generate() {
885      ReportingDescriptor retval = new ReportingDescriptor();
886      retval.setId(getId());
887      retval.setGuid(getGuid());
888      return retval;
889    }
890
891    public String getId() {
892      return "schema-valid";
893    }
894  }
895
896  private final class ConstraintRuleRecord
897      extends AbstractRuleRecord {
898    @NonNull
899    private final IConstraint constraint;
900
901    public ConstraintRuleRecord(@NonNull IConstraint constraint) {
902      this.constraint = constraint;
903    }
904
905    @NonNull
906    public IConstraint getConstraint() {
907      return constraint;
908    }
909
910    @Override
911    protected ReportingDescriptor generate() {
912      ReportingDescriptor retval = new ReportingDescriptor();
913      IConstraint constraint = getConstraint();
914
915      UUID guid = getGuid();
916
917      String id = constraint.getId();
918      if (id == null) {
919        retval.setId(guid.toString());
920      } else {
921        retval.setId(id);
922      }
923      retval.setGuid(guid);
924      String formalName = constraint.getFormalName();
925      if (formalName != null) {
926        MultiformatMessageString text = new MultiformatMessageString();
927        text.setText(formalName);
928        retval.setShortDescription(text);
929      }
930      MarkupLine description = constraint.getDescription();
931      if (description != null) {
932        MultiformatMessageString text = new MultiformatMessageString();
933        text.setText(description.toText());
934        text.setMarkdown(description.toMarkdown());
935        retval.setFullDescription(text);
936      }
937
938      Set<String> helpUrls = constraint.getPropertyValues(SARIF_HELP_URL_KEY);
939      if (!helpUrls.isEmpty()) {
940        retval.setHelpUri(URI.create(helpUrls.stream().findFirst().get()));
941      }
942
943      Set<String> helpText = constraint.getPropertyValues(SARIF_HELP_TEXT_KEY);
944      Set<String> helpMarkdown = constraint.getPropertyValues(SARIF_HELP_MARKDOWN_KEY);
945      // if there is help text or markdown, produce a message
946      if (!helpText.isEmpty() || !helpMarkdown.isEmpty()) {
947        MultiformatMessageString help = new MultiformatMessageString();
948
949        MarkupMultiline markdown = helpMarkdown.stream().map(MarkupMultiline::fromMarkdown).findFirst().orElse(null);
950        if (markdown != null) {
951          // markdown is provided
952          help.setMarkdown(markdown.toMarkdown());
953        }
954
955        String text = helpText.isEmpty()
956            ? ObjectUtils.requireNonNull(markdown).toText() // if text is empty, markdown must be provided
957            : helpText.stream().findFirst().get(); // use the provided text
958        help.setText(text);
959
960        retval.setHelp(help);
961      }
962
963      // Add timing data if available
964      TimingCollector collector = timingCollector;
965      if (collector != null) {
966        TimingRecord record = collector.getConstraintTiming(constraint.getInternalIdentifier());
967        if (record != null) {
968          PropertyBag props = retval.getProperties();
969          if (props == null) {
970            props = new PropertyBag();
971            retval.setProperties(props);
972          }
973          props.setTiming(toTimingData(record));
974        }
975      }
976
977      return retval;
978    }
979
980  }
981
982  private final class ArtifactRecord {
983    @NonNull
984    private final URI uri;
985    private final int index;
986
987    public ArtifactRecord(@NonNull URI uri) {
988      this.uri = uri;
989      this.index = artifactIndex.addAndGet(1);
990    }
991
992    @NonNull
993    public URI getUri() {
994      return uri;
995    }
996
997    public int getIndex() {
998      return index;
999    }
1000
1001    public ArtifactLocation generateArtifactLocation(@NonNull URI baseUri) throws IOException {
1002      ArtifactLocation location = new ArtifactLocation();
1003
1004      try {
1005        location.setUri(UriUtils.relativize(baseUri, getUri(), true));
1006      } catch (URISyntaxException ex) {
1007        throw new IOException(ex);
1008      }
1009
1010      location.setIndex(BigInteger.valueOf(getIndex()));
1011      return location;
1012    }
1013  }
1014
1015  /**
1016   * Snapshot of per-evaluation timing data, stored for deferred lookup when
1017   * findings are added after validation completes.
1018   */
1019  private static final class EvaluationTimingSnapshot {
1020    final long durationNs;
1021    @Nullable
1022    final Map<ILet, Long> letDurations;
1023
1024    EvaluationTimingSnapshot(long durationNs, @Nullable Map<ILet, Long> letDurations) {
1025      this.durationNs = durationNs;
1026      this.letDurations = letDurations;
1027    }
1028  }
1029}