CLIProcessor.java

/*
 * SPDX-FileCopyrightText: none
 * SPDX-License-Identifier: CC0-1.0
 */

package gov.nist.secauto.metaschema.cli.processor;

import static org.fusesource.jansi.Ansi.ansi;

import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
import gov.nist.secauto.metaschema.core.util.AutoCloser;
import gov.nist.secauto.metaschema.core.util.CollectionUtil;
import gov.nist.secauto.metaschema.core.util.IVersionInfo;
import gov.nist.secauto.metaschema.core.util.ObjectUtils;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.LoggerConfig;
import org.fusesource.jansi.AnsiConsole;
import org.fusesource.jansi.AnsiPrintStream;

import java.io.PrintStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

public class CLIProcessor {
  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);

  @SuppressWarnings("null")
  @NonNull
  public static final Option HELP_OPTION = Option.builder("h")
      .longOpt("help")
      .desc("display this help message")
      .build();
  @SuppressWarnings("null")
  @NonNull
  public static final Option NO_COLOR_OPTION = Option.builder()
      .longOpt("no-color")
      .desc("do not colorize output")
      .build();
  @SuppressWarnings("null")
  @NonNull
  public static final Option QUIET_OPTION = Option.builder("q")
      .longOpt("quiet")
      .desc("minimize output to include only errors")
      .build();
  @SuppressWarnings("null")
  @NonNull
  public static final Option SHOW_STACK_TRACE_OPTION = Option.builder()
      .longOpt("show-stack-trace")
      .desc("display the stack trace associated with an error")
      .build();
  @SuppressWarnings("null")
  @NonNull
  public static final Option VERSION_OPTION = Option.builder()
      .longOpt("version")
      .desc("display the application version")
      .build();
  @SuppressWarnings("null")
  @NonNull
  public static final List<Option> OPTIONS = List.of(
      HELP_OPTION,
      NO_COLOR_OPTION,
      QUIET_OPTION,
      SHOW_STACK_TRACE_OPTION,
      VERSION_OPTION);

  public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";

  @NonNull
  private final List<ICommand> commands = new LinkedList<>();
  @NonNull
  private final String exec;
  @NonNull
  private final Map<String, IVersionInfo> versionInfos;

  public static void main(String... args) {
    System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
    CLIProcessor processor = new CLIProcessor("metaschema-cli");

    CommandService.getInstance().getCommands().stream().forEach(command -> {
      assert command != null;
      processor.addCommandHandler(command);
    });
    System.exit(processor.process(args).getExitCode().getStatusCode());
  }

  @SuppressWarnings("null")
  public CLIProcessor(@NonNull String exec) {
    this(exec, Map.of());
  }

  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
    this.exec = exec;
    this.versionInfos = versionInfos;
    AnsiConsole.systemInstall();
  }

  /**
   * Gets the command used to execute for use in help text.
   *
   * @return the command name
   */
  @NonNull
  public String getExec() {
    return exec;
  }

  /**
   * Retrieve the version information for this application.
   *
   * @return the versionInfo
   */
  @NonNull
  public Map<String, IVersionInfo> getVersionInfos() {
    return versionInfos;
  }

  public void addCommandHandler(@NonNull ICommand handler) {
    commands.add(handler);
  }

  /**
   * Process a set of CLIProcessor arguments.
   * <p>
   * process().getExitCode().getStatusCode()
   *
   * @param args
   *          the arguments to process
   * @return the exit status
   */
  @NonNull
  public ExitStatus process(String... args) {
    return parseCommand(args);
  }

  @NonNull
  private ExitStatus parseCommand(String... args) {
    List<String> commandArgs = Arrays.asList(args);
    assert commandArgs != null;
    CallingContext callingContext = new CallingContext(commandArgs);

    if (LOGGER.isDebugEnabled()) {
      String commandChain = callingContext.getCalledCommands().stream()
          .map(ICommand::getName)
          .collect(Collectors.joining(" -> "));
      LOGGER.debug("Processing command chain: {}", commandChain);
    }

    ExitStatus status;
    // the first two arguments should be the <command> and <operation>, where <type>
    // is the object type
    // the <operation> is performed against.
    if (commandArgs.isEmpty()) {
      status = ExitCode.INVALID_COMMAND.exit();
      callingContext.showHelp();
    } else {
      status = callingContext.processCommand();
    }
    return status;
  }

  @NonNull
  protected final List<ICommand> getTopLevelCommands() {
    return CollectionUtil.unmodifiableList(commands);
  }

  @NonNull
  protected final Map<String, ICommand> getTopLevelCommandsByName() {
    return ObjectUtils.notNull(getTopLevelCommands()
        .stream()
        .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
  }

  private static void handleNoColor() {
    System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
    AnsiConsole.systemUninstall();
  }

  public static void handleQuiet() {
    LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
    Configuration config = ctx.getConfiguration();
    LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
    Level oldLevel = loggerConfig.getLevel();
    if (oldLevel.isLessSpecificThan(Level.ERROR)) {
      loggerConfig.setLevel(Level.ERROR);
      ctx.updateLoggers();
    }
  }

  protected void showVersion() {
    @SuppressWarnings("resource")
    PrintStream out = AnsiConsole.out(); // NOPMD - not owner
    getVersionInfos().values().stream().forEach(info -> {
      out.println(ansi()
          .bold().a(info.getName()).boldOff()
          .a(" ")
          .bold().a(info.getVersion()).boldOff()
          .a(" built at ")
          .bold().a(info.getBuildTimestamp()).boldOff()
          .a(" from branch ")
          .bold().a(info.getGitBranch()).boldOff()
          .a(" (")
          .bold().a(info.getGitCommit()).boldOff()
          .a(") at ")
          .bold().a(info.getGitOriginUrl()).boldOff()
          .reset());
    });
    out.flush();
  }

  // @SuppressWarnings("null")
  // @NonNull
  // public String[] getArgArray() {
  // return Stream.concat(options.stream(), extraArgs.stream()).toArray(size ->
  // new String[size]);
  // }

  public class CallingContext {
    @NonNull
    private final List<Option> options;
    @NonNull
    private final List<ICommand> calledCommands;
    @Nullable
    private final ICommand targetCommand;
    @NonNull
    private final List<String> extraArgs;

    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
    public CallingContext(@NonNull List<String> args) {
      @SuppressWarnings("PMD.LooseCoupling")
      LinkedList<ICommand> calledCommands = new LinkedList<>();
      List<Option> options = new LinkedList<>(OPTIONS);
      List<String> extraArgs = new LinkedList<>();

      AtomicBoolean endArgs = new AtomicBoolean();
      args.forEach(arg -> {
        if (endArgs.get() || arg.startsWith("-")) {
          extraArgs.add(arg);
        } else if ("--".equals(arg)) {
          endArgs.set(true);
        } else {
          ICommand command = calledCommands.isEmpty()
              ? getTopLevelCommandsByName().get(arg)
              : calledCommands.getLast().getSubCommandByName(arg);

          if (command == null) {
            extraArgs.add(arg);
            endArgs.set(true);
          } else {
            calledCommands.add(command);
            options.addAll(command.gatherOptions());
          }
        }
      });

      this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
      this.targetCommand = calledCommands.peekLast();
      this.options = CollectionUtil.unmodifiableList(options);
      this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
    }

    @NonNull
    public CLIProcessor getCLIProcessor() {
      return CLIProcessor.this;
    }

    @Nullable
    public ICommand getTargetCommand() {
      return targetCommand;
    }

    @NonNull
    protected List<Option> getOptionsList() {
      return options;
    }

    @NonNull
    private List<ICommand> getCalledCommands() {
      return calledCommands;
    }

    @NonNull
    protected List<String> getExtraArgs() {
      return extraArgs;
    }

    protected Options toOptions() {
      Options retval = new Options();
      for (Option option : getOptionsList()) {
        retval.addOption(option);
      }
      return retval;
    }

    @SuppressWarnings("PMD.OnlyOneReturn") // readability
    @NonNull
    public ExitStatus processCommand() {
      CommandLineParser parser = new DefaultParser();

      // this uses a three phase approach where:
      // phase 1: checks if help or version are used
      // phase 2: parse and validate arguments
      // phase 3: executes the command

      // phase 1
      CommandLine cmdLine;
      try {
        Options phase1Options = new Options();
        phase1Options.addOption(HELP_OPTION);
        phase1Options.addOption(VERSION_OPTION);

        cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true));
      } catch (ParseException ex) {
        String msg = ex.getMessage();
        assert msg != null;
        return handleInvalidCommand(msg);
      }

      if (cmdLine.hasOption(VERSION_OPTION)) {
        showVersion();
        return ExitCode.OK.exit();
      }
      if (cmdLine.hasOption(HELP_OPTION)) {
        showHelp();
        return ExitCode.OK.exit();
      }

      // phase 2
      try {
        cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0])));
      } catch (ParseException ex) {
        String msg = ex.getMessage();
        assert msg != null;
        return handleInvalidCommand(msg);
      }

      ICommand targetCommand = getTargetCommand();
      if (targetCommand != null) {
        if (targetCommand.isSubCommandRequired()) {
          return handleError(
              ExitCode.INVALID_ARGUMENTS
                  .exitMessage("Please choose a valid sub-command."),
              cmdLine,
              true);
        }

        List<ExtraArgument> extraArguments = targetCommand.getExtraArguments();
        int maxArguments = extraArguments.size();

        List<String> actualArgs = cmdLine.getArgList();
        int actualArgsSize = actualArgs.size();
        if (actualArgs.size() > maxArguments) {
          return handleError(
              ExitCode.INVALID_ARGUMENTS
                  .exitMessage("The provided extra arguments exceed the number of allowed arguments."),
              cmdLine,
              true);
        }

        List<ExtraArgument> requiredExtraArguments = targetCommand.getExtraArguments().stream()
            .filter(ExtraArgument::isRequired)
            .collect(Collectors.toUnmodifiableList());

        if (actualArgsSize < requiredExtraArguments.size()) {
          return handleError(
              ExitCode.INVALID_ARGUMENTS
                  .exitMessage("Please provide the required extra arguments."),
              cmdLine,
              true);
        }
      }

      for (ICommand cmd : getCalledCommands()) {
        try {
          cmd.validateOptions(this, cmdLine);
        } catch (InvalidArgumentException ex) {
          String msg = ex.getMessage();
          assert msg != null;
          return handleInvalidCommand(msg);
        }
      }

      // phase 3
      if (cmdLine.hasOption(NO_COLOR_OPTION)) {
        handleNoColor();
      }

      if (cmdLine.hasOption(QUIET_OPTION)) {
        handleQuiet();
      }
      return invokeCommand(cmdLine);
    }

    @SuppressWarnings({
        "PMD.OnlyOneReturn", // readability
        "PMD.AvoidCatchingGenericException" // needed here
    })
    @NonNull
    protected ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
      ExitStatus retval;
      try {
        ICommand targetCommand = getTargetCommand();
        if (targetCommand == null) {
          retval = ExitCode.INVALID_COMMAND.exit();
        } else {
          ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
          try {
            executor.execute();
            retval = ExitCode.OK.exit();
          } catch (CommandExecutionException ex) {
            retval = ex.toExitStatus();
          } catch (RuntimeException ex) {
            retval = ExitCode.RUNTIME_ERROR
                .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage())
                .withThrowable(ex);
          }
        }
      } catch (RuntimeException ex) {
        retval = ExitCode.RUNTIME_ERROR
            .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
            .withThrowable(ex);
      }

      if (!ExitCode.OK.equals(retval.getExitCode())) {
        retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));

        if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
          showHelp();
        }
      }
      return retval;
    }

    @NonNull
    public ExitStatus handleError(
        @NonNull ExitStatus exitStatus,
        @NonNull CommandLine cmdLine,
        boolean showHelp) {
      exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
      if (showHelp) {
        showHelp();
      }
      return exitStatus;
    }

    @NonNull
    public ExitStatus handleInvalidCommand(
        @NonNull String message) {
      showHelp();

      ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
      retval.generateMessage(false);
      return retval;
    }

    /**
     * Callback for providing a help header.
     *
     * @return the header or {@code null}
     */
    @Nullable
    protected String buildHelpHeader() {
      // TODO: build a suitable header
      return null;
    }

    /**
     * Callback for providing a help footer.
     *
     * @param exec
     *          the executable name
     *
     * @return the footer or {@code null}
     */
    @NonNull
    private String buildHelpFooter() {

      ICommand targetCommand = getTargetCommand();
      Collection<ICommand> subCommands;
      if (targetCommand == null) {
        subCommands = getTopLevelCommands();
      } else {
        subCommands = targetCommand.getSubCommands();
      }

      String retval;
      if (subCommands.isEmpty()) {
        retval = "";
      } else {
        StringBuilder builder = new StringBuilder(128);
        builder
            .append(System.lineSeparator())
            .append("The following are available commands:")
            .append(System.lineSeparator());

        int length = subCommands.stream()
            .mapToInt(command -> command.getName().length())
            .max().orElse(0);

        for (ICommand command : subCommands) {
          builder.append(
              ansi()
                  .render(String.format("   @|bold %-" + length + "s|@ %s%n",
                      command.getName(),
                      command.getDescription())));
        }
        builder
            .append(System.lineSeparator())
            .append('\'')
            .append(getExec())
            .append(" <command> --help' will show help on that specific command.")
            .append(System.lineSeparator());
        retval = builder.toString();
        assert retval != null;
      }
      return retval;
    }

    /**
     * Get the CLI syntax.
     *
     * @return the CLI syntax to display in help output
     */
    protected String buildHelpCliSyntax() {

      StringBuilder builder = new StringBuilder(64);
      builder.append(getExec());

      List<ICommand> calledCommands = getCalledCommands();
      if (!calledCommands.isEmpty()) {
        builder.append(calledCommands.stream()
            .map(ICommand::getName)
            .collect(Collectors.joining(" ", " ", "")));
      }

      // output calling commands
      ICommand targetCommand = getTargetCommand();
      if (targetCommand == null) {
        builder.append(" <command>");
      } else {
        Collection<ICommand> subCommands = targetCommand.getSubCommands();

        if (!subCommands.isEmpty()) {
          builder.append(' ');
          if (!targetCommand.isSubCommandRequired()) {
            builder.append('[');
          }

          builder.append("<command>");

          if (!targetCommand.isSubCommandRequired()) {
            builder.append(']');
          }
        }
      }

      // output required options
      getOptionsList().stream()
          .filter(Option::isRequired)
          .forEach(option -> {
            builder
                .append(' ')
                .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
            if (option.hasArg()) {
              builder
                  .append('=')
                  .append(option.getArgName());
            }
          });

      // output non-required option placeholder
      builder.append(" [<options>]");

      // output extra arguments
      if (targetCommand != null) {
        // handle extra arguments
        for (ExtraArgument argument : targetCommand.getExtraArguments()) {
          builder.append(' ');
          if (!argument.isRequired()) {
            builder.append('[');
          }

          builder.append('<')
              .append(argument.getName())
              .append('>');

          if (argument.getNumber() > 1) {
            builder.append("...");
          }

          if (!argument.isRequired()) {
            builder.append(']');
          }
        }
      }

      String retval = builder.toString();
      assert retval != null;
      return retval;
    }

    /**
     * Output the help text to the console.
     */
    public void showHelp() {

      HelpFormatter formatter = new HelpFormatter();
      formatter.setLongOptSeparator("=");

      @SuppressWarnings("resource")
      AnsiPrintStream out = AnsiConsole.out();

      try (PrintWriter writer = new PrintWriter( // NOPMD not owned
          AutoCloser.preventClose(out),
          true,
          StandardCharsets.UTF_8)) {
        formatter.printHelp(
            writer,
            Math.max(out.getTerminalWidth(), 50),
            buildHelpCliSyntax(),
            buildHelpHeader(),
            toOptions(),
            HelpFormatter.DEFAULT_LEFT_PAD,
            HelpFormatter.DEFAULT_DESC_PAD,
            buildHelpFooter(),
            false);
        writer.flush();
      }
    }
  }
}