View Javadoc
1   /*
2    * Portions of this software was developed by employees of the National Institute
3    * of Standards and Technology (NIST), an agency of the Federal Government and is
4    * being made available as a public service. Pursuant to title 17 United States
5    * Code Section 105, works of NIST employees are not subject to copyright
6    * protection in the United States. This software may be subject to foreign
7    * copyright. Permission in the United States and in foreign countries, to the
8    * extent that NIST may hold copyright, to use, copy, modify, create derivative
9    * works, and distribute this software and its documentation without fee is hereby
10   * granted on a non-exclusive basis, provided that this notice and disclaimer
11   * of warranty appears in all copies.
12   *
13   * THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND, EITHER
14   * EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY
15   * THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF
16   * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND FREEDOM FROM
17   * INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION WILL CONFORM TO THE
18   * SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE ERROR FREE.  IN NO EVENT
19   * SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO, DIRECT,
20   * INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES, ARISING OUT OF, RESULTING FROM,
21   * OR IN ANY WAY CONNECTED WITH THIS SOFTWARE, WHETHER OR NOT BASED UPON WARRANTY,
22   * CONTRACT, TORT, OR OTHERWISE, WHETHER OR NOT INJURY WAS SUSTAINED BY PERSONS OR
23   * PROPERTY OR OTHERWISE, AND WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT
24   * OF THE RESULTS OF, OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER.
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    * Gets the command used to execute for use in help text.
143    *
144    * @return the command name
145    */
146   @NonNull
147   public String getExec() {
148     return exec;
149   }
150 
151   /**
152    * Retrieve the version information for this application.
153    *
154    * @return the versionInfo
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    * Process a set of CLIProcessor arguments.
167    * <p>
168    * process().getExitCode().getStatusCode()
169    *
170    * @param args
171    *          the arguments to process
172    * @return the exit status
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     // the first two arguments should be the <command> and <operation>, where <type>
187     // is the object type
188     // the <operation> is performed against.
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); // NOPMD not closable here
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(); // NOPMD - not owner
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   // @SuppressWarnings("null")
242   // @NonNull
243   // public String[] getArgArray() {
244   // return Stream.concat(options.stream(), extraArgs.stream()).toArray(size ->
245   // new String[size]);
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") // readability
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         // } else {
371         // retval = handleInvalidCommand(commandResult, options,
372         // "Invalid command arguments: " +
373         // cmdLine.getArgList().stream().collect(Collectors.joining(" ")));
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", // readability
386         "PMD.AvoidCatchingGenericException" // needed here
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      * Callback for providing a help header.
432      *
433      * @return the header or {@code null}
434      */
435     @Nullable
436     protected String buildHelpHeader() {
437       // TODO: build a suitable header
438       return null;
439     }
440 
441     /**
442      * Callback for providing a help footer.
443      *
444      * @param exec
445      *          the executable name
446      *
447      * @return the footer or {@code null}
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      * Get the CLI syntax.
495      *
496      * @return the CLI syntax to display in help output
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       // output calling commands
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       // output required options
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       // output non-required option placeholder
546       builder.append(" [<options>]");
547 
548       // output extra arguments
549       if (targetCommand != null) {
550         // handle extra arguments
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( // NOPMD not owned
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 }