001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package gov.nist.secauto.metaschema.cli.processor; 007 008import static org.fusesource.jansi.Ansi.ansi; 009 010import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException; 011import gov.nist.secauto.metaschema.cli.processor.command.CommandService; 012import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument; 013import gov.nist.secauto.metaschema.cli.processor.command.ICommand; 014import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor; 015import gov.nist.secauto.metaschema.core.util.AutoCloser; 016import gov.nist.secauto.metaschema.core.util.CollectionUtil; 017import gov.nist.secauto.metaschema.core.util.IVersionInfo; 018import gov.nist.secauto.metaschema.core.util.ObjectUtils; 019 020import org.apache.commons.cli.CommandLine; 021import org.apache.commons.cli.CommandLineParser; 022import org.apache.commons.cli.DefaultParser; 023import org.apache.commons.cli.HelpFormatter; 024import org.apache.commons.cli.Option; 025import org.apache.commons.cli.Options; 026import org.apache.commons.cli.ParseException; 027import org.apache.logging.log4j.Level; 028import org.apache.logging.log4j.LogManager; 029import org.apache.logging.log4j.Logger; 030import org.apache.logging.log4j.core.LoggerContext; 031import org.apache.logging.log4j.core.config.Configuration; 032import org.apache.logging.log4j.core.config.LoggerConfig; 033import org.fusesource.jansi.AnsiConsole; 034import org.fusesource.jansi.AnsiPrintStream; 035 036import java.io.PrintStream; 037import java.io.PrintWriter; 038import java.nio.charset.StandardCharsets; 039import java.util.Arrays; 040import java.util.Collection; 041import java.util.LinkedList; 042import java.util.List; 043import java.util.Map; 044import java.util.concurrent.atomic.AtomicBoolean; 045import java.util.function.Function; 046import java.util.stream.Collectors; 047 048import edu.umd.cs.findbugs.annotations.NonNull; 049import edu.umd.cs.findbugs.annotations.Nullable; 050import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 051 052/** 053 * Processes command line arguments and dispatches called commands. 054 * <p> 055 * This implementation make significant use of the command pattern to support a 056 * delegation chain of commands based on implementations of {@link ICommand}. 057 */ 058@SuppressWarnings("PMD.CouplingBetweenObjects") 059public class CLIProcessor { 060 private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class); 061 062 /** 063 * This option indicates if the help should be shown. 064 */ 065 @NonNull 066 public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h") 067 .longOpt("help") 068 .desc("display this help message") 069 .build()); 070 /** 071 * This option indicates if colorized output should be disabled. 072 */ 073 @NonNull 074 public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder() 075 .longOpt("no-color") 076 .desc("do not colorize output") 077 .build()); 078 /** 079 * This option indicates if non-errors should be suppressed. 080 */ 081 @NonNull 082 public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q") 083 .longOpt("quiet") 084 .desc("minimize output to include only errors") 085 .build()); 086 /** 087 * This option indicates if a strack trace should be shown for an error 088 * {@link ExitStatus}. 089 */ 090 @NonNull 091 public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder() 092 .longOpt("show-stack-trace") 093 .desc("display the stack trace associated with an error") 094 .build()); 095 /** 096 * This option indicates if the version information should be shown. 097 */ 098 @NonNull 099 public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder() 100 .longOpt("version") 101 .desc("display the application version") 102 .build()); 103 104 @NonNull 105 private static final List<Option> OPTIONS = ObjectUtils.notNull(List.of( 106 HELP_OPTION, 107 NO_COLOR_OPTION, 108 QUIET_OPTION, 109 SHOW_STACK_TRACE_OPTION, 110 VERSION_OPTION)); 111 112 /** 113 * Used to identify the version info for the command. 114 */ 115 public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version"; 116 117 @NonNull 118 private final List<ICommand> commands = new LinkedList<>(); 119 @NonNull 120 private final String exec; 121 @NonNull 122 private final Map<String, IVersionInfo> versionInfos; 123 124 /** 125 * The main entry point for command execution. 126 * 127 * @param args 128 * the command line arguments to process 129 */ 130 public static void main(String... args) { 131 System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); 132 CLIProcessor processor = new CLIProcessor("metaschema-cli"); 133 134 CommandService.getInstance().getCommands().stream().forEach(command -> { 135 assert command != null; 136 processor.addCommandHandler(command); 137 }); 138 System.exit(processor.process(args).getExitCode().getStatusCode()); 139 } 140 141 /** 142 * The main entry point for CLI processing. 143 * <p> 144 * This uses the build-in version information. 145 * 146 * @param args 147 * the command line arguments 148 */ 149 public CLIProcessor(@NonNull String args) { 150 this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion())); 151 } 152 153 /** 154 * The main entry point for CLI processing. 155 * <p> 156 * This uses the provided version information. 157 * 158 * @param exec 159 * the command name 160 * @param versionInfos 161 * the version info to display when the version option is provided 162 */ 163 public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) { 164 this.exec = exec; 165 this.versionInfos = versionInfos; 166 AnsiConsole.systemInstall(); 167 } 168 169 /** 170 * Gets the command used to execute for use in help text. 171 * 172 * @return the command name 173 */ 174 @NonNull 175 public String getExec() { 176 return exec; 177 } 178 179 /** 180 * Retrieve the version information for this application. 181 * 182 * @return the versionInfo 183 */ 184 @NonNull 185 public Map<String, IVersionInfo> getVersionInfos() { 186 return versionInfos; 187 } 188 189 /** 190 * Register a new command handler. 191 * 192 * @param handler 193 * the command handler to register 194 */ 195 public void addCommandHandler(@NonNull ICommand handler) { 196 commands.add(handler); 197 } 198 199 /** 200 * Process a set of CLIProcessor arguments. 201 * <p> 202 * process().getExitCode().getStatusCode() 203 * 204 * @param args 205 * the arguments to process 206 * @return the exit status 207 */ 208 @NonNull 209 public ExitStatus process(String... args) { 210 return parseCommand(args); 211 } 212 213 @NonNull 214 private ExitStatus parseCommand(String... args) { 215 List<String> commandArgs = Arrays.asList(args); 216 assert commandArgs != null; 217 CallingContext callingContext = new CallingContext(commandArgs); 218 219 if (LOGGER.isDebugEnabled()) { 220 String commandChain = callingContext.getCalledCommands().stream() 221 .map(ICommand::getName) 222 .collect(Collectors.joining(" -> ")); 223 LOGGER.debug("Processing command chain: {}", commandChain); 224 } 225 226 ExitStatus status; 227 // the first two arguments should be the <command> and <operation>, where <type> 228 // is the object type 229 // the <operation> is performed against. 230 if (commandArgs.isEmpty()) { 231 status = ExitCode.INVALID_COMMAND.exit(); 232 callingContext.showHelp(); 233 } else { 234 status = callingContext.processCommand(); 235 } 236 return status; 237 } 238 239 /** 240 * Get the root-level commands. 241 * 242 * @return the list of commands 243 */ 244 @NonNull 245 protected final List<ICommand> getTopLevelCommands() { 246 return CollectionUtil.unmodifiableList(commands); 247 } 248 249 /** 250 * Get the root-level commands, mapped from name to command. 251 * 252 * @return the map of command names to command 253 */ 254 @NonNull 255 protected final Map<String, ICommand> getTopLevelCommandsByName() { 256 return ObjectUtils.notNull(getTopLevelCommands() 257 .stream() 258 .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity()))); 259 } 260 261 private static void handleNoColor() { 262 System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP); 263 AnsiConsole.systemUninstall(); 264 } 265 266 /** 267 * Configure the logger to only report errors. 268 */ 269 public static void handleQuiet() { 270 LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here 271 Configuration config = ctx.getConfiguration(); 272 LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME); 273 Level oldLevel = loggerConfig.getLevel(); 274 if (oldLevel.isLessSpecificThan(Level.ERROR)) { 275 loggerConfig.setLevel(Level.ERROR); 276 ctx.updateLoggers(); 277 } 278 } 279 280 /** 281 * Output version information. 282 */ 283 protected void showVersion() { 284 @SuppressWarnings("resource") 285 PrintStream out = AnsiConsole.out(); // NOPMD - not owner 286 getVersionInfos().values().stream().forEach(info -> { 287 out.println(ansi() 288 .bold().a(info.getName()).boldOff() 289 .a(" ") 290 .bold().a(info.getVersion()).boldOff() 291 .a(" built at ") 292 .bold().a(info.getBuildTimestamp()).boldOff() 293 .a(" from branch ") 294 .bold().a(info.getGitBranch()).boldOff() 295 .a(" (") 296 .bold().a(info.getGitCommit()).boldOff() 297 .a(") at ") 298 .bold().a(info.getGitOriginUrl()).boldOff() 299 .reset()); 300 }); 301 out.flush(); 302 } 303 304 /** 305 * Records information about the command line options and called command 306 * hierarchy. 307 */ 308 @SuppressWarnings("PMD.GodClass") 309 public final class CallingContext { 310 @NonNull 311 private final List<Option> options; 312 @NonNull 313 private final List<ICommand> calledCommands; 314 @Nullable 315 private final ICommand targetCommand; 316 @NonNull 317 private final List<String> extraArgs; 318 319 @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields") 320 private CallingContext(@NonNull List<String> args) { 321 @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast 322 LinkedList<ICommand> calledCommands = new LinkedList<>(); 323 List<Option> options = new LinkedList<>(OPTIONS); 324 List<String> extraArgs = new LinkedList<>(); 325 326 AtomicBoolean endArgs = new AtomicBoolean(); 327 args.forEach(arg -> { 328 if (endArgs.get() || arg.startsWith("-")) { 329 extraArgs.add(arg); 330 } else if ("--".equals(arg)) { 331 endArgs.set(true); 332 } else { 333 ICommand command = calledCommands.isEmpty() 334 ? getTopLevelCommandsByName().get(arg) 335 : calledCommands.getLast().getSubCommandByName(arg); 336 337 if (command == null) { 338 extraArgs.add(arg); 339 endArgs.set(true); 340 } else { 341 calledCommands.add(command); 342 options.addAll(command.gatherOptions()); 343 } 344 } 345 }); 346 347 this.calledCommands = CollectionUtil.unmodifiableList(calledCommands); 348 this.targetCommand = calledCommands.peekLast(); 349 this.options = CollectionUtil.unmodifiableList(options); 350 this.extraArgs = CollectionUtil.unmodifiableList(extraArgs); 351 } 352 353 /** 354 * Get the command line processor instance that generated this calling context. 355 * 356 * @return the instance 357 */ 358 @NonNull 359 public CLIProcessor getCLIProcessor() { 360 return CLIProcessor.this; 361 } 362 363 /** 364 * Get the command that was triggered by the CLI arguments. 365 * 366 * @return the command or {@code null} if no command was triggered 367 */ 368 @Nullable 369 public ICommand getTargetCommand() { 370 return targetCommand; 371 } 372 373 /** 374 * Get the options that are in scope for the current command context. 375 * 376 * @return the list of options 377 */ 378 @NonNull 379 private List<Option> getOptionsList() { 380 return options; 381 } 382 383 @NonNull 384 private List<ICommand> getCalledCommands() { 385 return calledCommands; 386 } 387 388 /** 389 * Get any left over arguments that were not consumed by CLI options. 390 * 391 * @return the list of remaining arguments, which may be empty 392 */ 393 @NonNull 394 private List<String> getExtraArgs() { 395 return extraArgs; 396 } 397 398 /** 399 * Get the collections of in scope options as an options group. 400 * 401 * @return the options group 402 */ 403 private Options toOptions() { 404 Options retval = new Options(); 405 for (Option option : getOptionsList()) { 406 retval.addOption(option); 407 } 408 return retval; 409 } 410 411 /** 412 * Process the command identified by the CLI arguments. 413 * 414 * @return the result of processing the command 415 */ 416 @SuppressWarnings({ 417 "PMD.OnlyOneReturn", 418 "PMD.NPathComplexity", 419 "PMD.CyclomaticComplexity" 420 }) 421 @NonNull 422 public ExitStatus processCommand() { 423 // TODO: Consider refactoring as follows to reduce complexity: 424 // - Extract the parsing logic for each phase into separate methods (e.g., 425 // parsePhaseOneOptions, parsePhaseTwoOptions). 426 // - Encapsulate error handling into dedicated methods. 427 // - Separate command execution logic into its own method if possible. 428 CommandLineParser parser = new DefaultParser(); 429 430 // this uses a three phase approach where: 431 // phase 1: checks if help or version are used 432 // phase 2: parse and validate arguments 433 // phase 3: executes the command 434 435 // phase 1 436 CommandLine cmdLine; 437 try { 438 Options phase1Options = new Options(); 439 phase1Options.addOption(HELP_OPTION); 440 phase1Options.addOption(VERSION_OPTION); 441 442 cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true)); 443 } catch (ParseException ex) { 444 String msg = ex.getMessage(); 445 assert msg != null; 446 return handleInvalidCommand(msg); 447 } 448 449 if (cmdLine.hasOption(VERSION_OPTION)) { 450 showVersion(); 451 return ExitCode.OK.exit(); 452 } 453 if (cmdLine.hasOption(HELP_OPTION)) { 454 showHelp(); 455 return ExitCode.OK.exit(); 456 } 457 458 // phase 2 459 try { 460 cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0]))); 461 } catch (ParseException ex) { 462 String msg = ex.getMessage(); 463 assert msg != null; 464 return handleInvalidCommand(msg); 465 } 466 467 ICommand targetCommand = getTargetCommand(); 468 if (targetCommand != null) { 469 try { 470 targetCommand.validateExtraArguments(this, cmdLine); 471 } catch (InvalidArgumentException ex) { 472 return handleError( 473 ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()), 474 cmdLine, 475 true); 476 } 477 } 478 479 for (ICommand cmd : getCalledCommands()) { 480 try { 481 cmd.validateOptions(this, cmdLine); 482 } catch (InvalidArgumentException ex) { 483 String msg = ex.getMessage(); 484 assert msg != null; 485 return handleInvalidCommand(msg); 486 } 487 } 488 489 // phase 3 490 if (cmdLine.hasOption(NO_COLOR_OPTION)) { 491 handleNoColor(); 492 } 493 494 if (cmdLine.hasOption(QUIET_OPTION)) { 495 handleQuiet(); 496 } 497 return invokeCommand(cmdLine); 498 } 499 500 /** 501 * Directly execute the logic associated with the command. 502 * 503 * @param cmdLine 504 * the command line information 505 * @return the result of executing the command 506 */ 507 @SuppressWarnings({ 508 "PMD.OnlyOneReturn", // readability 509 "PMD.AvoidCatchingGenericException" // needed here 510 }) 511 @NonNull 512 private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) { 513 ExitStatus retval; 514 try { 515 ICommand targetCommand = getTargetCommand(); 516 if (targetCommand == null) { 517 retval = ExitCode.INVALID_COMMAND.exit(); 518 } else { 519 ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine); 520 try { 521 executor.execute(); 522 retval = ExitCode.OK.exit(); 523 } catch (CommandExecutionException ex) { 524 retval = ex.toExitStatus(); 525 } catch (RuntimeException ex) { 526 retval = ExitCode.RUNTIME_ERROR 527 .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage()) 528 .withThrowable(ex); 529 } 530 } 531 } catch (RuntimeException ex) { 532 retval = ExitCode.RUNTIME_ERROR 533 .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage())) 534 .withThrowable(ex); 535 } 536 537 if (!ExitCode.OK.equals(retval.getExitCode())) { 538 retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION)); 539 540 if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) { 541 showHelp(); 542 } 543 } 544 return retval; 545 } 546 547 /** 548 * Handle an error that occurred while executing the command. 549 * 550 * @param exitStatus 551 * the execution result 552 * @param cmdLine 553 * the command line information 554 * @param showHelp 555 * if {@code true} show the help information 556 * @return the resulting exit status 557 */ 558 @NonNull 559 public ExitStatus handleError( 560 @NonNull ExitStatus exitStatus, 561 @NonNull CommandLine cmdLine, 562 boolean showHelp) { 563 exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION)); 564 if (showHelp) { 565 showHelp(); 566 } 567 return exitStatus; 568 } 569 570 /** 571 * Generate the help message and exit status for an invalid command using the 572 * provided message. 573 * 574 * @param message 575 * the error message 576 * @return the resulting exit status 577 */ 578 @NonNull 579 public ExitStatus handleInvalidCommand( 580 @NonNull String message) { 581 showHelp(); 582 583 ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message); 584 retval.generateMessage(false); 585 return retval; 586 } 587 588 /** 589 * Callback for providing a help header. 590 * 591 * @return the header or {@code null} 592 */ 593 @Nullable 594 private String buildHelpHeader() { 595 // TODO: build a suitable header 596 return null; 597 } 598 599 /** 600 * Callback for providing a help footer. 601 * 602 * @param exec 603 * the executable name 604 * 605 * @return the footer or {@code null} 606 */ 607 @NonNull 608 private String buildHelpFooter() { 609 610 ICommand targetCommand = getTargetCommand(); 611 Collection<ICommand> subCommands; 612 if (targetCommand == null) { 613 subCommands = getTopLevelCommands(); 614 } else { 615 subCommands = targetCommand.getSubCommands(); 616 } 617 618 String retval; 619 if (subCommands.isEmpty()) { 620 retval = ""; 621 } else { 622 StringBuilder builder = new StringBuilder(128); 623 builder 624 .append(System.lineSeparator()) 625 .append("The following are available commands:") 626 .append(System.lineSeparator()); 627 628 int length = subCommands.stream() 629 .mapToInt(command -> command.getName().length()) 630 .max().orElse(0); 631 632 for (ICommand command : subCommands) { 633 builder.append( 634 ansi() 635 .render(String.format(" @|bold %-" + length + "s|@ %s%n", 636 command.getName(), 637 command.getDescription()))); 638 } 639 builder 640 .append(System.lineSeparator()) 641 .append('\'') 642 .append(getExec()) 643 .append(" <command> --help' will show help on that specific command.") 644 .append(System.lineSeparator()); 645 retval = builder.toString(); 646 assert retval != null; 647 } 648 return retval; 649 } 650 651 /** 652 * Get the CLI syntax. 653 * 654 * @return the CLI syntax to display in help output 655 */ 656 private String buildHelpCliSyntax() { 657 658 StringBuilder builder = new StringBuilder(64); 659 builder.append(getExec()); 660 661 List<ICommand> calledCommands = getCalledCommands(); 662 if (!calledCommands.isEmpty()) { 663 builder.append(calledCommands.stream() 664 .map(ICommand::getName) 665 .collect(Collectors.joining(" ", " ", ""))); 666 } 667 668 // output calling commands 669 ICommand targetCommand = getTargetCommand(); 670 if (targetCommand == null) { 671 builder.append(" <command>"); 672 } else { 673 builder.append(getSubCommands(targetCommand)); 674 } 675 676 // output required options 677 getOptionsList().stream() 678 .filter(Option::isRequired) 679 .forEach(option -> { 680 builder 681 .append(' ') 682 .append(OptionUtils.toArgument(ObjectUtils.notNull(option))); 683 if (option.hasArg()) { 684 builder 685 .append('=') 686 .append(option.getArgName()); 687 } 688 }); 689 690 // output non-required option placeholder 691 builder.append(" [<options>]"); 692 693 // output extra arguments 694 if (targetCommand != null) { 695 // handle extra arguments 696 builder.append(getExtraArguments(targetCommand)); 697 } 698 699 String retval = builder.toString(); 700 assert retval != null; 701 return retval; 702 } 703 704 @NonNull 705 private CharSequence getSubCommands(ICommand targetCommand) { 706 Collection<ICommand> subCommands = targetCommand.getSubCommands(); 707 708 StringBuilder builder = new StringBuilder(); 709 if (!subCommands.isEmpty()) { 710 builder.append(' '); 711 if (!targetCommand.isSubCommandRequired()) { 712 builder.append('['); 713 } 714 715 builder.append("<command>"); 716 717 if (!targetCommand.isSubCommandRequired()) { 718 builder.append(']'); 719 } 720 } 721 return builder; 722 } 723 724 @NonNull 725 private CharSequence getExtraArguments(@NonNull ICommand targetCommand) { 726 StringBuilder builder = new StringBuilder(); 727 for (ExtraArgument argument : targetCommand.getExtraArguments()) { 728 builder.append(' '); 729 if (!argument.isRequired()) { 730 builder.append('['); 731 } 732 733 builder.append('<') 734 .append(argument.getName()) 735 .append('>'); 736 737 if (argument.getNumber() > 1) { 738 builder.append("..."); 739 } 740 741 if (!argument.isRequired()) { 742 builder.append(']'); 743 } 744 } 745 return builder; 746 } 747 748 /** 749 * Output the help text to the console. 750 */ 751 public void showHelp() { 752 753 HelpFormatter formatter = new HelpFormatter(); 754 formatter.setLongOptSeparator("="); 755 756 @SuppressWarnings("resource") 757 AnsiPrintStream out = AnsiConsole.out(); 758 759 try (PrintWriter writer = new PrintWriter( // NOPMD not owned 760 AutoCloser.preventClose(out), 761 true, 762 StandardCharsets.UTF_8)) { 763 formatter.printHelp( 764 writer, 765 Math.max(out.getTerminalWidth(), 50), 766 buildHelpCliSyntax(), 767 buildHelpHeader(), 768 toOptions(), 769 HelpFormatter.DEFAULT_LEFT_PAD, 770 HelpFormatter.DEFAULT_DESC_PAD, 771 buildHelpFooter(), 772 false); 773 writer.flush(); 774 } 775 } 776 } 777}