001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.cli.processor.command;
007
008import org.apache.commons.cli.CommandLine;
009import org.apache.commons.cli.Option;
010
011import java.io.File;
012import java.io.IOException;
013import java.io.OutputStreamWriter;
014import java.io.PrintWriter;
015import java.io.Writer;
016import java.nio.charset.StandardCharsets;
017import java.nio.file.Files;
018import java.nio.file.Path;
019import java.util.Collection;
020import java.util.List;
021import java.util.Locale;
022
023import dev.metaschema.cli.processor.CLIProcessor;
024import dev.metaschema.cli.processor.CallingContext;
025import dev.metaschema.cli.processor.ExitCode;
026import dev.metaschema.cli.processor.completion.CompletionScriptGenerator;
027import dev.metaschema.core.util.ObjectUtils;
028import edu.umd.cs.findbugs.annotations.NonNull;
029
030/**
031 * A command that generates shell completion scripts for Bash and Zsh.
032 * <p>
033 * This command introspects all registered commands and generates a completion
034 * script that provides intelligent tab-completion for the CLI tool.
035 */
036public class ShellCompletionCommand
037    extends AbstractTerminalCommand {
038
039  @NonNull
040  private static final String COMMAND = "shell-completion";
041
042  @NonNull
043  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
044      ExtraArgument.newInstance("shell", true)));
045
046  @NonNull
047  private static final Option TO_OPTION = ObjectUtils.notNull(
048      Option.builder()
049          .longOpt("to")
050          .hasArg()
051          .argName("FILE")
052          .type(File.class)
053          .desc("write completion script to this file instead of stdout")
054          .get());
055
056  /**
057   * Supported shell types.
058   */
059  public enum Shell {
060    /** Bash shell. */
061    BASH("bash"),
062    /** Zsh shell. */
063    ZSH("zsh");
064
065    @NonNull
066    private final String name;
067
068    Shell(@NonNull String name) {
069      this.name = name;
070    }
071
072    /**
073     * Get the shell name.
074     *
075     * @return the name
076     */
077    @NonNull
078    public String getName() {
079      return name;
080    }
081
082    /**
083     * Parse a shell type from a string.
084     *
085     * @param value
086     *          the string value to parse
087     * @return the shell type
088     * @throws IllegalArgumentException
089     *           if the value is not a recognized shell type
090     */
091    @NonNull
092    public static Shell fromString(@NonNull String value) {
093      String normalized = value.toLowerCase(Locale.ROOT);
094      for (Shell shell : values()) {
095        if (shell.name.equals(normalized)) {
096          return shell;
097        }
098      }
099      throw new IllegalArgumentException(
100          "Unknown shell: " + value + ". Supported shells: bash, zsh");
101    }
102  }
103
104  @Override
105  public String getName() {
106    return COMMAND;
107  }
108
109  @Override
110  public String getDescription() {
111    return "Generate shell completion script for bash or zsh";
112  }
113
114  @SuppressWarnings("null")
115  @Override
116  public Collection<? extends Option> gatherOptions() {
117    return List.of(TO_OPTION);
118  }
119
120  @Override
121  public List<ExtraArgument> getExtraArguments() {
122    return EXTRA_ARGUMENTS;
123  }
124
125  @Override
126  public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) {
127    return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand);
128  }
129
130  /**
131   * Execute the shell completion generation.
132   *
133   * @param callingContext
134   *          the calling context
135   * @param cmdLine
136   *          the parsed command line
137   * @throws CommandExecutionException
138   *           if an error occurs during execution
139   */
140  protected void executeCommand(
141      @NonNull CallingContext callingContext,
142      @NonNull CommandLine cmdLine) throws CommandExecutionException {
143
144    List<String> extraArgs = cmdLine.getArgList();
145    if (extraArgs.isEmpty()) {
146      throw new CommandExecutionException(
147          ExitCode.INVALID_ARGUMENTS,
148          "Shell type is required. Supported shells: bash, zsh");
149    }
150
151    Shell shell;
152    try {
153      shell = Shell.fromString(ObjectUtils.notNull(extraArgs.get(0)));
154    } catch (IllegalArgumentException ex) {
155      throw new CommandExecutionException(ExitCode.INVALID_ARGUMENTS, ex.getMessage());
156    }
157
158    CLIProcessor processor = callingContext.getCLIProcessor();
159    CompletionScriptGenerator generator = new CompletionScriptGenerator(
160        processor.getExec(),
161        processor.getTopLevelCommands());
162
163    String script = shell == Shell.BASH
164        ? generator.generateBashCompletion()
165        : generator.generateZshCompletion();
166
167    String outputFile = cmdLine.getOptionValue(TO_OPTION);
168    if (outputFile != null) {
169      writeToFile(outputFile, script);
170    } else {
171      writeToStdout(script);
172    }
173  }
174
175  private static void writeToFile(@NonNull String outputFile, @NonNull String script)
176      throws CommandExecutionException {
177    Path path = resolveAgainstCWD(ObjectUtils.notNull(Path.of(outputFile)));
178    try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
179      writer.write(script);
180    } catch (IOException ex) {
181      throw new CommandExecutionException(
182          ExitCode.IO_ERROR,
183          "Failed to write completion script to: " + path,
184          ex);
185    }
186  }
187
188  private static void writeToStdout(@NonNull String script) {
189    PrintWriter writer = new PrintWriter(
190        new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
191    writer.print(script);
192    writer.flush();
193  }
194}