001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.cli.processor;
007
008import static org.fusesource.jansi.Ansi.ansi;
009
010import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
011import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
012import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
013import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
014import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
015import gov.nist.secauto.metaschema.core.util.AutoCloser;
016import gov.nist.secauto.metaschema.core.util.CollectionUtil;
017import gov.nist.secauto.metaschema.core.util.IVersionInfo;
018import gov.nist.secauto.metaschema.core.util.ObjectUtils;
019
020import org.apache.commons.cli.CommandLine;
021import org.apache.commons.cli.CommandLineParser;
022import org.apache.commons.cli.DefaultParser;
023import org.apache.commons.cli.HelpFormatter;
024import org.apache.commons.cli.Option;
025import org.apache.commons.cli.Options;
026import org.apache.commons.cli.ParseException;
027import org.apache.logging.log4j.Level;
028import org.apache.logging.log4j.LogManager;
029import org.apache.logging.log4j.Logger;
030import org.apache.logging.log4j.core.LoggerContext;
031import org.apache.logging.log4j.core.config.Configuration;
032import org.apache.logging.log4j.core.config.LoggerConfig;
033import org.fusesource.jansi.AnsiConsole;
034import org.fusesource.jansi.AnsiPrintStream;
035
036import java.io.PrintStream;
037import java.io.PrintWriter;
038import java.nio.charset.StandardCharsets;
039import java.util.Arrays;
040import java.util.Collection;
041import java.util.LinkedList;
042import java.util.List;
043import java.util.Map;
044import java.util.concurrent.atomic.AtomicBoolean;
045import java.util.function.Function;
046import java.util.stream.Collectors;
047
048import edu.umd.cs.findbugs.annotations.NonNull;
049import edu.umd.cs.findbugs.annotations.Nullable;
050import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
051
052public class CLIProcessor {
053  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
054
055  @SuppressWarnings("null")
056  @NonNull
057  public static final Option HELP_OPTION = Option.builder("h")
058      .longOpt("help")
059      .desc("display this help message")
060      .build();
061  @SuppressWarnings("null")
062  @NonNull
063  public static final Option NO_COLOR_OPTION = Option.builder()
064      .longOpt("no-color")
065      .desc("do not colorize output")
066      .build();
067  @SuppressWarnings("null")
068  @NonNull
069  public static final Option QUIET_OPTION = Option.builder("q")
070      .longOpt("quiet")
071      .desc("minimize output to include only errors")
072      .build();
073  @SuppressWarnings("null")
074  @NonNull
075  public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
076      .longOpt("show-stack-trace")
077      .desc("display the stack trace associated with an error")
078      .build();
079  @SuppressWarnings("null")
080  @NonNull
081  public static final Option VERSION_OPTION = Option.builder()
082      .longOpt("version")
083      .desc("display the application version")
084      .build();
085  @SuppressWarnings("null")
086  @NonNull
087  public static final List<Option> OPTIONS = List.of(
088      HELP_OPTION,
089      NO_COLOR_OPTION,
090      QUIET_OPTION,
091      SHOW_STACK_TRACE_OPTION,
092      VERSION_OPTION);
093
094  public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
095
096  @NonNull
097  private final List<ICommand> commands = new LinkedList<>();
098  @NonNull
099  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}