1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.processor.completion;
7   
8   import org.apache.commons.cli.Option;
9   
10  import java.util.Collection;
11  import java.util.List;
12  import java.util.stream.Collectors;
13  
14  import dev.metaschema.cli.processor.command.ExtraArgument;
15  import dev.metaschema.cli.processor.command.ICommand;
16  import dev.metaschema.core.util.ObjectUtils;
17  import edu.umd.cs.findbugs.annotations.NonNull;
18  
19  /**
20   * Generates shell completion scripts for Bash and Zsh.
21   * <p>
22   * This generator introspects registered commands and their options to produce
23   * completion scripts that provide intelligent tab-completion for command-line
24   * tools built on the cli-processor framework.
25   */
26  public class CompletionScriptGenerator {
27  
28    /**
29     * Supported shell types.
30     */
31    public enum Shell {
32      /** Bash shell. */
33      BASH,
34      /** Zsh shell. */
35      ZSH
36    }
37  
38    @NonNull
39    private final String programName;
40    @NonNull
41    private final List<ICommand> commands;
42  
43    /**
44     * Construct a new generator.
45     *
46     * @param programName
47     *          the name of the CLI program
48     * @param commands
49     *          the top-level commands to include in completion
50     */
51    public CompletionScriptGenerator(
52        @NonNull String programName,
53        @NonNull List<ICommand> commands) {
54      this.programName = programName;
55      this.commands = commands;
56    }
57  
58    /**
59     * Get the program name.
60     *
61     * @return the program name
62     */
63    @NonNull
64    public String getProgramName() {
65      return programName;
66    }
67  
68    /**
69     * Get the commands.
70     *
71     * @return the commands
72     */
73    @NonNull
74    public List<ICommand> getCommands() {
75      return commands;
76    }
77  
78    /**
79     * Generate a Bash completion script.
80     *
81     * @return the bash completion script
82     */
83    @NonNull
84    public String generateBashCompletion() {
85      StringBuilder sb = new StringBuilder();
86      String funcName = "_" + sanitizeFunctionName(programName);
87  
88      // Header
89      sb.append("# Bash completion for ").append(programName).append("\n");
90      sb.append("# Generated by metaschema cli-processor\n\n");
91  
92      // Fallback for systems without bash-completion
93      sb.append("# Provide fallback if _init_completion is not available\n");
94      sb.append("if ! type _init_completion &>/dev/null; then\n");
95      sb.append("    _init_completion() {\n");
96      sb.append("        COMPREPLY=()\n");
97      sb.append("        cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
98      sb.append("        prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n");
99      sb.append("        words=(\"${COMP_WORDS[@]}\")\n");
100     sb.append("        cword=$COMP_CWORD\n");
101     sb.append("    }\n");
102     sb.append("fi\n\n");
103 
104     // Fallback for _filedir if not available
105     sb.append("# Provide fallback if _filedir is not available\n");
106     sb.append("if ! type _filedir &>/dev/null; then\n");
107     sb.append("    _filedir() {\n");
108     sb.append("        COMPREPLY=($(compgen -f -- \"$cur\"))\n");
109     sb.append("    }\n");
110     sb.append("fi\n\n");
111 
112     // Main function
113     sb.append(funcName).append("() {\n");
114     sb.append("    local cur prev words cword\n");
115     sb.append("    _init_completion || return\n\n");
116 
117     // Top-level commands
118     String commandNames = commands.stream()
119         .map(ICommand::getName)
120         .collect(Collectors.joining(" "));
121     sb.append("    local commands=\"").append(commandNames).append("\"\n\n");
122 
123     // Case statement for command-specific completions
124     sb.append("    case \"${words[1]}\" in\n");
125     for (ICommand cmd : commands) {
126       generateBashCommandCase(sb, cmd, 2);
127     }
128     sb.append("        *)\n");
129     sb.append("            COMPREPLY=($(compgen -W \"$commands\" -- \"$cur\"))\n");
130     sb.append("            ;;\n");
131     sb.append("    esac\n");
132     sb.append("}\n\n");
133 
134     // Register completion
135     sb.append("complete -F ").append(funcName).append(" ").append(programName).append("\n");
136 
137     return ObjectUtils.notNull(sb.toString());
138   }
139 
140   /**
141    * Generate a Zsh completion script.
142    *
143    * @return the zsh completion script
144    */
145   @NonNull
146   public String generateZshCompletion() {
147     StringBuilder sb = new StringBuilder();
148     String funcName = "_" + sanitizeFunctionName(programName);
149 
150     // Header
151     sb.append("#compdef ").append(programName).append("\n\n");
152     sb.append("# Zsh completion for ").append(programName).append("\n");
153     sb.append("# Generated by metaschema cli-processor\n\n");
154 
155     // Main function
156     sb.append(funcName).append("() {\n");
157     sb.append("    local -a commands\n");
158     sb.append("    commands=(\n");
159     for (ICommand cmd : commands) {
160       String escaped = cmd.getDescription().replace("'", "'\\''");
161       sb.append("        '").append(cmd.getName()).append(":").append(escaped).append("'\n");
162     }
163     sb.append("    )\n\n");
164 
165     sb.append("    _arguments -C \\\n");
166     sb.append("        '1:command:->command' \\\n");
167     sb.append("        '*::arg:->args'\n\n");
168 
169     sb.append("    case $state in\n");
170     sb.append("        command)\n");
171     sb.append("            _describe 'command' commands\n");
172     sb.append("            ;;\n");
173     sb.append("        args)\n");
174     sb.append("            case $words[1] in\n");
175     for (ICommand cmd : commands) {
176       generateZshCommandCase(sb, cmd);
177     }
178     sb.append("            esac\n");
179     sb.append("            ;;\n");
180     sb.append("    esac\n");
181     sb.append("}\n\n");
182 
183     sb.append(funcName).append(" \"$@\"\n");
184 
185     return ObjectUtils.notNull(sb.toString());
186   }
187 
188   private void generateBashCommandCase(StringBuilder sb, ICommand cmd, int depth) {
189     String indent = "        ".repeat(Math.max(1, depth - 1));
190     sb.append(indent).append(cmd.getName()).append(")\n");
191 
192     Collection<? extends Option> options = cmd.gatherOptions();
193     List<ExtraArgument> extraArgs = cmd.getExtraArguments();
194     Collection<ICommand> subCommands = cmd.getSubCommands();
195 
196     // Handle sub-commands
197     if (!subCommands.isEmpty()) {
198       String subCmdNames = subCommands.stream()
199           .map(ICommand::getName)
200           .collect(Collectors.joining(" "));
201 
202       sb.append(indent).append("    if [[ ${#words[@]} -eq ").append(depth).append(" ]]; then\n");
203       sb.append(indent).append("        COMPREPLY=($(compgen -W \"").append(subCmdNames).append("\" -- \"$cur\"))\n");
204       sb.append(indent).append("    else\n");
205       sb.append(indent).append("        case \"${words[").append(depth).append("]}\" in\n");
206       for (ICommand subCmd : subCommands) {
207         generateBashCommandCase(sb, subCmd, depth + 1);
208       }
209       sb.append(indent).append("        esac\n");
210       sb.append(indent).append("    fi\n");
211     } else {
212       // Generate option completions
213       String optionNames = options.stream()
214           .map(CompletionScriptGenerator::getBashOptionName)
215           .collect(Collectors.joining(" "));
216 
217       sb.append(indent).append("    if [[ \"$cur\" == -* ]]; then\n");
218       sb.append(indent).append("        COMPREPLY=($(compgen -W \"").append(optionNames).append("\" -- \"$cur\"))\n");
219       sb.append(indent).append("    else\n");
220 
221       // Check previous word for option-specific completion
222       boolean hasOptionCompletion = false;
223       for (Option opt : options) {
224         if (opt.hasArg()) {
225           String completion = getCompletionForOption(opt, Shell.BASH);
226           if (!completion.isEmpty()) {
227             if (!hasOptionCompletion) {
228               sb.append(indent).append("        case \"$prev\" in\n");
229               hasOptionCompletion = true;
230             }
231             String optNames = getBashOptionName(opt);
232             sb.append(indent).append("            ").append(optNames.replace(" ", "|")).append(")\n");
233             sb.append(indent).append("                COMPREPLY=($(").append(completion).append(" -- \"$cur\"))\n");
234             sb.append(indent).append("                ;;\n");
235           }
236         }
237       }
238       if (hasOptionCompletion) {
239         sb.append(indent).append("            *)\n");
240       }
241 
242       // Default: file completion for extra arguments
243       String extraArgCompletion = getDefaultExtraArgumentCompletion(extraArgs, Shell.BASH);
244       if (!extraArgCompletion.isEmpty()) {
245         sb.append(indent).append("                ").append(extraArgCompletion).append("\n");
246       }
247 
248       if (hasOptionCompletion) {
249         sb.append(indent).append("                ;;\n");
250         sb.append(indent).append("        esac\n");
251       }
252       sb.append(indent).append("    fi\n");
253     }
254     sb.append(indent).append("    ;;\n");
255   }
256 
257   private static void generateZshCommandCase(StringBuilder sb, ICommand cmd) {
258     sb.append("                ").append(cmd.getName()).append(")\n");
259 
260     Collection<? extends Option> options = cmd.gatherOptions();
261     List<ExtraArgument> extraArgs = cmd.getExtraArguments();
262 
263     sb.append("                    _arguments \\\n");
264 
265     // Add options
266     for (Option opt : options) {
267       String optSpec = getZshOptionSpec(opt);
268       sb.append("                        ").append(optSpec).append(" \\\n");
269     }
270 
271     // Add extra arguments
272     int argNum = 1;
273     for (ExtraArgument arg : extraArgs) {
274       String completion = getCompletionForExtraArgument(arg, Shell.ZSH);
275       String required = arg.isRequired() ? "" : "::";
276       if (completion.isEmpty()) {
277         completion = " ";
278       }
279       sb.append("                        '").append(required.isEmpty() ? argNum : "").append(required)
280           .append(arg.getName()).append(":").append(completion).append("' \\\n");
281       argNum++;
282     }
283 
284     // Remove trailing backslash from last line
285     int lastBackslash = sb.lastIndexOf(" \\");
286     if (lastBackslash > 0) {
287       sb.delete(lastBackslash, lastBackslash + 2);
288     }
289     sb.append("\n");
290 
291     sb.append("                    ;;\n");
292   }
293 
294   @NonNull
295   private static String getBashOptionName(Option opt) {
296     StringBuilder sb = new StringBuilder();
297     if (opt.getOpt() != null) {
298       sb.append("-").append(opt.getOpt());
299     }
300     if (opt.getLongOpt() != null) {
301       if (sb.length() > 0) {
302         sb.append(" ");
303       }
304       sb.append("--").append(opt.getLongOpt());
305     }
306     return ObjectUtils.notNull(sb.toString());
307   }
308 
309   @NonNull
310   private static String getZshOptionSpec(Option opt) {
311     StringBuilder sb = new StringBuilder("'");
312     if (opt.getLongOpt() != null) {
313       sb.append("--").append(opt.getLongOpt());
314     } else if (opt.getOpt() != null) {
315       sb.append("-").append(opt.getOpt());
316     }
317 
318     String desc = opt.getDescription();
319     if (desc != null) {
320       desc = desc.replace("'", "'\\''");
321       sb.append("[").append(desc).append("]");
322     }
323 
324     if (opt.hasArg()) {
325       String argName = opt.getArgName();
326       if (argName == null) {
327         argName = "arg";
328       }
329       String completion = getCompletionForOption(opt, Shell.ZSH);
330       sb.append(":").append(argName).append(":").append(completion);
331     }
332 
333     sb.append("'");
334     return ObjectUtils.notNull(sb.toString());
335   }
336 
337   @NonNull
338   private static String getCompletionForOption(Option opt, Shell shell) {
339     Object typeObj = opt.getType();
340     Class<?> type = typeObj instanceof Class ? (Class<?>) typeObj : null;
341     ICompletionType completion = CompletionTypeRegistry.lookup(type);
342     if (completion != null) {
343       return shell == Shell.BASH ? completion.getBashCompletion() : completion.getZshCompletion();
344     }
345     return "";
346   }
347 
348   @NonNull
349   private static String getCompletionForExtraArgument(ExtraArgument arg, Shell shell) {
350     Class<?> type = arg.getType();
351     ICompletionType completion = CompletionTypeRegistry.lookup(type);
352     if (completion != null) {
353       return shell == Shell.BASH ? completion.getBashCompletion() : completion.getZshCompletion();
354     }
355     return "";
356   }
357 
358   @NonNull
359   private static String getDefaultExtraArgumentCompletion(List<ExtraArgument> args, Shell shell) {
360     // Use the first argument's type, or default to file completion
361     if (!args.isEmpty()) {
362       String completion = getCompletionForExtraArgument(args.get(0), shell);
363       if (!completion.isEmpty()) {
364         return completion;
365       }
366     }
367     // Default to file completion
368     if (shell == Shell.BASH) {
369       return "_filedir";
370     }
371     return "_files";
372   }
373 
374   @NonNull
375   private static String sanitizeFunctionName(String name) {
376     return ObjectUtils.notNull(name.replaceAll("[^a-zA-Z0-9_]", "_"));
377   }
378 }