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 052public class CLIProcessor { 053 private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class); 054 055 @SuppressWarnings("null") 056 @NonNull 057 public static final Option HELP_OPTION = Option.builder("h") 058 .longOpt("help") 059 .desc("display this help message") 060 .build(); 061 @SuppressWarnings("null") 062 @NonNull 063 public static final Option NO_COLOR_OPTION = Option.builder() 064 .longOpt("no-color") 065 .desc("do not colorize output") 066 .build(); 067 @SuppressWarnings("null") 068 @NonNull 069 public static final Option QUIET_OPTION = Option.builder("q") 070 .longOpt("quiet") 071 .desc("minimize output to include only errors") 072 .build(); 073 @SuppressWarnings("null") 074 @NonNull 075 public static final Option SHOW_STACK_TRACE_OPTION = Option.builder() 076 .longOpt("show-stack-trace") 077 .desc("display the stack trace associated with an error") 078 .build(); 079 @SuppressWarnings("null") 080 @NonNull 081 public static final Option VERSION_OPTION = Option.builder() 082 .longOpt("version") 083 .desc("display the application version") 084 .build(); 085 @SuppressWarnings("null") 086 @NonNull 087 public static final List<Option> OPTIONS = List.of( 088 HELP_OPTION, 089 NO_COLOR_OPTION, 090 QUIET_OPTION, 091 SHOW_STACK_TRACE_OPTION, 092 VERSION_OPTION); 093 094 public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version"; 095 096 @NonNull 097 private final List<ICommand> commands = new LinkedList<>(); 098 @NonNull 099 private final String exec; 100 @NonNull 101 private final Map<String, IVersionInfo> versionInfos; 102 103 public static void main(String... args) { 104 System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); 105 CLIProcessor processor = new CLIProcessor("metaschema-cli"); 106 107 CommandService.getInstance().getCommands().stream().forEach(command -> { 108 assert command != null; 109 processor.addCommandHandler(command); 110 }); 111 System.exit(processor.process(args).getExitCode().getStatusCode()); 112 } 113 114 @SuppressWarnings("null") 115 public CLIProcessor(@NonNull String exec) { 116 this(exec, Map.of()); 117 } 118 119 public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) { 120 this.exec = exec; 121 this.versionInfos = versionInfos; 122 AnsiConsole.systemInstall(); 123 } 124 125 /** 126 * Gets the command used to execute for use in help text. 127 * 128 * @return the command name 129 */ 130 @NonNull 131 public String getExec() { 132 return exec; 133 } 134 135 /** 136 * Retrieve the version information for this application. 137 * 138 * @return the versionInfo 139 */ 140 @NonNull 141 public Map<String, IVersionInfo> getVersionInfos() { 142 return versionInfos; 143 } 144 145 public void addCommandHandler(@NonNull ICommand handler) { 146 commands.add(handler); 147 } 148 149 /** 150 * Process a set of CLIProcessor arguments. 151 * <p> 152 * process().getExitCode().getStatusCode() 153 * 154 * @param args 155 * the arguments to process 156 * @return the exit status 157 */ 158 @NonNull 159 public ExitStatus process(String... args) { 160 return parseCommand(args); 161 } 162 163 @NonNull 164 private ExitStatus parseCommand(String... args) { 165 List<String> commandArgs = Arrays.asList(args); 166 assert commandArgs != null; 167 CallingContext callingContext = new CallingContext(commandArgs); 168 169 if (LOGGER.isDebugEnabled()) { 170 String commandChain = callingContext.getCalledCommands().stream() 171 .map(ICommand::getName) 172 .collect(Collectors.joining(" -> ")); 173 LOGGER.debug("Processing command chain: {}", commandChain); 174 } 175 176 ExitStatus status; 177 // the first two arguments should be the <command> and <operation>, where <type> 178 // is the object type 179 // the <operation> is performed against. 180 if (commandArgs.isEmpty()) { 181 status = ExitCode.INVALID_COMMAND.exit(); 182 callingContext.showHelp(); 183 } else { 184 status = callingContext.processCommand(); 185 } 186 return status; 187 } 188 189 @NonNull 190 protected final List<ICommand> getTopLevelCommands() { 191 return CollectionUtil.unmodifiableList(commands); 192 } 193 194 @NonNull 195 protected final Map<String, ICommand> getTopLevelCommandsByName() { 196 return ObjectUtils.notNull(getTopLevelCommands() 197 .stream() 198 .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity()))); 199 } 200 201 private static void handleNoColor() { 202 System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP); 203 AnsiConsole.systemUninstall(); 204 } 205 206 public static void handleQuiet() { 207 LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here 208 Configuration config = ctx.getConfiguration(); 209 LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME); 210 Level oldLevel = loggerConfig.getLevel(); 211 if (oldLevel.isLessSpecificThan(Level.ERROR)) { 212 loggerConfig.setLevel(Level.ERROR); 213 ctx.updateLoggers(); 214 } 215 } 216 217 protected void showVersion() { 218 @SuppressWarnings("resource") 219 PrintStream out = AnsiConsole.out(); // NOPMD - not owner 220 getVersionInfos().values().stream().forEach(info -> { 221 out.println(ansi() 222 .bold().a(info.getName()).boldOff() 223 .a(" ") 224 .bold().a(info.getVersion()).boldOff() 225 .a(" built at ") 226 .bold().a(info.getBuildTimestamp()).boldOff() 227 .a(" from branch ") 228 .bold().a(info.getGitBranch()).boldOff() 229 .a(" (") 230 .bold().a(info.getGitCommit()).boldOff() 231 .a(") at ") 232 .bold().a(info.getGitOriginUrl()).boldOff() 233 .reset()); 234 }); 235 out.flush(); 236 } 237 238 // @SuppressWarnings("null") 239 // @NonNull 240 // public String[] getArgArray() { 241 // return Stream.concat(options.stream(), extraArgs.stream()).toArray(size -> 242 // new String[size]); 243 // } 244 245 public class CallingContext { 246 @NonNull 247 private final List<Option> options; 248 @NonNull 249 private final List<ICommand> calledCommands; 250 @Nullable 251 private final ICommand targetCommand; 252 @NonNull 253 private final List<String> extraArgs; 254 255 @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields") 256 public CallingContext(@NonNull List<String> args) { 257 @SuppressWarnings("PMD.LooseCoupling") 258 LinkedList<ICommand> calledCommands = new LinkedList<>(); 259 List<Option> options = new LinkedList<>(OPTIONS); 260 List<String> extraArgs = new LinkedList<>(); 261 262 AtomicBoolean endArgs = new AtomicBoolean(); 263 args.forEach(arg -> { 264 if (endArgs.get() || arg.startsWith("-")) { 265 extraArgs.add(arg); 266 } else if ("--".equals(arg)) { 267 endArgs.set(true); 268 } else { 269 ICommand command = calledCommands.isEmpty() 270 ? getTopLevelCommandsByName().get(arg) 271 : calledCommands.getLast().getSubCommandByName(arg); 272 273 if (command == null) { 274 extraArgs.add(arg); 275 endArgs.set(true); 276 } else { 277 calledCommands.add(command); 278 options.addAll(command.gatherOptions()); 279 } 280 } 281 }); 282 283 this.calledCommands = CollectionUtil.unmodifiableList(calledCommands); 284 this.targetCommand = calledCommands.peekLast(); 285 this.options = CollectionUtil.unmodifiableList(options); 286 this.extraArgs = CollectionUtil.unmodifiableList(extraArgs); 287 } 288 289 @NonNull 290 public CLIProcessor getCLIProcessor() { 291 return CLIProcessor.this; 292 } 293 294 @Nullable 295 public ICommand getTargetCommand() { 296 return targetCommand; 297 } 298 299 @NonNull 300 protected List<Option> getOptionsList() { 301 return options; 302 } 303 304 @NonNull 305 private List<ICommand> getCalledCommands() { 306 return calledCommands; 307 } 308 309 @NonNull 310 protected List<String> getExtraArgs() { 311 return extraArgs; 312 } 313 314 protected Options toOptions() { 315 Options retval = new Options(); 316 for (Option option : getOptionsList()) { 317 retval.addOption(option); 318 } 319 return retval; 320 } 321 322 @SuppressWarnings("PMD.OnlyOneReturn") // readability 323 @NonNull 324 public ExitStatus processCommand() { 325 CommandLineParser parser = new DefaultParser(); 326 327 // this uses a three phase approach where: 328 // phase 1: checks if help or version are used 329 // phase 2: parse and validate arguments 330 // phase 3: executes the command 331 332 // phase 1 333 CommandLine cmdLine; 334 try { 335 Options phase1Options = new Options(); 336 phase1Options.addOption(HELP_OPTION); 337 phase1Options.addOption(VERSION_OPTION); 338 339 cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true)); 340 } catch (ParseException ex) { 341 String msg = ex.getMessage(); 342 assert msg != null; 343 return handleInvalidCommand(msg); 344 } 345 346 if (cmdLine.hasOption(VERSION_OPTION)) { 347 showVersion(); 348 return ExitCode.OK.exit(); 349 } 350 if (cmdLine.hasOption(HELP_OPTION)) { 351 showHelp(); 352 return ExitCode.OK.exit(); 353 } 354 355 // phase 2 356 try { 357 cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0]))); 358 } catch (ParseException ex) { 359 String msg = ex.getMessage(); 360 assert msg != null; 361 return handleInvalidCommand(msg); 362 } 363 364 ICommand targetCommand = getTargetCommand(); 365 if (targetCommand != null) { 366 if (targetCommand.isSubCommandRequired()) { 367 return handleError( 368 ExitCode.INVALID_ARGUMENTS 369 .exitMessage("Please choose a valid sub-command."), 370 cmdLine, 371 true); 372 } 373 374 List<ExtraArgument> extraArguments = targetCommand.getExtraArguments(); 375 int maxArguments = extraArguments.size(); 376 377 List<String> actualArgs = cmdLine.getArgList(); 378 int actualArgsSize = actualArgs.size(); 379 if (actualArgs.size() > maxArguments) { 380 return handleError( 381 ExitCode.INVALID_ARGUMENTS 382 .exitMessage("The provided extra arguments exceed the number of allowed arguments."), 383 cmdLine, 384 true); 385 } 386 387 List<ExtraArgument> requiredExtraArguments = targetCommand.getExtraArguments().stream() 388 .filter(ExtraArgument::isRequired) 389 .collect(Collectors.toUnmodifiableList()); 390 391 if (actualArgsSize < requiredExtraArguments.size()) { 392 return handleError( 393 ExitCode.INVALID_ARGUMENTS 394 .exitMessage("Please provide the required extra arguments."), 395 cmdLine, 396 true); 397 } 398 } 399 400 for (ICommand cmd : getCalledCommands()) { 401 try { 402 cmd.validateOptions(this, cmdLine); 403 } catch (InvalidArgumentException ex) { 404 String msg = ex.getMessage(); 405 assert msg != null; 406 return handleInvalidCommand(msg); 407 } 408 } 409 410 // phase 3 411 if (cmdLine.hasOption(NO_COLOR_OPTION)) { 412 handleNoColor(); 413 } 414 415 if (cmdLine.hasOption(QUIET_OPTION)) { 416 handleQuiet(); 417 } 418 return invokeCommand(cmdLine); 419 } 420 421 @SuppressWarnings({ 422 "PMD.OnlyOneReturn", // readability 423 "PMD.AvoidCatchingGenericException" // needed here 424 }) 425 @NonNull 426 protected ExitStatus invokeCommand(@NonNull CommandLine cmdLine) { 427 ExitStatus retval; 428 try { 429 ICommand targetCommand = getTargetCommand(); 430 if (targetCommand == null) { 431 retval = ExitCode.INVALID_COMMAND.exit(); 432 } else { 433 ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine); 434 try { 435 executor.execute(); 436 retval = ExitCode.OK.exit(); 437 } catch (CommandExecutionException ex) { 438 retval = ex.toExitStatus(); 439 } catch (RuntimeException ex) { 440 retval = ExitCode.RUNTIME_ERROR 441 .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage()) 442 .withThrowable(ex); 443 } 444 } 445 } catch (RuntimeException ex) { 446 retval = ExitCode.RUNTIME_ERROR 447 .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage())) 448 .withThrowable(ex); 449 } 450 451 if (!ExitCode.OK.equals(retval.getExitCode())) { 452 retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION)); 453 454 if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) { 455 showHelp(); 456 } 457 } 458 return retval; 459 } 460 461 @NonNull 462 public ExitStatus handleError( 463 @NonNull ExitStatus exitStatus, 464 @NonNull CommandLine cmdLine, 465 boolean showHelp) { 466 exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION)); 467 if (showHelp) { 468 showHelp(); 469 } 470 return exitStatus; 471 } 472 473 @NonNull 474 public ExitStatus handleInvalidCommand( 475 @NonNull String message) { 476 showHelp(); 477 478 ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message); 479 retval.generateMessage(false); 480 return retval; 481 } 482 483 /** 484 * Callback for providing a help header. 485 * 486 * @return the header or {@code null} 487 */ 488 @Nullable 489 protected String buildHelpHeader() { 490 // TODO: build a suitable header 491 return null; 492 } 493 494 /** 495 * Callback for providing a help footer. 496 * 497 * @param exec 498 * the executable name 499 * 500 * @return the footer or {@code null} 501 */ 502 @NonNull 503 private String buildHelpFooter() { 504 505 ICommand targetCommand = getTargetCommand(); 506 Collection<ICommand> subCommands; 507 if (targetCommand == null) { 508 subCommands = getTopLevelCommands(); 509 } else { 510 subCommands = targetCommand.getSubCommands(); 511 } 512 513 String retval; 514 if (subCommands.isEmpty()) { 515 retval = ""; 516 } else { 517 StringBuilder builder = new StringBuilder(128); 518 builder 519 .append(System.lineSeparator()) 520 .append("The following are available commands:") 521 .append(System.lineSeparator()); 522 523 int length = subCommands.stream() 524 .mapToInt(command -> command.getName().length()) 525 .max().orElse(0); 526 527 for (ICommand command : subCommands) { 528 builder.append( 529 ansi() 530 .render(String.format(" @|bold %-" + length + "s|@ %s%n", 531 command.getName(), 532 command.getDescription()))); 533 } 534 builder 535 .append(System.lineSeparator()) 536 .append('\'') 537 .append(getExec()) 538 .append(" <command> --help' will show help on that specific command.") 539 .append(System.lineSeparator()); 540 retval = builder.toString(); 541 assert retval != null; 542 } 543 return retval; 544 } 545 546 /** 547 * Get the CLI syntax. 548 * 549 * @return the CLI syntax to display in help output 550 */ 551 protected String buildHelpCliSyntax() { 552 553 StringBuilder builder = new StringBuilder(64); 554 builder.append(getExec()); 555 556 List<ICommand> calledCommands = getCalledCommands(); 557 if (!calledCommands.isEmpty()) { 558 builder.append(calledCommands.stream() 559 .map(ICommand::getName) 560 .collect(Collectors.joining(" ", " ", ""))); 561 } 562 563 // output calling commands 564 ICommand targetCommand = getTargetCommand(); 565 if (targetCommand == null) { 566 builder.append(" <command>"); 567 } else { 568 Collection<ICommand> subCommands = targetCommand.getSubCommands(); 569 570 if (!subCommands.isEmpty()) { 571 builder.append(' '); 572 if (!targetCommand.isSubCommandRequired()) { 573 builder.append('['); 574 } 575 576 builder.append("<command>"); 577 578 if (!targetCommand.isSubCommandRequired()) { 579 builder.append(']'); 580 } 581 } 582 } 583 584 // output required options 585 getOptionsList().stream() 586 .filter(Option::isRequired) 587 .forEach(option -> { 588 builder 589 .append(' ') 590 .append(OptionUtils.toArgument(ObjectUtils.notNull(option))); 591 if (option.hasArg()) { 592 builder 593 .append('=') 594 .append(option.getArgName()); 595 } 596 }); 597 598 // output non-required option placeholder 599 builder.append(" [<options>]"); 600 601 // output extra arguments 602 if (targetCommand != null) { 603 // handle extra arguments 604 for (ExtraArgument argument : targetCommand.getExtraArguments()) { 605 builder.append(' '); 606 if (!argument.isRequired()) { 607 builder.append('['); 608 } 609 610 builder.append('<') 611 .append(argument.getName()) 612 .append('>'); 613 614 if (argument.getNumber() > 1) { 615 builder.append("..."); 616 } 617 618 if (!argument.isRequired()) { 619 builder.append(']'); 620 } 621 } 622 } 623 624 String retval = builder.toString(); 625 assert retval != null; 626 return retval; 627 } 628 629 /** 630 * Output the help text to the console. 631 */ 632 public void showHelp() { 633 634 HelpFormatter formatter = new HelpFormatter(); 635 formatter.setLongOptSeparator("="); 636 637 @SuppressWarnings("resource") 638 AnsiPrintStream out = AnsiConsole.out(); 639 640 try (PrintWriter writer = new PrintWriter( // NOPMD not owned 641 AutoCloser.preventClose(out), 642 true, 643 StandardCharsets.UTF_8)) { 644 formatter.printHelp( 645 writer, 646 Math.max(out.getTerminalWidth(), 50), 647 buildHelpCliSyntax(), 648 buildHelpHeader(), 649 toOptions(), 650 HelpFormatter.DEFAULT_LEFT_PAD, 651 HelpFormatter.DEFAULT_DESC_PAD, 652 buildHelpFooter(), 653 false); 654 writer.flush(); 655 } 656 } 657 } 658}