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.CommandService;
011import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
012import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
013import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
014import gov.nist.secauto.metaschema.core.util.IVersionInfo;
015import gov.nist.secauto.metaschema.core.util.ObjectUtils;
016
017import org.apache.commons.cli.CommandLine;
018import org.apache.commons.cli.CommandLineParser;
019import org.apache.commons.cli.DefaultParser;
020import org.apache.commons.cli.HelpFormatter;
021import org.apache.commons.cli.Option;
022import org.apache.commons.cli.Options;
023import org.apache.commons.cli.ParseException;
024import org.apache.logging.log4j.Level;
025import org.apache.logging.log4j.LogManager;
026import org.apache.logging.log4j.Logger;
027import org.apache.logging.log4j.core.LoggerContext;
028import org.apache.logging.log4j.core.config.Configuration;
029import org.apache.logging.log4j.core.config.LoggerConfig;
030import org.fusesource.jansi.AnsiConsole;
031import org.fusesource.jansi.AnsiPrintStream;
032
033import java.io.PrintStream;
034import java.io.PrintWriter;
035import java.nio.charset.StandardCharsets;
036import java.util.Arrays;
037import java.util.Collection;
038import java.util.Collections;
039import java.util.Deque;
040import java.util.LinkedList;
041import java.util.List;
042import java.util.Map;
043import java.util.function.Function;
044import java.util.stream.Collectors;
045
046import edu.umd.cs.findbugs.annotations.NonNull;
047import edu.umd.cs.findbugs.annotations.Nullable;
048import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
049
050public class CLIProcessor {
051  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
052
053  @SuppressWarnings("null")
054  @NonNull
055  public static final Option HELP_OPTION = Option.builder("h")
056      .longOpt("help")
057      .desc("display this help message")
058      .build();
059  @SuppressWarnings("null")
060  @NonNull
061  public static final Option NO_COLOR_OPTION = Option.builder()
062      .longOpt("no-color")
063      .desc("do not colorize output")
064      .build();
065  @SuppressWarnings("null")
066  @NonNull
067  public static final Option QUIET_OPTION = Option.builder("q")
068      .longOpt("quiet")
069      .desc("minimize output to include only errors")
070      .build();
071  @SuppressWarnings("null")
072  @NonNull
073  public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
074      .longOpt("show-stack-trace")
075      .desc("display the stack trace associated with an error")
076      .build();
077  @SuppressWarnings("null")
078  @NonNull
079  public static final Option VERSION_OPTION = Option.builder()
080      .longOpt("version")
081      .desc("display the application version")
082      .build();
083  @SuppressWarnings("null")
084  @NonNull
085  public static final List<Option> OPTIONS = List.of(
086      HELP_OPTION,
087      NO_COLOR_OPTION,
088      QUIET_OPTION,
089      SHOW_STACK_TRACE_OPTION,
090      VERSION_OPTION);
091
092  public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
093
094  @NonNull
095  private final List<ICommand> commands = new LinkedList<>();
096  @NonNull
097  private final String exec;
098  @NonNull
099  private final Map<String, IVersionInfo> versionInfos;
100
101  public static void main(String... args) {
102    System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
103    CLIProcessor processor = new CLIProcessor("metaschema-cli");
104
105    CommandService.getInstance().getCommands().stream().forEach(command -> {
106      assert command != null;
107      processor.addCommandHandler(command);
108    });
109    System.exit(processor.process(args).getExitCode().getStatusCode());
110  }
111
112  @SuppressWarnings("null")
113  public CLIProcessor(@NonNull String exec) {
114    this(exec, Map.of());
115  }
116
117  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
118    this.exec = exec;
119    this.versionInfos = versionInfos;
120    AnsiConsole.systemInstall();
121  }
122
123  /**
124   * Gets the command used to execute for use in help text.
125   *
126   * @return the command name
127   */
128  @NonNull
129  public String getExec() {
130    return exec;
131  }
132
133  /**
134   * Retrieve the version information for this application.
135   *
136   * @return the versionInfo
137   */
138  @NonNull
139  public Map<String, IVersionInfo> getVersionInfos() {
140    return versionInfos;
141  }
142
143  public void addCommandHandler(@NonNull ICommand handler) {
144    commands.add(handler);
145  }
146
147  /**
148   * Process a set of CLIProcessor arguments.
149   * <p>
150   * process().getExitCode().getStatusCode()
151   *
152   * @param args
153   *          the arguments to process
154   * @return the exit status
155   */
156  @NonNull
157  public ExitStatus process(String... args) {
158    return parseCommand(args);
159  }
160
161  @NonNull
162  private ExitStatus parseCommand(String... args) {
163    List<String> commandArgs = Arrays.asList(args);
164    assert commandArgs != null;
165    CallingContext callingContext = new CallingContext(commandArgs);
166
167    ExitStatus status;
168    // the first two arguments should be the <command> and <operation>, where <type>
169    // is the object type
170    // the <operation> is performed against.
171    if (commandArgs.isEmpty()) {
172      status = ExitCode.INVALID_COMMAND.exit();
173      callingContext.showHelp();
174    } else {
175      status = callingContext.processCommand();
176    }
177    return status;
178  }
179
180  protected final List<ICommand> getTopLevelCommands() {
181    List<ICommand> retval = Collections.unmodifiableList(commands);
182    assert retval != null;
183    return retval;
184  }
185
186  private static void handleNoColor() {
187    System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
188    AnsiConsole.systemUninstall();
189  }
190
191  public static void handleQuiet() {
192    LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
193    Configuration config = ctx.getConfiguration();
194    LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
195    Level oldLevel = loggerConfig.getLevel();
196    if (oldLevel.isLessSpecificThan(Level.ERROR)) {
197      loggerConfig.setLevel(Level.ERROR);
198      ctx.updateLoggers();
199    }
200  }
201
202  protected void showVersion() {
203    @SuppressWarnings("resource") PrintStream out = AnsiConsole.out(); // NOPMD - not owner
204    getVersionInfos().values().stream().forEach(info -> {
205      out.println(ansi()
206          .bold().a(info.getName()).boldOff()
207          .a(" ")
208          .bold().a(info.getVersion()).boldOff()
209          .a(" built at ")
210          .bold().a(info.getBuildTimestamp()).boldOff()
211          .a(" from branch ")
212          .bold().a(info.getGitBranch()).boldOff()
213          .a(" (")
214          .bold().a(info.getGitCommit()).boldOff()
215          .a(") at ")
216          .bold().a(info.getGitOriginUrl()).boldOff()
217          .reset());
218    });
219    out.flush();
220  }
221
222  // @SuppressWarnings("null")
223  // @NonNull
224  // public String[] getArgArray() {
225  // return Stream.concat(options.stream(), extraArgs.stream()).toArray(size ->
226  // new String[size]);
227  // }
228
229  public class CallingContext {
230    @NonNull
231    private final List<Option> options;
232    @NonNull
233    private final Deque<ICommand> calledCommands;
234    @NonNull
235    private final List<String> extraArgs;
236
237    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
238    public CallingContext(@NonNull List<String> args) {
239      Map<String, ICommand> topLevelCommandMap = getTopLevelCommands().stream()
240          .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity()));
241
242      List<Option> options = new LinkedList<>(OPTIONS);
243      Deque<ICommand> calledCommands = new LinkedList<>();
244      List<String> extraArgs = new LinkedList<>();
245
246      boolean endArgs = false;
247      for (String arg : args) {
248        if (endArgs || arg.startsWith("-")) {
249          extraArgs.add(arg);
250        } else if ("--".equals(arg)) {
251          endArgs = true;
252        } else {
253          ICommand command;
254          if (calledCommands.isEmpty()) {
255            command = topLevelCommandMap.get(arg);
256          } else {
257            command = calledCommands.getLast();
258            command = command.getSubCommandByName(arg);
259          }
260
261          if (command == null) {
262            extraArgs.add(arg);
263            endArgs = true;
264          } else {
265            calledCommands.add(command);
266          }
267        }
268      }
269
270      if (LOGGER.isDebugEnabled()) {
271        String commandChain = calledCommands.stream()
272            .map(ICommand::getName)
273            .collect(Collectors.joining(" -> "));
274        LOGGER.debug("Processing command chain: {}", commandChain);
275      }
276
277      for (ICommand cmd : calledCommands) {
278        options.addAll(cmd.gatherOptions());
279      }
280
281      options = Collections.unmodifiableList(options);
282      extraArgs = Collections.unmodifiableList(extraArgs);
283
284      assert options != null;
285      assert extraArgs != null;
286
287      this.options = options;
288      this.calledCommands = calledCommands;
289      this.extraArgs = extraArgs;
290    }
291
292    @NonNull
293    public CLIProcessor getCLIProcessor() {
294      return CLIProcessor.this;
295    }
296
297    @Nullable
298    public ICommand getTargetCommand() {
299      return calledCommands.peekLast();
300    }
301
302    @NonNull
303    protected List<Option> getOptionsList() {
304      return options;
305    }
306
307    @NonNull
308    private Deque<ICommand> getCalledCommands() {
309      return calledCommands;
310    }
311
312    @NonNull
313    protected List<String> getExtraArgs() {
314      return extraArgs;
315    }
316
317    protected Options toOptions() {
318      Options retval = new Options();
319      for (Option option : getOptionsList()) {
320        retval.addOption(option);
321      }
322      return retval;
323    }
324
325    @SuppressWarnings("PMD.OnlyOneReturn") // readability
326    @NonNull
327    public ExitStatus processCommand() {
328      CommandLineParser parser = new DefaultParser();
329      CommandLine cmdLine;
330
331      // this uses a two phase approach where:
332      // phase 1: checks if help or version are used
333      // phase 2: executes the command
334
335      // phase 1
336      ExitStatus retval = null;
337      {
338        try {
339          Options phase1Options = new Options();
340          phase1Options.addOption(HELP_OPTION);
341          phase1Options.addOption(VERSION_OPTION);
342
343          cmdLine = parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true);
344        } catch (ParseException ex) {
345          String msg = ex.getMessage();
346          assert msg != null;
347          return handleInvalidCommand(msg);
348        }
349
350        if (cmdLine.hasOption(VERSION_OPTION)) {
351          showVersion();
352          retval = ExitCode.OK.exit();
353        } else if (cmdLine.hasOption(HELP_OPTION)) {
354          showHelp();
355          retval = ExitCode.OK.exit();
356        }
357      }
358
359      if (retval == null) {
360        // phase 2
361        try {
362          cmdLine = parser.parse(toOptions(), getExtraArgs().toArray(new String[0]));
363        } catch (ParseException ex) {
364          String msg = ex.getMessage();
365          assert msg != null;
366          return handleInvalidCommand(msg);
367        }
368
369        if (cmdLine.hasOption(NO_COLOR_OPTION)) {
370          handleNoColor();
371        }
372
373        if (cmdLine.hasOption(QUIET_OPTION)) {
374          handleQuiet();
375        }
376        retval = invokeCommand(cmdLine);
377      }
378
379      retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
380      return retval;
381    }
382
383    @SuppressWarnings({
384        "PMD.OnlyOneReturn", // readability
385        "PMD.AvoidCatchingGenericException" // needed here
386    })
387    protected ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
388      ExitStatus retval;
389      try {
390        for (ICommand cmd : getCalledCommands()) {
391          try {
392            cmd.validateOptions(this, cmdLine);
393          } catch (InvalidArgumentException ex) {
394            String msg = ex.getMessage();
395            assert msg != null;
396            return handleInvalidCommand(msg);
397          }
398        }
399
400        ICommand targetCommand = getTargetCommand();
401        if (targetCommand == null) {
402          retval = ExitCode.INVALID_COMMAND.exit();
403        } else {
404          ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
405          retval = executor.execute();
406        }
407
408        if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
409          showHelp();
410        }
411      } catch (RuntimeException ex) {
412        retval = ExitCode.RUNTIME_ERROR
413            .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
414            .withThrowable(ex);
415      }
416      return retval;
417    }
418
419    @NonNull
420    public ExitStatus handleInvalidCommand(
421        @NonNull String message) {
422      showHelp();
423
424      ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
425      retval.generateMessage(false);
426      return retval;
427    }
428
429    /**
430     * Callback for providing a help header.
431     *
432     * @return the header or {@code null}
433     */
434    @Nullable
435    protected String buildHelpHeader() {
436      // TODO: build a suitable header
437      return null;
438    }
439
440    /**
441     * Callback for providing a help footer.
442     *
443     * @param exec
444     *          the executable name
445     *
446     * @return the footer or {@code null}
447     */
448    @NonNull
449    private String buildHelpFooter() {
450
451      ICommand targetCommand = getTargetCommand();
452      Collection<ICommand> subCommands;
453      if (targetCommand == null) {
454        subCommands = getTopLevelCommands();
455      } else {
456        subCommands = targetCommand.getSubCommands();
457      }
458
459      String retval;
460      if (subCommands.isEmpty()) {
461        retval = "";
462      } else {
463        StringBuilder builder = new StringBuilder(64);
464        builder
465            .append(System.lineSeparator())
466            .append("The following are available commands:")
467            .append(System.lineSeparator());
468
469        int length = subCommands.stream()
470            .mapToInt(command -> command.getName().length())
471            .max().orElse(0);
472
473        for (ICommand command : subCommands) {
474          builder.append(
475              ansi()
476                  .render(String.format("   @|bold %-" + length + "s|@ %s%n",
477                      command.getName(),
478                      command.getDescription())));
479        }
480        builder
481            .append(System.lineSeparator())
482            .append('\'')
483            .append(getExec())
484            .append(" <command> --help' will show help on that specific command.")
485            .append(System.lineSeparator());
486        retval = builder.toString();
487        assert retval != null;
488      }
489      return retval;
490    }
491
492    /**
493     * Get the CLI syntax.
494     *
495     * @return the CLI syntax to display in help output
496     */
497    protected String buildHelpCliSyntax() {
498
499      StringBuilder builder = new StringBuilder(64);
500      builder.append(getExec());
501
502      Deque<ICommand> calledCommands = getCalledCommands();
503      if (!calledCommands.isEmpty()) {
504        builder.append(calledCommands.stream()
505            .map(ICommand::getName)
506            .collect(Collectors.joining(" ", " ", "")));
507      }
508
509      // output calling commands
510      ICommand targetCommand = getTargetCommand();
511      if (targetCommand == null) {
512        builder.append(" <command>");
513      } else {
514        Collection<ICommand> subCommands = targetCommand.getSubCommands();
515
516        if (!subCommands.isEmpty()) {
517          builder.append(' ');
518          if (!targetCommand.isSubCommandRequired()) {
519            builder.append('[');
520          }
521
522          builder.append("<command>");
523
524          if (!targetCommand.isSubCommandRequired()) {
525            builder.append(']');
526          }
527        }
528      }
529
530      // output required options
531      getOptionsList().stream()
532          .filter(Option::isRequired)
533          .forEach(option -> {
534            builder
535                .append(' ')
536                .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
537            if (option.hasArg()) {
538              builder
539                  .append('=')
540                  .append(option.getArgName());
541            }
542          });
543
544      // output non-required option placeholder
545      builder.append(" [<options>]");
546
547      // output extra arguments
548      if (targetCommand != null) {
549        // handle extra arguments
550        for (ExtraArgument argument : targetCommand.getExtraArguments()) {
551          builder.append(' ');
552          if (!argument.isRequired()) {
553            builder.append('[');
554          }
555
556          builder.append('<');
557          builder.append(argument.getName());
558          builder.append('>');
559
560          if (argument.getNumber() > 1) {
561            builder.append("...");
562          }
563
564          if (!argument.isRequired()) {
565            builder.append(']');
566          }
567        }
568      }
569
570      String retval = builder.toString();
571      assert retval != null;
572      return retval;
573    }
574
575    public void showHelp() {
576
577      HelpFormatter formatter = new HelpFormatter();
578      formatter.setLongOptSeparator("=");
579
580      AnsiPrintStream out = AnsiConsole.out();
581      int terminalWidth = Math.max(out.getTerminalWidth(), 40);
582
583      @SuppressWarnings("resource") PrintWriter writer = new PrintWriter( // NOPMD not owned
584          out,
585          true,
586          StandardCharsets.UTF_8);
587      formatter.printHelp(
588          writer,
589          terminalWidth,
590          buildHelpCliSyntax(),
591          buildHelpHeader(),
592          toOptions(),
593          HelpFormatter.DEFAULT_LEFT_PAD,
594          HelpFormatter.DEFAULT_DESC_PAD,
595          buildHelpFooter(),
596          false);
597      writer.flush();
598    }
599  }
600
601}