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