001/*
002 * Portions of this software was developed by employees of the National Institute
003 * of Standards and Technology (NIST), an agency of the Federal Government and is
004 * being made available as a public service. Pursuant to title 17 United States
005 * Code Section 105, works of NIST employees are not subject to copyright
006 * protection in the United States. This software may be subject to foreign
007 * copyright. Permission in the United States and in foreign countries, to the
008 * extent that NIST may hold copyright, to use, copy, modify, create derivative
009 * works, and distribute this software and its documentation without fee is hereby
010 * granted on a non-exclusive basis, provided that this notice and disclaimer
011 * of warranty appears in all copies.
012 *
013 * THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND, EITHER
014 * EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY
015 * THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF
016 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND FREEDOM FROM
017 * INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION WILL CONFORM TO THE
018 * SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE ERROR FREE.  IN NO EVENT
019 * SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO, DIRECT,
020 * INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES, ARISING OUT OF, RESULTING FROM,
021 * OR IN ANY WAY CONNECTED WITH THIS SOFTWARE, WHETHER OR NOT BASED UPON WARRANTY,
022 * CONTRACT, TORT, OR OTHERWISE, WHETHER OR NOT INJURY WAS SUSTAINED BY PERSONS OR
023 * PROPERTY OR OTHERWISE, AND WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT
024 * OF THE RESULTS OF, OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER.
025 */
026
027package gov.nist.secauto.metaschema.cli.processor;
028
029import static org.fusesource.jansi.Ansi.ansi;
030
031import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
032import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
033import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
034import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
035import gov.nist.secauto.metaschema.core.util.IVersionInfo;
036import gov.nist.secauto.metaschema.core.util.ObjectUtils;
037
038import org.apache.commons.cli.CommandLine;
039import org.apache.commons.cli.CommandLineParser;
040import org.apache.commons.cli.DefaultParser;
041import org.apache.commons.cli.HelpFormatter;
042import org.apache.commons.cli.Option;
043import org.apache.commons.cli.Options;
044import org.apache.commons.cli.ParseException;
045import org.apache.logging.log4j.Level;
046import org.apache.logging.log4j.LogManager;
047import org.apache.logging.log4j.Logger;
048import org.apache.logging.log4j.core.LoggerContext;
049import org.apache.logging.log4j.core.config.Configuration;
050import org.apache.logging.log4j.core.config.LoggerConfig;
051import org.fusesource.jansi.AnsiConsole;
052import org.fusesource.jansi.AnsiPrintStream;
053
054import java.io.PrintStream;
055import java.io.PrintWriter;
056import java.nio.charset.StandardCharsets;
057import java.util.Arrays;
058import java.util.Collection;
059import java.util.Collections;
060import java.util.Deque;
061import java.util.LinkedList;
062import java.util.List;
063import java.util.Map;
064import java.util.function.Function;
065import java.util.stream.Collectors;
066
067import edu.umd.cs.findbugs.annotations.NonNull;
068import edu.umd.cs.findbugs.annotations.Nullable;
069
070public class CLIProcessor {
071  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
072
073  @SuppressWarnings("null")
074  @NonNull
075  public static final Option HELP_OPTION = Option.builder("h")
076      .longOpt("help")
077      .desc("display this help message")
078      .build();
079  @SuppressWarnings("null")
080  @NonNull
081  public static final Option NO_COLOR_OPTION = Option.builder()
082      .longOpt("no-color")
083      .desc("do not colorize output")
084      .build();
085  @SuppressWarnings("null")
086  @NonNull
087  public static final Option QUIET_OPTION = Option.builder("q")
088      .longOpt("quiet")
089      .desc("minimize output to include only errors")
090      .build();
091  @SuppressWarnings("null")
092  @NonNull
093  public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
094      .longOpt("show-stack-trace")
095      .desc("display the stack trace associated with an error")
096      .build();
097  @SuppressWarnings("null")
098  @NonNull
099  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}