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
53
54
55
56
57
58 @SuppressWarnings("PMD.CouplingBetweenObjects")
59 public class CLIProcessor {
60 private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
61
62
63
64
65 @NonNull
66 public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
67 .longOpt("help")
68 .desc("display this help message")
69 .get());
70
71
72
73 @NonNull
74 public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
75 .longOpt("no-color")
76 .desc("do not colorize output")
77 .get());
78
79
80
81 @NonNull
82 public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
83 .longOpt("quiet")
84 .desc("minimize output to include only errors")
85 .get());
86
87
88
89
90 @NonNull
91 public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
92 .longOpt("show-stack-trace")
93 .desc("display the stack trace associated with an error")
94 .get());
95
96
97
98 @NonNull
99 public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
100 .longOpt("version")
101 .desc("display the application version")
102 .get());
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
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 @NonNull
124 private final PrintStream outputStream;
125
126
127
128
129
130
131
132 public static void main(String... args) {
133 System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
134 CLIProcessor processor = new CLIProcessor("metaschema-cli");
135
136 CommandService.getInstance().getCommands().stream().forEach(command -> {
137 assert command != null;
138 processor.addCommandHandler(command);
139 });
140 System.exit(processor.process(args).getExitCode().getStatusCode());
141 }
142
143
144
145
146
147
148
149
150
151 public CLIProcessor(@NonNull String args) {
152 this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion()));
153 }
154
155
156
157
158
159
160
161
162
163
164
165 public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
166 this(exec, versionInfos, null);
167 }
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183 public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos,
184 @Nullable PrintStream outputStream) {
185 this.exec = exec;
186 this.versionInfos = versionInfos;
187 if (outputStream == null) {
188 AnsiConsole.systemInstall();
189 this.outputStream = ObjectUtils.notNull(AnsiConsole.out());
190 } else {
191 this.outputStream = outputStream;
192 }
193 }
194
195
196
197
198
199
200 @NonNull
201 public String getExec() {
202 return exec;
203 }
204
205
206
207
208
209
210 @NonNull
211 public Map<String, IVersionInfo> getVersionInfos() {
212 return versionInfos;
213 }
214
215
216
217
218
219
220
221 public void addCommandHandler(@NonNull ICommand handler) {
222 commands.add(handler);
223 }
224
225
226
227
228
229
230
231
232
233
234 @NonNull
235 public ExitStatus process(String... args) {
236 return parseCommand(args);
237 }
238
239 @NonNull
240 private ExitStatus parseCommand(String... args) {
241 List<String> commandArgs = Arrays.asList(args);
242 assert commandArgs != null;
243 CallingContext callingContext = new CallingContext(commandArgs);
244
245 if (LOGGER.isDebugEnabled()) {
246 String commandChain = callingContext.getCalledCommands().stream()
247 .map(ICommand::getName)
248 .collect(Collectors.joining(" -> "));
249 LOGGER.debug("Processing command chain: {}", commandChain);
250 }
251
252 ExitStatus status;
253
254
255
256 if (commandArgs.isEmpty()) {
257 status = ExitCode.INVALID_COMMAND.exit();
258 callingContext.showHelp();
259 } else {
260 status = callingContext.processCommand();
261 }
262 return status;
263 }
264
265
266
267
268
269
270 @NonNull
271 protected final List<ICommand> getTopLevelCommands() {
272 return CollectionUtil.unmodifiableList(commands);
273 }
274
275
276
277
278
279
280 @NonNull
281 protected final Map<String, ICommand> getTopLevelCommandsByName() {
282 return ObjectUtils.notNull(getTopLevelCommands()
283 .stream()
284 .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
285 }
286
287 private static void handleNoColor() {
288 System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
289 AnsiConsole.systemUninstall();
290 }
291
292
293
294
295 public static void handleQuiet() {
296 LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
297 Configuration config = ctx.getConfiguration();
298 LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
299 Level oldLevel = loggerConfig.getLevel();
300 if (oldLevel.isLessSpecificThan(Level.ERROR)) {
301 loggerConfig.setLevel(Level.ERROR);
302 ctx.updateLoggers();
303 }
304 }
305
306
307
308
309 protected void showVersion() {
310 getVersionInfos().values().stream().forEach(info -> {
311 outputStream.println(ansi()
312 .bold().a(info.getName()).boldOff()
313 .a(" ")
314 .bold().a(info.getVersion()).boldOff()
315 .a(" built at ")
316 .bold().a(info.getBuildTimestamp()).boldOff()
317 .a(" from branch ")
318 .bold().a(info.getGitBranch()).boldOff()
319 .a(" (")
320 .bold().a(info.getGitCommit()).boldOff()
321 .a(") at ")
322 .bold().a(info.getGitOriginUrl()).boldOff()
323 .reset());
324 });
325 outputStream.flush();
326 }
327
328
329
330
331
332 @SuppressWarnings("PMD.GodClass")
333 public final class CallingContext {
334 @NonNull
335 private final List<Option> options;
336 @NonNull
337 private final List<ICommand> calledCommands;
338 @Nullable
339 private final ICommand targetCommand;
340 @NonNull
341 private final List<String> extraArgs;
342
343 @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
344 private CallingContext(@NonNull List<String> args) {
345 @SuppressWarnings("PMD.LooseCoupling")
346 LinkedList<ICommand> calledCommands = new LinkedList<>();
347 List<Option> options = new LinkedList<>(OPTIONS);
348 List<String> extraArgs = new LinkedList<>();
349
350 AtomicBoolean endArgs = new AtomicBoolean();
351 args.forEach(arg -> {
352 if (endArgs.get() || arg.startsWith("-")) {
353 extraArgs.add(arg);
354 } else if ("--".equals(arg)) {
355 endArgs.set(true);
356 } else {
357 ICommand command = calledCommands.isEmpty()
358 ? getTopLevelCommandsByName().get(arg)
359 : calledCommands.getLast().getSubCommandByName(arg);
360
361 if (command == null) {
362 extraArgs.add(arg);
363 endArgs.set(true);
364 } else {
365 calledCommands.add(command);
366 options.addAll(command.gatherOptions());
367 }
368 }
369 });
370
371 this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
372 this.targetCommand = calledCommands.peekLast();
373 this.options = CollectionUtil.unmodifiableList(options);
374 this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
375 }
376
377
378
379
380
381
382 @NonNull
383 public CLIProcessor getCLIProcessor() {
384 return CLIProcessor.this;
385 }
386
387
388
389
390
391
392 @Nullable
393 public ICommand getTargetCommand() {
394 return targetCommand;
395 }
396
397
398
399
400
401
402 @NonNull
403 private List<Option> getOptionsList() {
404 return options;
405 }
406
407 @NonNull
408 private List<ICommand> getCalledCommands() {
409 return calledCommands;
410 }
411
412
413
414
415
416
417 @NonNull
418 private List<String> getExtraArgs() {
419 return extraArgs;
420 }
421
422
423
424
425
426
427 private Options toOptions() {
428 Options retval = new Options();
429 for (Option option : getOptionsList()) {
430 retval.addOption(option);
431 }
432 return retval;
433 }
434
435
436
437
438
439
440 @SuppressWarnings({
441 "PMD.OnlyOneReturn",
442 "PMD.NPathComplexity",
443 "PMD.CyclomaticComplexity"
444 })
445 @NonNull
446 public ExitStatus processCommand() {
447
448
449
450
451
452 CommandLineParser parser = new DefaultParser();
453
454
455
456
457
458
459
460 CommandLine cmdLine;
461 try {
462 Options phase1Options = new Options();
463 phase1Options.addOption(HELP_OPTION);
464 phase1Options.addOption(VERSION_OPTION);
465
466 cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true));
467 } catch (ParseException ex) {
468 String msg = ex.getMessage();
469 assert msg != null;
470 return handleInvalidCommand(msg);
471 }
472
473 if (cmdLine.hasOption(VERSION_OPTION)) {
474 showVersion();
475 return ExitCode.OK.exit();
476 }
477 if (cmdLine.hasOption(HELP_OPTION)) {
478 showHelp();
479 return ExitCode.OK.exit();
480 }
481
482
483 try {
484 cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0])));
485 } catch (ParseException ex) {
486 String msg = ex.getMessage();
487 assert msg != null;
488 return handleInvalidCommand(msg);
489 }
490
491 ICommand targetCommand = getTargetCommand();
492 if (targetCommand != null) {
493 try {
494 targetCommand.validateExtraArguments(this, cmdLine);
495 } catch (InvalidArgumentException ex) {
496 return handleError(
497 ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
498 cmdLine,
499 true);
500 }
501 }
502
503 for (ICommand cmd : getCalledCommands()) {
504 try {
505 cmd.validateOptions(this, cmdLine);
506 } catch (InvalidArgumentException ex) {
507 String msg = ex.getMessage();
508 assert msg != null;
509 return handleInvalidCommand(msg);
510 }
511 }
512
513
514 if (cmdLine.hasOption(NO_COLOR_OPTION)) {
515 handleNoColor();
516 }
517
518 if (cmdLine.hasOption(QUIET_OPTION)) {
519 handleQuiet();
520 }
521 return invokeCommand(cmdLine);
522 }
523
524
525
526
527
528
529
530
531 @SuppressWarnings({
532 "PMD.OnlyOneReturn",
533 "PMD.AvoidCatchingGenericException"
534 })
535 @NonNull
536 private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
537 ExitStatus retval;
538 try {
539 ICommand targetCommand = getTargetCommand();
540 if (targetCommand == null) {
541 retval = ExitCode.INVALID_COMMAND.exit();
542 } else {
543 ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
544 try {
545 executor.execute();
546 retval = ExitCode.OK.exit();
547 } catch (CommandExecutionException ex) {
548 retval = ex.toExitStatus();
549 } catch (RuntimeException ex) {
550 retval = ExitCode.RUNTIME_ERROR
551 .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage())
552 .withThrowable(ex);
553 }
554 }
555 } catch (RuntimeException ex) {
556 retval = ExitCode.RUNTIME_ERROR
557 .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
558 .withThrowable(ex);
559 }
560
561 if (!ExitCode.OK.equals(retval.getExitCode())) {
562 retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
563
564 if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
565 showHelp();
566 }
567 }
568 return retval;
569 }
570
571
572
573
574
575
576
577
578
579
580
581
582 @NonNull
583 public ExitStatus handleError(
584 @NonNull ExitStatus exitStatus,
585 @NonNull CommandLine cmdLine,
586 boolean showHelp) {
587 exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
588 if (showHelp) {
589 showHelp();
590 }
591 return exitStatus;
592 }
593
594
595
596
597
598
599
600
601
602 @NonNull
603 public ExitStatus handleInvalidCommand(
604 @NonNull String message) {
605 showHelp();
606
607 ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
608 retval.generateMessage(false);
609 return retval;
610 }
611
612
613
614
615
616
617 @Nullable
618 private String buildHelpHeader() {
619
620 return null;
621 }
622
623
624
625
626
627
628
629
630
631 @NonNull
632 private String buildHelpFooter() {
633
634 ICommand targetCommand = getTargetCommand();
635 Collection<ICommand> subCommands;
636 if (targetCommand == null) {
637 subCommands = getTopLevelCommands();
638 } else {
639 subCommands = targetCommand.getSubCommands();
640 }
641
642 String retval;
643 if (subCommands.isEmpty()) {
644 retval = "";
645 } else {
646 StringBuilder builder = new StringBuilder(128);
647 builder
648 .append(System.lineSeparator())
649 .append("The following are available commands:")
650 .append(System.lineSeparator());
651
652 int length = subCommands.stream()
653 .mapToInt(command -> command.getName().length())
654 .max().orElse(0);
655
656 for (ICommand command : subCommands) {
657 builder.append(
658 ansi()
659 .render(String.format(" @|bold %-" + length + "s|@ %s%n",
660 command.getName(),
661 command.getDescription())));
662 }
663 builder
664 .append(System.lineSeparator())
665 .append('\'')
666 .append(getExec())
667 .append(" <command> --help' will show help on that specific command.")
668 .append(System.lineSeparator());
669 retval = builder.toString();
670 assert retval != null;
671 }
672 return retval;
673 }
674
675
676
677
678
679
680 private String buildHelpCliSyntax() {
681
682 StringBuilder builder = new StringBuilder(64);
683 builder.append(getExec());
684
685 List<ICommand> calledCommands = getCalledCommands();
686 if (!calledCommands.isEmpty()) {
687 builder.append(calledCommands.stream()
688 .map(ICommand::getName)
689 .collect(Collectors.joining(" ", " ", "")));
690 }
691
692
693 ICommand targetCommand = getTargetCommand();
694 if (targetCommand == null) {
695 builder.append(" <command>");
696 } else {
697 builder.append(getSubCommands(targetCommand));
698 }
699
700
701 getOptionsList().stream()
702 .filter(Option::isRequired)
703 .forEach(option -> {
704 builder
705 .append(' ')
706 .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
707 if (option.hasArg()) {
708 builder
709 .append('=')
710 .append(option.getArgName());
711 }
712 });
713
714
715 builder.append(" [<options>]");
716
717
718 if (targetCommand != null) {
719
720 builder.append(getExtraArguments(targetCommand));
721 }
722
723 String retval = builder.toString();
724 assert retval != null;
725 return retval;
726 }
727
728 @NonNull
729 private CharSequence getSubCommands(ICommand targetCommand) {
730 Collection<ICommand> subCommands = targetCommand.getSubCommands();
731
732 StringBuilder builder = new StringBuilder();
733 if (!subCommands.isEmpty()) {
734 builder.append(' ');
735 if (!targetCommand.isSubCommandRequired()) {
736 builder.append('[');
737 }
738
739 builder.append("<command>");
740
741 if (!targetCommand.isSubCommandRequired()) {
742 builder.append(']');
743 }
744 }
745 return builder;
746 }
747
748 @NonNull
749 private CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
750 StringBuilder builder = new StringBuilder();
751 for (ExtraArgument argument : targetCommand.getExtraArguments()) {
752 builder.append(' ');
753 if (!argument.isRequired()) {
754 builder.append('[');
755 }
756
757 builder.append('<')
758 .append(argument.getName())
759 .append('>');
760
761 if (argument.getNumber() > 1) {
762 builder.append("...");
763 }
764
765 if (!argument.isRequired()) {
766 builder.append(']');
767 }
768 }
769 return builder;
770 }
771
772
773
774
775 public void showHelp() {
776
777 HelpFormatter formatter = new HelpFormatter();
778 formatter.setLongOptSeparator("=");
779
780 PrintStream out = outputStream;
781 int terminalWidth = (out instanceof AnsiPrintStream)
782 ? ((AnsiPrintStream) out).getTerminalWidth()
783 : 80;
784
785 try (PrintWriter writer = new PrintWriter(
786 AutoCloser.preventClose(out),
787 true,
788 StandardCharsets.UTF_8)) {
789 formatter.printHelp(
790 writer,
791 Math.max(terminalWidth, 50),
792 buildHelpCliSyntax(),
793 buildHelpHeader(),
794 toOptions(),
795 HelpFormatter.DEFAULT_LEFT_PAD,
796 HelpFormatter.DEFAULT_DESC_PAD,
797 buildHelpFooter(),
798 false);
799 writer.flush();
800 }
801 }
802 }
803 }