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}