1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.processor.command;
7   
8   import org.apache.commons.cli.CommandLine;
9   import org.apache.commons.cli.Option;
10  
11  import java.io.File;
12  import java.io.IOException;
13  import java.io.OutputStreamWriter;
14  import java.io.PrintWriter;
15  import java.io.Writer;
16  import java.nio.charset.StandardCharsets;
17  import java.nio.file.Files;
18  import java.nio.file.Path;
19  import java.util.Collection;
20  import java.util.List;
21  import java.util.Locale;
22  
23  import dev.metaschema.cli.processor.CLIProcessor;
24  import dev.metaschema.cli.processor.CallingContext;
25  import dev.metaschema.cli.processor.ExitCode;
26  import dev.metaschema.cli.processor.completion.CompletionScriptGenerator;
27  import dev.metaschema.core.util.ObjectUtils;
28  import edu.umd.cs.findbugs.annotations.NonNull;
29  
30  /**
31   * A command that generates shell completion scripts for Bash and Zsh.
32   * <p>
33   * This command introspects all registered commands and generates a completion
34   * script that provides intelligent tab-completion for the CLI tool.
35   */
36  public class ShellCompletionCommand
37      extends AbstractTerminalCommand {
38  
39    @NonNull
40    private static final String COMMAND = "shell-completion";
41  
42    @NonNull
43    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
44        ExtraArgument.newInstance("shell", true)));
45  
46    @NonNull
47    private static final Option TO_OPTION = ObjectUtils.notNull(
48        Option.builder()
49            .longOpt("to")
50            .hasArg()
51            .argName("FILE")
52            .type(File.class)
53            .desc("write completion script to this file instead of stdout")
54            .get());
55  
56    /**
57     * Supported shell types.
58     */
59    public enum Shell {
60      /** Bash shell. */
61      BASH("bash"),
62      /** Zsh shell. */
63      ZSH("zsh");
64  
65      @NonNull
66      private final String name;
67  
68      Shell(@NonNull String name) {
69        this.name = name;
70      }
71  
72      /**
73       * Get the shell name.
74       *
75       * @return the name
76       */
77      @NonNull
78      public String getName() {
79        return name;
80      }
81  
82      /**
83       * Parse a shell type from a string.
84       *
85       * @param value
86       *          the string value to parse
87       * @return the shell type
88       * @throws IllegalArgumentException
89       *           if the value is not a recognized shell type
90       */
91      @NonNull
92      public static Shell fromString(@NonNull String value) {
93        String normalized = value.toLowerCase(Locale.ROOT);
94        for (Shell shell : values()) {
95          if (shell.name.equals(normalized)) {
96            return shell;
97          }
98        }
99        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 }