001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.maven.plugin;
007
008import gov.nist.secauto.metaschema.core.metapath.MetapathException;
009import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
010import gov.nist.secauto.metaschema.core.model.IModule;
011import gov.nist.secauto.metaschema.core.model.IResourceLocation;
012import gov.nist.secauto.metaschema.core.model.MetaschemaException;
013import gov.nist.secauto.metaschema.core.model.constraint.ConstraintValidationFinding;
014import gov.nist.secauto.metaschema.core.model.constraint.ExternalConstraintsModulePostProcessor;
015import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
016import gov.nist.secauto.metaschema.core.model.validation.AbstractValidationResultProcessor;
017import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding;
018import gov.nist.secauto.metaschema.core.model.validation.IValidationResult;
019import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
020import gov.nist.secauto.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
021import gov.nist.secauto.metaschema.core.util.CollectionUtil;
022import gov.nist.secauto.metaschema.core.util.ObjectUtils;
023import gov.nist.secauto.metaschema.databind.DefaultBindingContext;
024import gov.nist.secauto.metaschema.databind.IBindingContext;
025import gov.nist.secauto.metaschema.databind.PostProcessingModuleLoaderStrategy;
026import gov.nist.secauto.metaschema.databind.SimpleModuleLoaderStrategy;
027import gov.nist.secauto.metaschema.databind.codegen.IGeneratedClass;
028import gov.nist.secauto.metaschema.databind.codegen.IGeneratedModuleClass;
029import gov.nist.secauto.metaschema.databind.codegen.IModuleBindingGenerator;
030import gov.nist.secauto.metaschema.databind.codegen.IProduction;
031import gov.nist.secauto.metaschema.databind.codegen.JavaCompilerSupport;
032import gov.nist.secauto.metaschema.databind.codegen.JavaGenerator;
033import gov.nist.secauto.metaschema.databind.codegen.ModuleCompilerHelper;
034import gov.nist.secauto.metaschema.databind.codegen.config.DefaultBindingConfiguration;
035import gov.nist.secauto.metaschema.databind.codegen.config.IBindingConfiguration;
036import gov.nist.secauto.metaschema.databind.model.IBoundModule;
037import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingMetaschemaModule;
038import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingModuleLoader;
039
040import org.apache.maven.artifact.Artifact;
041import org.apache.maven.artifact.DependencyResolutionRequiredException;
042import org.apache.maven.plugin.AbstractMojo;
043import org.apache.maven.plugin.MojoExecution;
044import org.apache.maven.plugin.MojoExecutionException;
045import org.apache.maven.plugin.logging.Log;
046import org.apache.maven.plugins.annotations.Component;
047import org.apache.maven.plugins.annotations.Parameter;
048import org.apache.maven.project.MavenProject;
049import org.codehaus.plexus.util.DirectoryScanner;
050import org.sonatype.plexus.build.incremental.BuildContext;
051import org.xml.sax.SAXParseException;
052
053import java.io.File;
054import java.io.IOException;
055import java.io.OutputStream;
056import java.net.URI;
057import java.nio.charset.Charset;
058import java.nio.file.Files;
059import java.nio.file.Path;
060import java.nio.file.Paths;
061import java.nio.file.StandardOpenOption;
062import java.util.ArrayList;
063import java.util.HashSet;
064import java.util.LinkedHashSet;
065import java.util.List;
066import java.util.Objects;
067import java.util.Set;
068import java.util.function.Function;
069import java.util.stream.Collectors;
070import java.util.stream.Stream;
071
072import javax.tools.DiagnosticCollector;
073
074import edu.umd.cs.findbugs.annotations.NonNull;
075import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
076
077public abstract class AbstractMetaschemaMojo
078    extends AbstractMojo {
079  private static final String[] DEFAULT_INCLUDES = { "**/*.xml" };
080
081  /**
082   * The Maven project context.
083   *
084   * @required
085   * @readonly
086   */
087  @Parameter(defaultValue = "${project}", required = true, readonly = true)
088  MavenProject mavenProject;
089
090  /**
091   * This will be injected if this plugin is executed as part of the standard
092   * Maven lifecycle. If the mojo is directly invoked, this parameter will not be
093   * injected.
094   */
095  @Parameter(defaultValue = "${mojoExecution}", readonly = true)
096  private MojoExecution mojoExecution;
097
098  @Component
099  private BuildContext buildContext;
100
101  @Parameter(defaultValue = "${plugin.artifacts}", readonly = true, required = true)
102  private List<Artifact> pluginArtifacts;
103
104  /**
105   * <p>
106   * The directory where the staleFile is found. The staleFile is used to
107   * determine if re-generation of generated Java classes is needed, by recording
108   * when the last build occurred.
109   * </p>
110   * <p>
111   * This directory is expected to be located within the
112   * <code>${project.build.directory}</code>, to ensure that code (re)generation
113   * occurs after cleaning the project.
114   * </p>
115   */
116  @Parameter(defaultValue = "${project.build.directory}/metaschema", readonly = true, required = true)
117  protected File staleFileDirectory;
118
119  /**
120   * <p>
121   * Defines the encoding used for generating Java Source files.
122   * </p>
123   * <p>
124   * The algorithm for finding the encoding to use is as follows (where the first
125   * non-null value found is used for encoding):
126   * <ol>
127   * <li>If the configuration property is explicitly given within the plugin's
128   * configuration, use that value.</li>
129   * <li>If the Maven property <code>project.build.sourceEncoding</code> is
130   * defined, use its value.</li>
131   * <li>Otherwise use the value from the system property
132   * <code>file.encoding</code>.</li>
133   * </ol>
134   * </p>
135   *
136   * @see #getEncoding()
137   * @since 2.0
138   */
139  @Parameter(defaultValue = "${project.build.sourceEncoding}")
140  private String encoding;
141
142  /**
143   * Location to generate Java source files in.
144   */
145  @Parameter(
146      defaultValue = "${project.build.directory}/generated-sources/metaschema",
147      required = true,
148      property = "outputDirectory")
149  private File outputDirectory;
150
151  /**
152   * The directory to read source metaschema from.
153   */
154  @Parameter(defaultValue = "${basedir}/src/main/metaschema")
155  private File metaschemaDir;
156
157  /**
158   * A list of <code>files</code> containing Metaschema module constraints files.
159   */
160  @Parameter(property = "constraints")
161  private File[] constraints;
162
163  /**
164   * A set of inclusion patterns used to select which Metaschema modules are to be
165   * processed. By default, all files are processed.
166   */
167  @Parameter
168  protected String[] includes;
169
170  /**
171   * A set of exclusion patterns used to prevent certain files from being
172   * processed. By default, this set is empty such that no files are excluded.
173   */
174  @Parameter
175  protected String[] excludes;
176
177  /**
178   * Indicate if the execution should be skipped.
179   */
180  @Parameter(property = "metaschema.skip", defaultValue = "false")
181  private boolean skip;
182
183  /**
184   * The BuildContext is used to identify which files or directories were modified
185   * since last build. This is used to determine if Module-based generation must
186   * be performed again.
187   *
188   * @return the active Plexus BuildContext.
189   */
190  protected final BuildContext getBuildContext() {
191    return buildContext;
192  }
193
194  /**
195   * Retrieve the Maven project context.
196   *
197   * @return The active MavenProject.
198   */
199  protected final MavenProject getMavenProject() {
200    return mavenProject;
201  }
202
203  protected final List<Artifact> getPluginArtifacts() {
204    return pluginArtifacts;
205  }
206
207  /**
208   * Retrieve the mojo execution context.
209   *
210   * @return The active MojoExecution.
211   */
212  @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "this is a data holder")
213  public MojoExecution getMojoExecution() {
214    return mojoExecution;
215  }
216
217  /**
218   * Retrieve the directory where generated classes will be stored.
219   *
220   * @return the directory
221   */
222  protected File getOutputDirectory() {
223    return outputDirectory;
224  }
225
226  /**
227   * Set the directory where generated classes will be stored.
228   *
229   * @param outputDirectory
230   *          the directory to use
231   */
232  protected void setOutputDirectory(File outputDirectory) {
233    Objects.requireNonNull(outputDirectory, "outputDirectory");
234    this.outputDirectory = outputDirectory;
235  }
236
237  /**
238   * Gets the file encoding to use for generated classes.
239   * <p>
240   * The algorithm for finding the encoding to use is as follows (where the first
241   * non-null value found is used for encoding):
242   * </p>
243   * <ol>
244   * <li>If the configuration property is explicitly given within the plugin's
245   * configuration, use that value.</li>
246   * <li>If the Maven property <code>project.build.sourceEncoding</code> is
247   * defined, use its value.</li>
248   * <li>Otherwise use the value from the system property
249   * <code>file.encoding</code>.</li>
250   * </ol>
251   *
252   * @return The encoding to be used by this AbstractJaxbMojo and its tools.
253   */
254  protected final String getEncoding() {
255    String encoding;
256    if (this.encoding != null) {
257      // first try to use the provided encoding
258      encoding = this.encoding;
259      if (getLog().isDebugEnabled()) {
260        getLog().debug(String.format("Using configured encoding [%s].", encoding));
261      }
262    } else {
263      encoding = Charset.defaultCharset().displayName();
264      if (getLog().isWarnEnabled()) {
265        getLog().warn(String.format("Using system encoding [%s]. This build is platform dependent!", encoding));
266      }
267    }
268    return encoding;
269  }
270
271  /**
272   * Retrieve a stream of Module file sources.
273   *
274   * @return the stream
275   */
276  protected Stream<File> getModuleSources() {
277    DirectoryScanner ds = new DirectoryScanner();
278    ds.setBasedir(metaschemaDir);
279    ds.setIncludes(includes != null && includes.length > 0 ? includes : DEFAULT_INCLUDES);
280    ds.setExcludes(excludes != null && excludes.length > 0 ? excludes : null);
281    ds.addDefaultExcludes();
282    ds.setCaseSensitive(true);
283    ds.setFollowSymlinks(false);
284    ds.scan();
285    return Stream.of(ds.getIncludedFiles()).map(filename -> new File(metaschemaDir, filename)).distinct();
286  }
287
288  @NonNull
289  protected IBindingContext newBindingContext() throws IOException, MetaschemaException {
290    List<IConstraintSet> constraints = getConstraints();
291
292    // generate Java sources based on provided metaschema sources
293    return new DefaultBindingContext(
294        new PostProcessingModuleLoaderStrategy(
295            CollectionUtil.singletonList(new ExternalConstraintsModulePostProcessor(constraints)),
296            new SimpleModuleLoaderStrategy(
297                // this is used instead of the default generator to ensure that plugin classpath
298                // entries are used for compilation
299                new ModuleBindingGenerator(
300                    ObjectUtils.notNull(Files.createDirectories(Paths.get("target/metaschema-codegen-modules"))),
301                    new DefaultBindingConfiguration()))));
302  }
303
304  /**
305   * Get the configured collection of constraints.
306   *
307   * @return the loaded constraints
308   * @throws MetaschemaException
309   *           if a binding exception occurred while loading the constraints
310   * @throws IOException
311   *           if an error occurred while reading the constraints
312   */
313  @NonNull
314  protected List<IConstraintSet> getConstraints()
315      throws MetaschemaException, IOException {
316    IConstraintLoader loader = IBindingContext.getConstraintLoader();
317    List<IConstraintSet> constraintSets = new ArrayList<>(constraints.length);
318    for (File constraint : this.constraints) {
319      constraintSets.addAll(loader.load(ObjectUtils.notNull(constraint)));
320    }
321    return CollectionUtil.unmodifiableList(constraintSets);
322  }
323
324  /**
325   * Determine if the execution of this mojo should be skipped.
326   *
327   * @return {@code true} if the mojo execution should be skipped, or
328   *         {@code false} otherwise
329   */
330  protected boolean shouldExecutionBeSkipped() {
331    return skip;
332  }
333
334  /**
335   * Get the name of the file that is used to detect staleness.
336   *
337   * @return the name
338   */
339  protected abstract String getStaleFileName();
340
341  /**
342   * Gets the staleFile for this execution.
343   *
344   * @return the staleFile
345   */
346  protected final File getStaleFile() {
347    StringBuilder builder = new StringBuilder();
348    if (getMojoExecution() != null) {
349      builder.append(getMojoExecution().getExecutionId()).append('-');
350    }
351    builder.append(getStaleFileName());
352    return new File(staleFileDirectory, builder.toString());
353  }
354
355  /**
356   * Determine if code generation is required. This is done by comparing the last
357   * modified time of each Module source file against the stale file managed by
358   * this plugin.
359   *
360   * @return {@code true} if the code generation is needed, or {@code false}
361   *         otherwise
362   */
363  protected boolean isGenerationRequired() {
364    final File staleFile = getStaleFile();
365    boolean generate = !staleFile.exists();
366    if (generate) {
367      if (getLog().isInfoEnabled()) {
368        getLog().info(String.format("Stale file '%s' doesn't exist! Generating source files.", staleFile.getPath()));
369      }
370      generate = true;
371    } else {
372      generate = false;
373      // check for staleness
374      long staleLastModified = staleFile.lastModified();
375
376      BuildContext buildContext = getBuildContext();
377      URI metaschemaDirRelative = getMavenProject().getBasedir().toURI().relativize(metaschemaDir.toURI());
378
379      if (buildContext.isIncremental() && buildContext.hasDelta(metaschemaDirRelative.toString())) {
380        if (getLog().isInfoEnabled()) {
381          getLog().info("metaschemaDirRelative: " + metaschemaDirRelative.toString());
382        }
383        generate = true;
384      }
385
386      if (!generate) {
387        for (File sourceFile : getModuleSources().collect(Collectors.toList())) {
388          if (getLog().isInfoEnabled()) {
389            getLog().info("Source file: " + sourceFile.getPath());
390          }
391          if (sourceFile.lastModified() > staleLastModified) {
392            generate = true;
393          }
394        }
395      }
396    }
397    return generate;
398  }
399
400  protected Set<String> getClassPath() throws DependencyResolutionRequiredException {
401    Set<String> pathElements;
402    try {
403      pathElements = new LinkedHashSet<>(getMavenProject().getCompileClasspathElements());
404    } catch (DependencyResolutionRequiredException ex) {
405      getLog().warn("exception calling getCompileClasspathElements", ex);
406      throw ex;
407    }
408
409    if (pluginArtifacts != null) {
410      for (Artifact a : getPluginArtifacts()) {
411        if (a.getFile() != null) {
412          pathElements.add(a.getFile().getAbsolutePath());
413        }
414      }
415    }
416    return pathElements;
417  }
418
419  @NonNull
420  protected Set<IModule> getModulesToGenerateFor(@NonNull IBindingContext bindingContext)
421      throws MetaschemaException, IOException {
422    IBindingModuleLoader loader = bindingContext.newModuleLoader();
423    loader.allowEntityResolution();
424
425    LoggingValidationHandler validationHandler = new LoggingValidationHandler();
426
427    Set<IModule> modules = new HashSet<>();
428    for (File source : getModuleSources().collect(Collectors.toList())) {
429      assert source != null;
430      if (getLog().isInfoEnabled()) {
431        getLog().info("Using metaschema source: " + source.getPath());
432      }
433      IBindingMetaschemaModule module = loader.load(source);
434
435      IValidationResult result = bindingContext.validate(
436          module.getSourceNodeItem(),
437          loader.getBindingContext().newBoundLoader(),
438          null);
439
440      validationHandler.handleResults(result);
441
442      modules.add(module);
443    }
444    return modules;
445  }
446
447  protected void createStaleFile(@NonNull File staleFile) throws MojoExecutionException {
448    // create the stale file
449    if (!staleFileDirectory.exists() && !staleFileDirectory.mkdirs()) {
450      throw new MojoExecutionException("Unable to create output directory: " + staleFileDirectory);
451    }
452    try (OutputStream os
453        = Files.newOutputStream(staleFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE,
454            StandardOpenOption.TRUNCATE_EXISTING)) {
455      os.close();
456      if (getLog().isInfoEnabled()) {
457        getLog().info("Created stale file: " + staleFile);
458      }
459    } catch (IOException ex) {
460      throw new MojoExecutionException("Failed to write stale file: " + staleFile.getPath(), ex);
461    }
462  }
463
464  protected final class LoggingValidationHandler
465      extends AbstractValidationResultProcessor {
466
467    private <T extends IValidationFinding> void handleFinding(
468        @NonNull T finding,
469        @NonNull Function<T, CharSequence> formatter) {
470
471      Log log = getLog();
472
473      switch (finding.getSeverity()) {
474      case CRITICAL:
475      case ERROR:
476        if (log.isErrorEnabled()) {
477          log.error(formatter.apply(finding), finding.getCause());
478        }
479        break;
480      case WARNING:
481        if (log.isWarnEnabled()) {
482          getLog().warn(formatter.apply(finding), finding.getCause());
483        }
484        break;
485      case INFORMATIONAL:
486        if (log.isInfoEnabled()) {
487          getLog().info(formatter.apply(finding), finding.getCause());
488        }
489        break;
490      default:
491        if (log.isDebugEnabled()) {
492          getLog().debug(formatter.apply(finding), finding.getCause());
493        }
494        break;
495      }
496    }
497
498    @Override
499    protected void handleJsonValidationFinding(JsonValidationFinding finding) {
500      handleFinding(finding, this::getMessage);
501    }
502
503    @Override
504    protected void handleXmlValidationFinding(XmlValidationFinding finding) {
505      handleFinding(finding, this::getMessage);
506    }
507
508    @Override
509    protected void handleConstraintValidationFinding(ConstraintValidationFinding finding) {
510      handleFinding(finding, this::getMessage);
511    }
512
513    @NonNull
514    private CharSequence getMessage(JsonValidationFinding finding) {
515      StringBuilder builder = new StringBuilder();
516      builder.append('[')
517          .append(finding.getCause().getPointerToViolation())
518          .append("] ")
519          .append(finding.getMessage());
520
521      URI documentUri = finding.getDocumentUri();
522      if (documentUri != null) {
523        builder.append(" [")
524            .append(documentUri.toString())
525            .append(']');
526      }
527      return builder;
528    }
529
530    @NonNull
531    private CharSequence getMessage(XmlValidationFinding finding) {
532      StringBuilder builder = new StringBuilder();
533
534      builder.append(finding.getMessage())
535          .append(" [");
536
537      URI documentUri = finding.getDocumentUri();
538      if (documentUri != null) {
539        builder.append(documentUri.toString());
540      }
541
542      SAXParseException ex = finding.getCause();
543      builder.append(finding.getMessage())
544          .append('{')
545          .append(ex.getLineNumber())
546          .append(',')
547          .append(ex.getColumnNumber())
548          .append("}]");
549      return builder;
550    }
551
552    @NonNull
553    private CharSequence getMessage(@NonNull ConstraintValidationFinding finding) {
554      StringBuilder builder = new StringBuilder();
555      builder.append('[')
556          .append(finding.getTarget().getMetapath())
557          .append(']');
558
559      String id = finding.getIdentifier();
560      if (id != null) {
561        builder.append(' ')
562            .append(id);
563      }
564
565      builder.append(' ')
566          .append(finding.getMessage());
567
568      URI documentUri = finding.getTarget().getBaseUri();
569      IResourceLocation location = finding.getLocation();
570      if (documentUri != null || location != null) {
571        builder.append(" [");
572      }
573
574      if (documentUri != null) {
575        builder.append(documentUri.toString());
576      }
577
578      if (location != null) {
579        builder.append('{')
580            .append(location.getLine())
581            .append(',')
582            .append(location.getColumn())
583            .append('}');
584      }
585      if (documentUri != null || location != null) {
586        builder.append(']');
587      }
588      return builder;
589    }
590  }
591
592  public class ModuleBindingGenerator implements IModuleBindingGenerator {
593    @NonNull
594    private final Path compilePath;
595    @NonNull
596    private final ClassLoader classLoader;
597    @NonNull
598    private final IBindingConfiguration bindingConfiguration;
599
600    public ModuleBindingGenerator(
601        @NonNull Path compilePath,
602        @NonNull IBindingConfiguration bindingConfiguration) {
603      this.compilePath = compilePath;
604      this.classLoader = ModuleCompilerHelper.newClassLoader(
605          compilePath,
606          ObjectUtils.notNull(Thread.currentThread().getContextClassLoader()));
607      this.bindingConfiguration = bindingConfiguration;
608    }
609
610    @NonNull
611    public IProduction generateClasses(@NonNull IModule module) {
612      IProduction production;
613      try {
614        production = JavaGenerator.generate(module, compilePath, bindingConfiguration);
615      } catch (IOException ex) {
616        throw new MetapathException(
617            String.format("Unable to generate and compile classes for module '%s'.", module.getLocation()),
618            ex);
619      }
620      return production;
621    }
622
623    private void compileClasses(@NonNull IProduction production, @NonNull Path classDir)
624        throws IOException, DependencyResolutionRequiredException {
625      List<IGeneratedClass> classesToCompile = production.getGeneratedClasses().collect(Collectors.toList());
626
627      List<Path> classes = ObjectUtils.notNull(classesToCompile.stream()
628          .map(IGeneratedClass::getClassFile)
629          .collect(Collectors.toUnmodifiableList()));
630
631      JavaCompilerSupport compiler = new JavaCompilerSupport(classDir);
632
633      getClassPath().forEach(compiler::addToClassPath);
634
635      JavaCompilerSupport.CompilationResult result = compiler.compile(classes, null);
636
637      if (!result.isSuccessful()) {
638        DiagnosticCollector<?> diagnostics = new DiagnosticCollector<>();
639        if (getLog().isErrorEnabled()) {
640          getLog().error("diagnostics: " + diagnostics.getDiagnostics().toString());
641        }
642        throw new IllegalStateException(String.format("failed to compile classes: %s",
643            classesToCompile.stream()
644                .map(clazz -> clazz.getClassName().canonicalName())
645                .collect(Collectors.joining(","))));
646      }
647    }
648
649    @Override
650    public Class<? extends IBoundModule> generate(IModule module) {
651      IProduction production = generateClasses(module);
652      try {
653        compileClasses(production, compilePath);
654      } catch (IOException | DependencyResolutionRequiredException ex) {
655        throw new IllegalStateException("failed to compile classes", ex);
656      }
657      IGeneratedModuleClass moduleClass = ObjectUtils.requireNonNull(production.getModuleProduction(module));
658
659      try {
660        return moduleClass.load(classLoader);
661      } catch (ClassNotFoundException ex) {
662        throw new IllegalStateException(ex);
663      }
664    }
665
666  }
667}