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