1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
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    * Gets the command used to execute for use in help text.
127    *
128    * @return the command name
129    */
130   @NonNull
131   public String getExec() {
132     return exec;
133   }
134 
135   /**
136    * Retrieve the version information for this application.
137    *
138    * @return the versionInfo
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    * Process a set of CLIProcessor arguments.
151    * <p>
152    * process().getExitCode().getStatusCode()
153    *
154    * @param args
155    *          the arguments to process
156    * @return the exit status
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     // the first two arguments should be the <command> and <operation>, where <type>
178     // is the object type
179     // the <operation> is performed against.
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); // NOPMD not closable here
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(); // NOPMD - not owner
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   // @SuppressWarnings("null")
239   // @NonNull
240   // public String[] getArgArray() {
241   // return Stream.concat(options.stream(), extraArgs.stream()).toArray(size ->
242   // new String[size]);
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") // readability
323     @NonNull
324     public ExitStatus processCommand() {
325       CommandLineParser parser = new DefaultParser();
326 
327       // this uses a three phase approach where:
328       // phase 1: checks if help or version are used
329       // phase 2: parse and validate arguments
330       // phase 3: executes the command
331 
332       // phase 1
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       // phase 2
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       // phase 3
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", // readability
423         "PMD.AvoidCatchingGenericException" // needed here
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      * Callback for providing a help header.
485      *
486      * @return the header or {@code null}
487      */
488     @Nullable
489     protected String buildHelpHeader() {
490       // TODO: build a suitable header
491       return null;
492     }
493 
494     /**
495      * Callback for providing a help footer.
496      *
497      * @param exec
498      *          the executable name
499      *
500      * @return the footer or {@code null}
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      * Get the CLI syntax.
548      *
549      * @return the CLI syntax to display in help output
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       // output calling commands
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       // output required options
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       // output non-required option placeholder
599       builder.append(" [<options>]");
600 
601       // output extra arguments
602       if (targetCommand != null) {
603         // handle extra arguments
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      * Output the help text to the console.
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( // NOPMD not owned
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 }