1
2
3
4
5
6 package gov.nist.secauto.metaschema.cli.processor;
7
8 import static org.fusesource.jansi.Ansi.ansi;
9
10 import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
11 import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
12 import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
13 import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
14 import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
15 import gov.nist.secauto.metaschema.core.util.AutoCloser;
16 import gov.nist.secauto.metaschema.core.util.CollectionUtil;
17 import gov.nist.secauto.metaschema.core.util.IVersionInfo;
18 import gov.nist.secauto.metaschema.core.util.ObjectUtils;
19
20 import org.apache.commons.cli.CommandLine;
21 import org.apache.commons.cli.CommandLineParser;
22 import org.apache.commons.cli.DefaultParser;
23 import org.apache.commons.cli.HelpFormatter;
24 import org.apache.commons.cli.Option;
25 import org.apache.commons.cli.Options;
26 import org.apache.commons.cli.ParseException;
27 import org.apache.logging.log4j.Level;
28 import org.apache.logging.log4j.LogManager;
29 import org.apache.logging.log4j.Logger;
30 import org.apache.logging.log4j.core.LoggerContext;
31 import org.apache.logging.log4j.core.config.Configuration;
32 import org.apache.logging.log4j.core.config.LoggerConfig;
33 import org.fusesource.jansi.AnsiConsole;
34 import org.fusesource.jansi.AnsiPrintStream;
35
36 import java.io.PrintStream;
37 import java.io.PrintWriter;
38 import java.nio.charset.StandardCharsets;
39 import java.util.Arrays;
40 import java.util.Collection;
41 import java.util.LinkedList;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.concurrent.atomic.AtomicBoolean;
45 import java.util.function.Function;
46 import java.util.stream.Collectors;
47
48 import edu.umd.cs.findbugs.annotations.NonNull;
49 import edu.umd.cs.findbugs.annotations.Nullable;
50 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
51
52 public class CLIProcessor {
53 private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
54
55 @SuppressWarnings("null")
56 @NonNull
57 public static final Option HELP_OPTION = Option.builder("h")
58 .longOpt("help")
59 .desc("display this help message")
60 .build();
61 @SuppressWarnings("null")
62 @NonNull
63 public static final Option NO_COLOR_OPTION = Option.builder()
64 .longOpt("no-color")
65 .desc("do not colorize output")
66 .build();
67 @SuppressWarnings("null")
68 @NonNull
69 public static final Option QUIET_OPTION = Option.builder("q")
70 .longOpt("quiet")
71 .desc("minimize output to include only errors")
72 .build();
73 @SuppressWarnings("null")
74 @NonNull
75 public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
76 .longOpt("show-stack-trace")
77 .desc("display the stack trace associated with an error")
78 .build();
79 @SuppressWarnings("null")
80 @NonNull
81 public static final Option VERSION_OPTION = Option.builder()
82 .longOpt("version")
83 .desc("display the application version")
84 .build();
85 @SuppressWarnings("null")
86 @NonNull
87 public static final List<Option> OPTIONS = List.of(
88 HELP_OPTION,
89 NO_COLOR_OPTION,
90 QUIET_OPTION,
91 SHOW_STACK_TRACE_OPTION,
92 VERSION_OPTION);
93
94 public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
95
96 @NonNull
97 private final List<ICommand> commands = new LinkedList<>();
98 @NonNull
99 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
127
128
129
130 @NonNull
131 public String getExec() {
132 return exec;
133 }
134
135
136
137
138
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
151
152
153
154
155
156
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
178
179
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);
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();
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
239
240
241
242
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")
323 @NonNull
324 public ExitStatus processCommand() {
325 CommandLineParser parser = new DefaultParser();
326
327
328
329
330
331
332
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
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
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",
423 "PMD.AvoidCatchingGenericException"
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
485
486
487
488 @Nullable
489 protected String buildHelpHeader() {
490
491 return null;
492 }
493
494
495
496
497
498
499
500
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
548
549
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
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
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
599 builder.append(" [<options>]");
600
601
602 if (targetCommand != null) {
603
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
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(
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 }