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}