1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.maven.plugin;
7   
8   import gov.nist.secauto.metaschema.core.metapath.MetapathException;
9   import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
10  import gov.nist.secauto.metaschema.core.model.IModule;
11  import gov.nist.secauto.metaschema.core.model.IResourceLocation;
12  import gov.nist.secauto.metaschema.core.model.MetaschemaException;
13  import gov.nist.secauto.metaschema.core.model.constraint.ConstraintValidationFinding;
14  import gov.nist.secauto.metaschema.core.model.constraint.ExternalConstraintsModulePostProcessor;
15  import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
16  import gov.nist.secauto.metaschema.core.model.validation.AbstractValidationResultProcessor;
17  import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding;
18  import gov.nist.secauto.metaschema.core.model.validation.IValidationResult;
19  import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
20  import gov.nist.secauto.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
21  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
22  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
23  import gov.nist.secauto.metaschema.databind.DefaultBindingContext;
24  import gov.nist.secauto.metaschema.databind.IBindingContext;
25  import gov.nist.secauto.metaschema.databind.PostProcessingModuleLoaderStrategy;
26  import gov.nist.secauto.metaschema.databind.SimpleModuleLoaderStrategy;
27  import gov.nist.secauto.metaschema.databind.codegen.IGeneratedClass;
28  import gov.nist.secauto.metaschema.databind.codegen.IGeneratedModuleClass;
29  import gov.nist.secauto.metaschema.databind.codegen.IModuleBindingGenerator;
30  import gov.nist.secauto.metaschema.databind.codegen.IProduction;
31  import gov.nist.secauto.metaschema.databind.codegen.JavaCompilerSupport;
32  import gov.nist.secauto.metaschema.databind.codegen.JavaGenerator;
33  import gov.nist.secauto.metaschema.databind.codegen.ModuleCompilerHelper;
34  import gov.nist.secauto.metaschema.databind.codegen.config.DefaultBindingConfiguration;
35  import gov.nist.secauto.metaschema.databind.codegen.config.IBindingConfiguration;
36  import gov.nist.secauto.metaschema.databind.model.IBoundModule;
37  import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingMetaschemaModule;
38  import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingModuleLoader;
39  
40  import org.apache.maven.artifact.Artifact;
41  import org.apache.maven.artifact.DependencyResolutionRequiredException;
42  import org.apache.maven.plugin.AbstractMojo;
43  import org.apache.maven.plugin.MojoExecution;
44  import org.apache.maven.plugin.MojoExecutionException;
45  import org.apache.maven.plugin.logging.Log;
46  import org.apache.maven.plugins.annotations.Component;
47  import org.apache.maven.plugins.annotations.Parameter;
48  import org.apache.maven.project.MavenProject;
49  import org.codehaus.plexus.util.DirectoryScanner;
50  import org.sonatype.plexus.build.incremental.BuildContext;
51  import org.xml.sax.SAXParseException;
52  
53  import java.io.File;
54  import java.io.IOException;
55  import java.io.OutputStream;
56  import java.net.URI;
57  import java.nio.charset.Charset;
58  import java.nio.file.Files;
59  import java.nio.file.Path;
60  import java.nio.file.Paths;
61  import java.nio.file.StandardOpenOption;
62  import java.util.ArrayList;
63  import java.util.HashSet;
64  import java.util.LinkedHashSet;
65  import java.util.List;
66  import java.util.Objects;
67  import java.util.Set;
68  import java.util.function.Function;
69  import java.util.stream.Collectors;
70  import java.util.stream.Stream;
71  
72  import javax.tools.DiagnosticCollector;
73  
74  import edu.umd.cs.findbugs.annotations.NonNull;
75  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
76  
77  public abstract class AbstractMetaschemaMojo
78      extends AbstractMojo {
79    private static final String[] DEFAULT_INCLUDES = { "**/*.xml" };
80  
81    /**
82     * The Maven project context.
83     *
84     * @required
85     * @readonly
86     */
87    @Parameter(defaultValue = "${project}", required = true, readonly = true)
88    MavenProject mavenProject;
89  
90    /**
91     * This will be injected if this plugin is executed as part of the standard
92     * Maven lifecycle. If the mojo is directly invoked, this parameter will not be
93     * injected.
94     */
95    @Parameter(defaultValue = "${mojoExecution}", readonly = true)
96    private MojoExecution mojoExecution;
97  
98    @Component
99    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 }