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}