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