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