001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.cli.processor.completion;
007
008import org.apache.commons.cli.Option;
009
010import java.util.Collection;
011import java.util.List;
012import java.util.stream.Collectors;
013
014import dev.metaschema.cli.processor.command.ExtraArgument;
015import dev.metaschema.cli.processor.command.ICommand;
016import dev.metaschema.core.util.ObjectUtils;
017import edu.umd.cs.findbugs.annotations.NonNull;
018
019/**
020 * Generates shell completion scripts for Bash and Zsh.
021 * <p>
022 * This generator introspects registered commands and their options to produce
023 * completion scripts that provide intelligent tab-completion for command-line
024 * tools built on the cli-processor framework.
025 */
026public class CompletionScriptGenerator {
027
028  /**
029   * Supported shell types.
030   */
031  public enum Shell {
032    /** Bash shell. */
033    BASH,
034    /** Zsh shell. */
035    ZSH
036  }
037
038  @NonNull
039  private final String programName;
040  @NonNull
041  private final List<ICommand> commands;
042
043  /**
044   * Construct a new generator.
045   *
046   * @param programName
047   *          the name of the CLI program
048   * @param commands
049   *          the top-level commands to include in completion
050   */
051  public CompletionScriptGenerator(
052      @NonNull String programName,
053      @NonNull List<ICommand> commands) {
054    this.programName = programName;
055    this.commands = commands;
056  }
057
058  /**
059   * Get the program name.
060   *
061   * @return the program name
062   */
063  @NonNull
064  public String getProgramName() {
065    return programName;
066  }
067
068  /**
069   * Get the commands.
070   *
071   * @return the commands
072   */
073  @NonNull
074  public List<ICommand> getCommands() {
075    return commands;
076  }
077
078  /**
079   * Generate a Bash completion script.
080   *
081   * @return the bash completion script
082   */
083  @NonNull
084  public String generateBashCompletion() {
085    StringBuilder sb = new StringBuilder();
086    String funcName = "_" + sanitizeFunctionName(programName);
087
088    // Header
089    sb.append("# Bash completion for ").append(programName).append("\n");
090    sb.append("# Generated by metaschema cli-processor\n\n");
091
092    // Fallback for systems without bash-completion
093    sb.append("# Provide fallback if _init_completion is not available\n");
094    sb.append("if ! type _init_completion &>/dev/null; then\n");
095    sb.append("    _init_completion() {\n");
096    sb.append("        COMPREPLY=()\n");
097    sb.append("        cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
098    sb.append("        prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n");
099    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}