1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.commands;
7   
8   import org.apache.commons.cli.CommandLine;
9   import org.apache.commons.cli.Option;
10  
11  import java.io.FileNotFoundException;
12  import java.io.IOException;
13  import java.net.URI;
14  import java.net.URISyntaxException;
15  import java.nio.file.Files;
16  import java.nio.file.Path;
17  import java.nio.file.Paths;
18  import java.util.Arrays;
19  import java.util.LinkedHashSet;
20  import java.util.List;
21  import java.util.Locale;
22  import java.util.Set;
23  
24  import dev.metaschema.cli.commands.metapath.MetapathCommand;
25  import dev.metaschema.cli.processor.ExitCode;
26  import dev.metaschema.cli.processor.OptionUtils;
27  import dev.metaschema.cli.processor.command.CommandExecutionException;
28  import dev.metaschema.cli.processor.command.ICommand;
29  import dev.metaschema.core.metapath.MetapathException;
30  import dev.metaschema.core.model.IConstraintLoader;
31  import dev.metaschema.core.model.IModule;
32  import dev.metaschema.core.model.MetaschemaException;
33  import dev.metaschema.core.model.constraint.IConstraintSet;
34  import dev.metaschema.core.util.CollectionUtil;
35  import dev.metaschema.core.util.CustomCollectors;
36  import dev.metaschema.core.util.DeleteOnShutdown;
37  import dev.metaschema.core.util.ObjectUtils;
38  import dev.metaschema.core.util.UriUtils;
39  import dev.metaschema.databind.IBindingContext;
40  import dev.metaschema.databind.io.Format;
41  import dev.metaschema.databind.io.IBoundLoader;
42  import dev.metaschema.databind.model.metaschema.IBindingModuleLoader;
43  import dev.metaschema.schemagen.ISchemaGenerator.SchemaFormat;
44  import edu.umd.cs.findbugs.annotations.NonNull;
45  
46  /**
47   * This class provides a variety of utility methods for processing
48   * Metaschema-related commands.
49   * <p>
50   * These methods handle the errors produced using the
51   * {@link CommandExecutionException}, which will return an exceptional result to
52   * the command line interface (CLI) processor. This approach keeps the command
53   * implementations fairly clean and simple.
54   */
55  @SuppressWarnings("PMD.GodClass")
56  public final class MetaschemaCommands {
57    /**
58     * A list of the Metaschema-related command pathways, for reuse in this and
59     * other CLI applications.
60     */
61    @NonNull
62    public static final List<ICommand> COMMANDS = ObjectUtils.notNull(List.of(
63        new ValidateModuleCommand(),
64        new GenerateSchemaCommand(),
65        new GenerateDiagramCommand(),
66        new ValidateContentUsingModuleCommand(),
67        new ConvertContentUsingModuleCommand(),
68        new MetapathCommand()));
69  
70    /**
71     * Used by commands to declare a required Metaschema module for processing.
72     *
73     * @since 2.0.0
74     */
75    @NonNull
76    public static final Option METASCHEMA_REQUIRED_OPTION = ObjectUtils.notNull(
77        Option.builder("m")
78            .hasArg()
79            .argName("FILE_OR_URL")
80            .required()
81            .type(URI.class)
82            .desc("metaschema resource")
83            .numberOfArgs(1)
84            .get());
85    /**
86     * Used by commands to declare an optional Metaschema module for processing.
87     *
88     * @since 2.0.0
89     */
90    @NonNull
91    public static final Option METASCHEMA_OPTIONAL_OPTION = ObjectUtils.notNull(
92        Option.builder("m")
93            .hasArg()
94            .argName("FILE_OR_URL")
95            .type(URI.class)
96            .desc("metaschema resource")
97            .numberOfArgs(1)
98            .get());
99    /**
100    * Used by commands to protect existing files from being overwritten, unless
101    * this option is provided.
102    */
103   @NonNull
104   public static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
105       Option.builder()
106           .longOpt("overwrite")
107           .desc("overwrite the destination if it exists")
108           .get());
109   /**
110    * Used by commands to identify the target format for a content conversion
111    * operation.
112    *
113    * @since 2.0.0
114    */
115   @NonNull
116   public static final Option TO_OPTION = ObjectUtils.notNull(
117       Option.builder()
118           .longOpt("to")
119           .required()
120           .hasArg().argName("FORMAT")
121           .type(Format.class)
122           .desc("convert to format: " + Arrays.stream(Format.values())
123               .map(Enum::name)
124               .collect(CustomCollectors.joiningWithOxfordComma("or")))
125           .numberOfArgs(1)
126           .get());
127   /**
128    * Used by commands to identify the source format for a content-related
129    * operation.
130    *
131    * @since 2.0.0
132    */
133   @NonNull
134   public static final Option AS_FORMAT_OPTION = ObjectUtils.notNull(
135       Option.builder()
136           .longOpt("as")
137           .hasArg()
138           .argName("FORMAT")
139           .type(Format.class)
140           .desc("source format: " + Arrays.stream(Format.values())
141               .map(Enum::name)
142               .collect(CustomCollectors.joiningWithOxfordComma("or")))
143           .numberOfArgs(1)
144           .get());
145   /**
146    * Used by commands that produce schemas to identify the schema format to
147    * produce.
148    *
149    * @since 2.0.0
150    */
151   @NonNull
152   public static final Option AS_SCHEMA_FORMAT_OPTION = ObjectUtils.notNull(
153       Option.builder()
154           .longOpt("as")
155           .required()
156           .hasArg()
157           .argName("FORMAT")
158           .type(SchemaFormat.class)
159           .desc("schema format: " + Arrays.stream(SchemaFormat.values())
160               .map(Enum::name)
161               .collect(CustomCollectors.joiningWithOxfordComma("or")))
162           .numberOfArgs(1)
163           .get());
164 
165   /**
166    * Get the provided source path or URI string as an absolute {@link URI} for the
167    * resource.
168    *
169    * @param pathOrUri
170    *          the resource
171    * @param currentWorkingDirectory
172    *          the current working directory the URI will be resolved against to
173    *          ensure it is absolute
174    * @return the absolute URI for the resource
175    * @throws CommandExecutionException
176    *           if the resulting URI is not a well-formed URI
177    * @since 2.0.0
178    */
179   @NonNull
180   public static URI handleSource(
181       @NonNull String pathOrUri,
182       @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
183     try {
184       return getResourceUri(pathOrUri, currentWorkingDirectory);
185     } catch (URISyntaxException ex) {
186       throw new CommandExecutionException(
187           ExitCode.INVALID_ARGUMENTS,
188           String.format(
189               "Cannot load source '%s' as it is not a valid file or URI.",
190               pathOrUri),
191           ex);
192     }
193   }
194 
195   /**
196    * Get the provided destination path as an absolute {@link Path} for the
197    * resource.
198    * <p>
199    * This method checks if the path exists and if so, if the overwrite option is
200    * set. The method also ensures that the parent directory is created, if it
201    * doesn't already exist.
202    *
203    * @param path
204    *          the resource
205    * @param commandLine
206    *          the provided command line argument information
207    * @return the absolute URI for the resource
208    * @throws CommandExecutionException
209    *           if the path exists and cannot be overwritten or is not writable
210    * @since 2.0.0
211    */
212   public static Path handleDestination(
213       @NonNull String path,
214       @NonNull CommandLine commandLine) throws CommandExecutionException {
215     Path retval = Paths.get(path).toAbsolutePath();
216 
217     if (Files.exists(retval)) {
218       if (!commandLine.hasOption(OVERWRITE_OPTION)) {
219         throw new CommandExecutionException(
220             ExitCode.INVALID_ARGUMENTS,
221             String.format("The provided destination '%s' already exists and the '%s' option was not provided.",
222                 retval,
223                 OptionUtils.toArgument(OVERWRITE_OPTION)));
224       }
225       if (!Files.isWritable(retval)) {
226         throw new CommandExecutionException(
227             ExitCode.IO_ERROR,
228             String.format(
229                 "The provided destination '%s' is not writable.", retval));
230       }
231     } else {
232       Path parent = retval.getParent();
233       if (parent != null) {
234         try {
235           Files.createDirectories(parent);
236         } catch (IOException ex) {
237           throw new CommandExecutionException(
238               ExitCode.INVALID_TARGET,
239               ex);
240         }
241       }
242     }
243     return retval;
244   }
245 
246   /**
247    * Parse the command line options to get the selected format.
248    *
249    * @param commandLine
250    *          the provided command line argument information
251    * @param option
252    *          the option specifying the format, which must be present on the
253    *          command line
254    * @return the format
255    * @throws CommandExecutionException
256    *           if the format option was not provided or was an invalid choice
257    * @since 2.0.0
258    */
259   @NonNull
260   public static Format getFormat(
261       @NonNull CommandLine commandLine,
262       @NonNull Option option) throws CommandExecutionException {
263     // use the option
264     String toFormatText = commandLine.getOptionValue(option);
265     if (toFormatText == null) {
266       throw new CommandExecutionException(
267           ExitCode.INVALID_ARGUMENTS,
268           String.format("The '%s' argument was not provided.",
269               option.hasLongOpt()
270                   ? "--" + option.getLongOpt()
271                   : "-" + option.getOpt()));
272     }
273     try {
274       return Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
275     } catch (IllegalArgumentException ex) {
276       throw new CommandExecutionException(
277           ExitCode.INVALID_ARGUMENTS,
278           String.format("Invalid '%s' argument. The format must be one of: %s.",
279               option.hasLongOpt()
280                   ? "--" + option.getLongOpt()
281                   : "-" + option.getOpt(),
282               Arrays.stream(Format.values())
283                   .map(Enum::name)
284                   .collect(CustomCollectors.joiningWithOxfordComma("or"))),
285           ex);
286     }
287   }
288 
289   /**
290    * Parse the command line options to get the selected schema format.
291    *
292    * @param commandLine
293    *          the provided command line argument information
294    * @param option
295    *          the option specifying the format, which must be present on the
296    *          command line
297    * @return the format
298    * @throws CommandExecutionException
299    *           if the format option was not provided or was an invalid choice
300    * @since 2.0.0
301    */
302   @NonNull
303   public static SchemaFormat getSchemaFormat(
304       @NonNull CommandLine commandLine,
305       @NonNull Option option) throws CommandExecutionException {
306     // use the option
307     String toFormatText = commandLine.getOptionValue(option);
308     if (toFormatText == null) {
309       throw new CommandExecutionException(
310           ExitCode.INVALID_ARGUMENTS,
311           String.format("Option '%s' not provided.",
312               option.hasLongOpt()
313                   ? "--" + option.getLongOpt()
314                   : "-" + option.getOpt()));
315     }
316     try {
317       return SchemaFormat.valueOf(toFormatText.toUpperCase(Locale.ROOT));
318     } catch (IllegalArgumentException ex) {
319       throw new CommandExecutionException(
320           ExitCode.INVALID_ARGUMENTS,
321           String.format("Invalid '%s' argument. The schema format must be one of: %s.",
322               option.hasLongOpt()
323                   ? "--" + option.getLongOpt()
324                   : "-" + option.getOpt(),
325               Arrays.stream(SchemaFormat.values())
326                   .map(Enum::name)
327                   .collect(CustomCollectors.joiningWithOxfordComma("or"))),
328           ex);
329     }
330   }
331 
332   /**
333    * Detect the source format for content identified using the provided option.
334    * <p>
335    * This method will first check if the source format is explicitly declared on
336    * the command line. If so, this format will be returned.
337    * <p>
338    * If not, then the content will be analyzed to determine the format.
339    *
340    * @param commandLine
341    *          the provided command line argument information
342    * @param option
343    *          the option specifying the format, which must be present on the
344    *          command line
345    * @param loader
346    *          the content loader to use to load the content instance
347    * @param resource
348    *          the resource to load
349    * @return the identified content format
350    * @throws CommandExecutionException
351    *           if an error occurred while determining the source format
352    * @since 2.0.0
353    */
354   @NonNull
355   public static Format determineSourceFormat(
356       @NonNull CommandLine commandLine,
357       @NonNull Option option,
358       @NonNull IBoundLoader loader,
359       @NonNull URI resource) throws CommandExecutionException {
360     if (commandLine.hasOption(option)) {
361       // use the option
362       return getFormat(commandLine, option);
363     }
364 
365     // attempt to determine the format
366     try {
367       return loader.detectFormat(resource);
368     } catch (FileNotFoundException ex) {
369       // this case was already checked for
370       throw new CommandExecutionException(
371           ExitCode.IO_ERROR,
372           String.format("The provided source '%s' does not exist.", resource),
373           ex);
374     } catch (IOException ex) {
375       throw new CommandExecutionException(
376           ExitCode.IO_ERROR,
377           String.format("Unable to determine source format. Use '%s' to specify the format. %s",
378               option.hasLongOpt()
379                   ? "--" + option.getLongOpt()
380                   : "-" + option.getOpt(),
381               ex.getLocalizedMessage()),
382           ex);
383     }
384   }
385 
386   /**
387    * Load a Metaschema module based on the provided command line option.
388    *
389    * @param commandLine
390    *          the provided command line argument information
391    * @param option
392    *          the option specifying the module to load, which must be present on
393    *          the command line
394    * @param currentWorkingDirectory
395    *          the URI of the current working directory
396    * @param bindingContext
397    *          the context used to access Metaschema module information based on
398    *          Java class bindings
399    * @return the loaded module
400    * @throws CommandExecutionException
401    *           if an error occurred while loading the module
402    * @since 2.0.0
403    */
404   @NonNull
405   public static IModule loadModule(
406       @NonNull CommandLine commandLine,
407       @NonNull Option option,
408       @NonNull URI currentWorkingDirectory,
409       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
410     String moduleName = commandLine.getOptionValue(option);
411     if (moduleName == null) {
412       throw new CommandExecutionException(
413           ExitCode.INVALID_ARGUMENTS,
414           String.format("Unable to determine the module to load. Use '%s' to specify the module.",
415               option.hasLongOpt()
416                   ? "--" + option.getLongOpt()
417                   : "-" + option.getOpt()));
418     }
419 
420     URI moduleUri;
421     try {
422       moduleUri = UriUtils.toUri(moduleName, currentWorkingDirectory);
423     } catch (URISyntaxException ex) {
424       throw new CommandExecutionException(
425           ExitCode.INVALID_ARGUMENTS,
426           String.format("Cannot load module as '%s' is not a valid file or URL. %s",
427               ex.getInput(),
428               ex.getLocalizedMessage()),
429           ex);
430     }
431     return loadModule(moduleUri, bindingContext);
432   }
433 
434   /**
435    * Load a Metaschema module from the provided relative resource path.
436    * <p>
437    * This method will resolve the provided resource against the current working
438    * directory to create an absolute URI.
439    *
440    * @param moduleResource
441    *          the relative path to the module resource to load
442    * @param currentWorkingDirectory
443    *          the URI of the current working directory
444    * @param bindingContext
445    *          the context used to access Metaschema module information based on
446    *          Java class bindings
447    * @return the loaded module
448    * @throws CommandExecutionException
449    *           if an error occurred while loading the module
450    * @since 2.0.0
451    */
452   @NonNull
453   public static IModule loadModule(
454       @NonNull String moduleResource,
455       @NonNull URI currentWorkingDirectory,
456       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
457     try {
458       URI moduleUri = getResourceUri(
459           moduleResource,
460           currentWorkingDirectory);
461       return loadModule(moduleUri, bindingContext);
462     } catch (URISyntaxException ex) {
463       throw new CommandExecutionException(
464           ExitCode.INVALID_ARGUMENTS,
465           String.format("Cannot load module as '%s' is not a valid file or URL. %s",
466               ex.getInput(),
467               ex.getLocalizedMessage()),
468           ex);
469     }
470   }
471 
472   /**
473    * Load a Metaschema module from the provided resource path.
474    *
475    * @param moduleResource
476    *          the absolute path to the module resource to load
477    * @param bindingContext
478    *          the context used to access Metaschema module information based on
479    *          Java class bindings
480    * @return the loaded module
481    * @throws CommandExecutionException
482    *           if an error occurred while loading the module
483    * @since 2.0.0
484    */
485   @NonNull
486   public static IModule loadModule(
487       @NonNull URI moduleResource,
488       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
489     // TODO: ensure the resource URI is absolute
490     try {
491       IBindingModuleLoader loader = bindingContext.newModuleLoader();
492       loader.allowEntityResolution();
493       return loader.load(moduleResource);
494     } catch (IOException | MetaschemaException ex) {
495       throw new CommandExecutionException(ExitCode.PROCESSING_ERROR, ex);
496     }
497   }
498 
499   /**
500    * For a given resource location, resolve the location into an absolute URI.
501    *
502    * @param location
503    *          the resource location
504    * @param currentWorkingDirectory
505    *          the URI of the current working directory
506    * @return the resolved URI
507    * @throws URISyntaxException
508    *           if the location is not a valid URI
509    */
510   @NonNull
511   public static URI getResourceUri(
512       @NonNull String location,
513       @NonNull URI currentWorkingDirectory) throws URISyntaxException {
514     return UriUtils.toUri(location, currentWorkingDirectory);
515   }
516 
517   /**
518    * Load a set of external Metaschema module constraints based on the provided
519    * command line option.
520    *
521    * @param commandLine
522    *          the provided command line argument information
523    * @param option
524    *          the option specifying the constraints to load, which must be present
525    *          on the command line
526    * @param currentWorkingDirectory
527    *          the URI of the current working directory
528    * @return the set of loaded constraints
529    * @throws CommandExecutionException
530    *           if an error occurred while loading the module
531    * @since 2.0.0
532    */
533   @NonNull
534   public static Set<IConstraintSet> loadConstraintSets(
535       @NonNull CommandLine commandLine,
536       @NonNull Option option,
537       @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
538     Set<IConstraintSet> constraintSets;
539     if (commandLine.hasOption(option)) {
540       IConstraintLoader constraintLoader = IBindingContext.getConstraintLoader();
541       constraintSets = new LinkedHashSet<>();
542       String[] args = commandLine.getOptionValues(option);
543       for (String arg : args) {
544         assert arg != null;
545         try {
546           URI constraintUri = ObjectUtils.requireNonNull(UriUtils.toUri(arg, currentWorkingDirectory));
547           constraintSets.addAll(constraintLoader.load(constraintUri));
548         } catch (URISyntaxException | IOException | MetaschemaException | MetapathException ex) {
549           throw new CommandExecutionException(
550               ExitCode.IO_ERROR,
551               String.format("Unable to process constraint set '%s'. %s",
552                   arg,
553                   ex.getLocalizedMessage()),
554               ex);
555         }
556       }
557     } else {
558       constraintSets = CollectionUtil.emptySet();
559     }
560     return constraintSets;
561   }
562 
563   /**
564    * Create a temporary directory for ephemeral files that will be deleted on
565    * shutdown.
566    *
567    * @return the temp directory path
568    * @throws IOException
569    *           if an error occurred while creating the temporary directory
570    */
571   @NonNull
572   public static Path newTempDir() throws IOException {
573     Path retval = Files.createTempDirectory("metaschema-cli-");
574     DeleteOnShutdown.register(retval);
575     return ObjectUtils.notNull(retval);
576   }
577 
578   /**
579    * Create a new {@link IBindingContext} that is configured for dynamic
580    * compilation.
581    *
582    * @return the binding context
583    * @throws CommandExecutionException
584    *           if an error occurred while creating the binding context
585    * @since 2.0.0
586    */
587   @NonNull
588   public static IBindingContext newBindingContextWithDynamicCompilation() throws CommandExecutionException {
589     return newBindingContextWithDynamicCompilation(CollectionUtil.emptySet());
590   }
591 
592   /**
593    * Create a new {@link IBindingContext} that is configured for dynamic
594    * compilation and to use the provided constraints.
595    *
596    * @param constraintSets
597    *          the Metaschema module constraints to dynamicly bind to loaded
598    *          modules
599    * @return the binding context
600    * @throws CommandExecutionException
601    *           if an error occurred while creating the binding context
602    * @since 2.0.0
603    */
604   @NonNull
605   public static IBindingContext newBindingContextWithDynamicCompilation(@NonNull Set<IConstraintSet> constraintSets)
606       throws CommandExecutionException {
607     try {
608       Path tempDir = newTempDir();
609       return IBindingContext.builder()
610           .compilePath(tempDir)
611           .constraintSet(constraintSets)
612           .build();
613     } catch (IOException ex) {
614       throw new CommandExecutionException(ExitCode.RUNTIME_ERROR,
615           String.format("Unable to initialize the binding context. %s", ex.getLocalizedMessage()),
616           ex);
617     }
618   }
619 
620   private MetaschemaCommands() {
621     // disable construction
622   }
623 }