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