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 ValidateContentUsingModuleCommand(),
067      new ConvertContentUsingModuleCommand(),
068      new MetapathCommand()));
069
070  /**
071   * Used by commands to declare a required Metaschema module for processing.
072   *
073   * @since 2.0.0
074   */
075  @NonNull
076  public static final Option METASCHEMA_REQUIRED_OPTION = ObjectUtils.notNull(
077      Option.builder("m")
078          .hasArg()
079          .argName("FILE_OR_URL")
080          .required()
081          .type(URI.class)
082          .desc("metaschema resource")
083          .numberOfArgs(1)
084          .get());
085  /**
086   * Used by commands to declare an optional Metaschema module for processing.
087   *
088   * @since 2.0.0
089   */
090  @NonNull
091  public static final Option METASCHEMA_OPTIONAL_OPTION = ObjectUtils.notNull(
092      Option.builder("m")
093          .hasArg()
094          .argName("FILE_OR_URL")
095          .type(URI.class)
096          .desc("metaschema resource")
097          .numberOfArgs(1)
098          .get());
099  /**
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}