1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27 package gov.nist.secauto.metaschema.cli.processor;
28
29 import static org.fusesource.jansi.Ansi.ansi;
30
31 import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
32 import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
33 import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
34 import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
35 import gov.nist.secauto.metaschema.core.util.IVersionInfo;
36 import gov.nist.secauto.metaschema.core.util.ObjectUtils;
37
38 import org.apache.commons.cli.CommandLine;
39 import org.apache.commons.cli.CommandLineParser;
40 import org.apache.commons.cli.DefaultParser;
41 import org.apache.commons.cli.HelpFormatter;
42 import org.apache.commons.cli.Option;
43 import org.apache.commons.cli.Options;
44 import org.apache.commons.cli.ParseException;
45 import org.apache.logging.log4j.Level;
46 import org.apache.logging.log4j.LogManager;
47 import org.apache.logging.log4j.Logger;
48 import org.apache.logging.log4j.core.LoggerContext;
49 import org.apache.logging.log4j.core.config.Configuration;
50 import org.apache.logging.log4j.core.config.LoggerConfig;
51 import org.fusesource.jansi.AnsiConsole;
52 import org.fusesource.jansi.AnsiPrintStream;
53
54 import java.io.PrintStream;
55 import java.io.PrintWriter;
56 import java.nio.charset.StandardCharsets;
57 import java.util.Arrays;
58 import java.util.Collection;
59 import java.util.Collections;
60 import java.util.Deque;
61 import java.util.LinkedList;
62 import java.util.List;
63 import java.util.Map;
64 import java.util.function.Function;
65 import java.util.stream.Collectors;
66
67 import edu.umd.cs.findbugs.annotations.NonNull;
68 import edu.umd.cs.findbugs.annotations.Nullable;
69
70 public class CLIProcessor {
71 private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
72
73 @SuppressWarnings("null")
74 @NonNull
75 public static final Option HELP_OPTION = Option.builder("h")
76 .longOpt("help")
77 .desc("display this help message")
78 .build();
79 @SuppressWarnings("null")
80 @NonNull
81 public static final Option NO_COLOR_OPTION = Option.builder()
82 .longOpt("no-color")
83 .desc("do not colorize output")
84 .build();
85 @SuppressWarnings("null")
86 @NonNull
87 public static final Option QUIET_OPTION = Option.builder("q")
88 .longOpt("quiet")
89 .desc("minimize output to include only errors")
90 .build();
91 @SuppressWarnings("null")
92 @NonNull
93 public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
94 .longOpt("show-stack-trace")
95 .desc("display the stack trace associated with an error")
96 .build();
97 @SuppressWarnings("null")
98 @NonNull
99 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
143
144
145
146 @NonNull
147 public String getExec() {
148 return exec;
149 }
150
151
152
153
154
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
167
168
169
170
171
172
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
187
188
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);
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();
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
242
243
244
245
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")
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
371
372
373
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",
386 "PMD.AvoidCatchingGenericException"
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
432
433
434
435 @Nullable
436 protected String buildHelpHeader() {
437
438 return null;
439 }
440
441
442
443
444
445
446
447
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
495
496
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
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
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
546 builder.append(" [<options>]");
547
548
549 if (targetCommand != null) {
550
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(
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 }