| 1 | /** |
| 2 | * Static Bash completion script generator. |
| 3 | * |
| 4 | * Produces a self-contained completion script from a `CommandDescriptor` — |
| 5 | * no re-invocation of the CLI at runtime. |
| 6 | * |
| 7 | * @internal |
| 8 | */ |
| 9 | import type * as Completions from "../../Completions.ts" |
| 10 | |
| 11 | // --------------------------------------------------------------------------- |
| 12 | // Helpers |
| 13 | // --------------------------------------------------------------------------- |
| 14 | |
| 15 | const escapeForBash = (s: string): string => s.replace(/'/g, "'\\''") |
| 16 | |
| 17 | const sanitizeFunctionName = (s: string): string => s.replace(/[^a-zA-Z0-9_]/g, "_") |
| 18 | |
| 19 | const flagNamesForWordlist = (flag: Completions.FlagDescriptor): Array<string> => { |
| 20 | const names: Array<string> = [`--${flag.name}`] |
| 21 | for (const alias of flag.aliases) { |
| 22 | names.push(alias.length === 1 ? `-${alias}` : `--${alias}`) |
| 23 | } |
| 24 | if (flag.type._tag === "Boolean") { |
| 25 | names.push(`--no-${flag.name}`) |
| 26 | } |
| 27 | return names |
| 28 | } |
| 29 | |
| 30 | /** Emit a Bash 3.2-compatible used-flag filter. */ |
| 31 | const buildFlagGroupDeclarations = ( |
| 32 | flags: ReadonlyArray<Completions.FlagDescriptor>, |
| 33 | lines: Array<string> |
| 34 | ): void => { |
| 35 | if (flags.length === 0) return |
| 36 | const groups = flags.map(flagNamesForWordlist) |
| 37 | lines.push(` local ${groups.map((_, index) => `_used_${index}=""`).join(" ")}`) |
| 38 | lines.push(` for ((i = 1; i < cword; i++)); do`) |
| 39 | lines.push(` case "\${words[i]%%=*}" in`) |
| 40 | groups.forEach((forms, index) => { |
| 41 | lines.push(` ${forms.join("|")}) _used_${index}=1 ;;`) |
| 42 | }) |
| 43 | lines.push(` esac`) |
| 44 | lines.push(` done`) |
| 45 | lines.push(` local _filtered_flags=""`) |
| 46 | groups.forEach((forms, index) => { |
| 47 | lines.push(` [[ -n "$_used_${index}" ]] || _filtered_flags+=" ${forms.join(" ")}"`) |
| 48 | }) |
| 49 | lines.push(``) |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Complete choices without `compgen -W`, which reparses quoted values. Escape |
| 54 | * only the portion readline replaces for the active quote context. |
| 55 | * |
| 56 | * `!` cannot be escaped inside a user-opened double quote without changing the |
| 57 | * resulting argument. |
| 58 | */ |
| 59 | const choicesHelper = (helperName: string, lines: Array<string>): void => { |
| 60 | lines.push(`${helperName}()`) |
| 61 | lines.push(`{`) |
| 62 | lines.push(` local _cur="$1" _word="$2"; shift 2`) |
| 63 | lines.push(``) |
| 64 | lines.push(` local _head="\${_cur%"$_word"}"`) |
| 65 | lines.push(` local _open=""`) |
| 66 | lines.push(` case "$_head" in`) |
| 67 | lines.push(` *\\') _open="'" ;;`) |
| 68 | lines.push(` *\\") _open='"' ;;`) |
| 69 | lines.push(` esac`) |
| 70 | lines.push(``) |
| 71 | lines.push(` local _prefix="$_cur" _committed="$_head"`) |
| 72 | // Quoting these substitutions breaks quote matching on Bash 3.2. |
| 73 | lines.push(` _prefix=\${_prefix//\\\\/}; _prefix=\${_prefix//\\"/}; _prefix=\${_prefix//\\'/}`) |
| 74 | lines.push(` _committed=\${_committed//\\\\/}; _committed=\${_committed//\\"/}; _committed=\${_committed//\\'/}`) |
| 75 | lines.push(``) |
| 76 | lines.push(` COMPREPLY=()`) |
| 77 | lines.push(` local _choice _rest _match`) |
| 78 | lines.push(` for _choice in "$@"; do`) |
| 79 | lines.push(` [[ "$_choice" == "$_prefix"* ]] || continue`) |
| 80 | lines.push(` _rest="\${_choice#"$_committed"}"`) |
| 81 | lines.push(` case "$_open" in`) |
| 82 | lines.push(` "'")`) |
| 83 | lines.push(` if [[ "$_head" == "'" ]]; then`) |
| 84 | // Use a shell splice for a quote opened at the start of the word. |
| 85 | lines.push(` _match=\${_rest//\\'/\\'\\\\\\'\\'}`) |
| 86 | lines.push(` else`) |
| 87 | // A mid-word single-quote context cannot contain another single quote. |
| 88 | lines.push(` [[ "$_rest" == *\\'* ]] && continue`) |
| 89 | lines.push(` _match="$_rest"`) |
| 90 | lines.push(` fi`) |
| 91 | lines.push(` ;;`) |
| 92 | lines.push(` '"')`) |
| 93 | lines.push(` _match="\${_rest//\\\\/\\\\\\\\}"`) |
| 94 | lines.push(` _match="\${_match//\\$/\\\\$}"`) |
| 95 | lines.push(" _match=\"${_match//\\`/\\\\\\`}\"") |
| 96 | lines.push(` _match="\${_match//\\"/\\\\\\"}"`) |
| 97 | lines.push(` ;;`) |
| 98 | lines.push(` *)`) |
| 99 | lines.push(` printf -v _match '%q' "$_rest"`) |
| 100 | // Bash 3.2 leaves a leading tilde unescaped. |
| 101 | lines.push(` [[ -z "$_head" && "$_match" == '~'* ]] && _match="\\\\$_match"`) |
| 102 | lines.push(` ;;`) |
| 103 | lines.push(` esac`) |
| 104 | // Readline omits a closing quote already present in the match. |
| 105 | lines.push(` [[ -n "$_open" && "$_match" == *"$_open" ]] && _match+="$_open"`) |
| 106 | lines.push(` COMPREPLY+=("$_match")`) |
| 107 | lines.push(` done`) |
| 108 | lines.push(`}`) |
| 109 | lines.push(``) |
| 110 | } |
| 111 | |
| 112 | const choiceCompletion = (helperName: string, values: ReadonlyArray<string>): string => |
| 113 | `${helperName} "$cur" "$_comp_word" ${values.map((value) => `'${escapeForBash(value)}'`).join(" ")}` |
| 114 | |
| 115 | const flagValueCompletion = (type: Completions.FlagType, helperName: string): string | undefined => { |
| 116 | switch (type._tag) { |
| 117 | case "Boolean": |
| 118 | return undefined |
| 119 | case "Choice": |
| 120 | return choiceCompletion(helperName, type.values) |
| 121 | case "Path": |
| 122 | if (type.pathType === "directory") return `COMPREPLY=( $(compgen -d -- "$cur") )` |
| 123 | return `COMPREPLY=( $(compgen -f -- "$cur") )` |
| 124 | default: |
| 125 | return undefined |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | const argCompletion = (type: Completions.ArgumentType, helperName: string): string | undefined => { |
| 130 | switch (type._tag) { |
| 131 | case "Choice": |
| 132 | return choiceCompletion(helperName, type.values) |
| 133 | case "Path": |
| 134 | if (type.pathType === "directory") return `COMPREPLY=( $(compgen -d -- "$cur") )` |
| 135 | return `COMPREPLY=( $(compgen -f -- "$cur") )` |
| 136 | default: |
| 137 | return undefined |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // --------------------------------------------------------------------------- |
| 142 | // Generator |
| 143 | // --------------------------------------------------------------------------- |
| 144 | |
| 145 | const generateFunction = ( |
| 146 | descriptor: Completions.CommandDescriptor, |
| 147 | parentPath: ReadonlyArray<string>, |
| 148 | lines: Array<string>, |
| 149 | helperName: string |
| 150 | ): void => { |
| 151 | const currentPath = [...parentPath, descriptor.name] |
| 152 | const funcName = `_${currentPath.map(sanitizeFunctionName).join("_")}` |
| 153 | |
| 154 | lines.push(`${funcName}()`) |
| 155 | lines.push(`{`) |
| 156 | lines.push(` local cur prev words cword i`) |
| 157 | lines.push(parentPath.length === 0 ? ` local _command_index=0` : ` local _command_index="$1"`) |
| 158 | // Keep values containing COMP_WORDBREAKS characters in one word. |
| 159 | lines.push(` _init_completion -n "$COMP_WORDBREAKS" || return`) |
| 160 | if (parentPath.length === 0) { |
| 161 | // Subcommand functions inherit this through Bash's dynamic scope. |
| 162 | lines.push(` local _comp_word="$2"`) |
| 163 | } |
| 164 | lines.push(``) |
| 165 | |
| 166 | // Build flag-value dispatch |
| 167 | const flagsWithValues = descriptor.flags.filter((f) => f.type._tag !== "Boolean") |
| 168 | if (flagsWithValues.length > 0) { |
| 169 | lines.push(` # Flag value completions`) |
| 170 | lines.push(` case "$prev" in`) |
| 171 | for (const flag of flagsWithValues) { |
| 172 | const longNames = [`--${flag.name}`] |
| 173 | for (const alias of flag.aliases) { |
| 174 | longNames.push(alias.length === 1 ? `-${alias}` : `--${alias}`) |
| 175 | } |
| 176 | const completion = flagValueCompletion(flag.type, helperName) |
| 177 | if (completion) { |
| 178 | lines.push(` ${longNames.join("|")})`) |
| 179 | lines.push(` ${completion}`) |
| 180 | lines.push(` return`) |
| 181 | lines.push(` ;;`) |
| 182 | } |
| 183 | } |
| 184 | lines.push(` esac`) |
| 185 | lines.push(``) |
| 186 | } |
| 187 | |
| 188 | // Subcommand dispatch |
| 189 | if (descriptor.subcommands.length > 0) { |
| 190 | lines.push(` # Subcommand dispatch`) |
| 191 | lines.push(` local cmd _skip_next=0`) |
| 192 | lines.push(` for ((i = _command_index + 1; i < cword; i++)); do`) |
| 193 | lines.push(` if (( _skip_next )); then`) |
| 194 | lines.push(` _skip_next=0`) |
| 195 | lines.push(` continue`) |
| 196 | lines.push(` fi`) |
| 197 | lines.push(` case "\${words[i]}" in`) |
| 198 | for (const flag of descriptor.flags) { |
| 199 | if (flag.type._tag === "Boolean") continue |
| 200 | const forms = flagNamesForWordlist(flag) |
| 201 | lines.push(` ${forms.join("|")}) _skip_next=1 ;;`) |
| 202 | lines.push(` ${forms.map((form) => `${form}=*`).join("|")}) ;;`) |
| 203 | } |
| 204 | for (const sub of descriptor.subcommands) { |
| 205 | const subFuncName = `_${[...currentPath, sub.name].map(sanitizeFunctionName).join("_")}` |
| 206 | lines.push(` ${sub.name})`) |
| 207 | lines.push(` ${subFuncName} "$i"`) |
| 208 | lines.push(` return`) |
| 209 | lines.push(` ;;`) |
| 210 | } |
| 211 | lines.push(` esac`) |
| 212 | lines.push(` done`) |
| 213 | lines.push(``) |
| 214 | } |
| 215 | |
| 216 | // Filter already-used flags (entire alias group removed when any form is used) |
| 217 | buildFlagGroupDeclarations(descriptor.flags, lines) |
| 218 | |
| 219 | if (descriptor.flags.length > 0 || descriptor.subcommands.length > 0) { |
| 220 | lines.push(` # Complete flags (filtered) and subcommands`) |
| 221 | lines.push(` if [[ "$cur" == -* ]]; then`) |
| 222 | if (descriptor.flags.length > 0) { |
| 223 | lines.push(` COMPREPLY=( $(compgen -W "$_filtered_flags" -- "$cur") )`) |
| 224 | } |
| 225 | lines.push(` return`) |
| 226 | lines.push(` fi`) |
| 227 | lines.push(``) |
| 228 | } |
| 229 | |
| 230 | // Positional argument completion |
| 231 | const argsWithCompletions = descriptor.arguments.flatMap((argument, index) => { |
| 232 | const completion = argCompletion(argument.type, helperName) |
| 233 | return completion === undefined ? [] : [{ argument, completion, index }] |
| 234 | }) |
| 235 | if (argsWithCompletions.length > 0) { |
| 236 | lines.push(` # Positional argument completions`) |
| 237 | lines.push(` local _position=0 _skip_next=0 _end_of_options=0`) |
| 238 | lines.push(` for ((i = _command_index + 1; i < cword; i++)); do`) |
| 239 | lines.push(` if (( _skip_next )); then`) |
| 240 | lines.push(` _skip_next=0`) |
| 241 | lines.push(` continue`) |
| 242 | lines.push(` fi`) |
| 243 | lines.push(` if (( _end_of_options )); then`) |
| 244 | lines.push(` ((_position += 1))`) |
| 245 | lines.push(` continue`) |
| 246 | lines.push(` fi`) |
| 247 | lines.push(` case "\${words[i]}" in`) |
| 248 | lines.push(` --) _end_of_options=1 ;;`) |
| 249 | for (const flag of descriptor.flags) { |
| 250 | const forms = flagNamesForWordlist(flag) |
| 251 | if (flag.type._tag === "Boolean") { |
| 252 | lines.push(` ${forms.join("|")}) ;;`) |
| 253 | } else { |
| 254 | lines.push(` ${forms.join("|")}) _skip_next=1 ;;`) |
| 255 | lines.push(` ${forms.map((form) => `${form}=*`).join("|")}) ;;`) |
| 256 | } |
| 257 | } |
| 258 | lines.push(` -*) ;;`) |
| 259 | lines.push(` *) ((_position += 1)) ;;`) |
| 260 | lines.push(` esac`) |
| 261 | lines.push(` done`) |
| 262 | lines.push(` case "$_position" in`) |
| 263 | for (const { argument, completion, index } of argsWithCompletions) { |
| 264 | if (argument.variadic) continue |
| 265 | lines.push(` ${index})`) |
| 266 | lines.push(` ${completion}`) |
| 267 | lines.push(` return`) |
| 268 | lines.push(` ;;`) |
| 269 | } |
| 270 | lines.push(` esac`) |
| 271 | const variadic = argsWithCompletions.find(({ argument }) => argument.variadic) |
| 272 | if (variadic !== undefined) { |
| 273 | lines.push(` if (( _position >= ${variadic.index} )); then`) |
| 274 | lines.push(` ${variadic.completion}`) |
| 275 | lines.push(` return`) |
| 276 | lines.push(` fi`) |
| 277 | } |
| 278 | } else if (descriptor.subcommands.length > 0) { |
| 279 | const subNames = descriptor.subcommands.map((s) => s.name) |
| 280 | lines.push(` COMPREPLY=( $(compgen -W '${subNames.join(" ")}' -- "$cur") )`) |
| 281 | } |
| 282 | |
| 283 | lines.push(`}`) |
| 284 | lines.push(``) |
| 285 | |
| 286 | // Recurse into subcommands |
| 287 | for (const sub of descriptor.subcommands) { |
| 288 | generateFunction(sub, currentPath, lines, helperName) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * @internal |
| 294 | */ |
| 295 | export const generate = ( |
| 296 | executableName: string, |
| 297 | descriptor: Completions.CommandDescriptor |
| 298 | ): string => { |
| 299 | const lines: Array<string> = [] |
| 300 | const safeName = sanitizeFunctionName(executableName) |
| 301 | // Sanitized command names cannot contain `-`. |
| 302 | const helperName = `_${safeName}--choices` |
| 303 | |
| 304 | lines.push(`###-begin-${escapeForBash(executableName)}-completions-###`) |
| 305 | lines.push(`#`) |
| 306 | lines.push(`# Static completion script for Bash`) |
| 307 | lines.push(`#`) |
| 308 | lines.push(`# Installation:`) |
| 309 | lines.push(`# ${escapeForBash(executableName)} --completions bash >> ~/.bashrc`) |
| 310 | lines.push(`#`) |
| 311 | lines.push(``) |
| 312 | |
| 313 | // Fallback for environments without bash-completion. Preserve word-break |
| 314 | // characters inside values. |
| 315 | lines.push(`if ! type _init_completion &>/dev/null; then`) |
| 316 | lines.push(` _init_completion()`) |
| 317 | lines.push(` {`) |
| 318 | lines.push(` COMPREPLY=()`) |
| 319 | lines.push(` local _i _j=0 _piece _line="$COMP_LINE"`) |
| 320 | lines.push(` words=("\${COMP_WORDS[0]}")`) |
| 321 | lines.push(` cword=0`) |
| 322 | lines.push(` _line="\${_line#*"\${COMP_WORDS[0]}"}"`) |
| 323 | lines.push(` for ((_i = 1; _i < \${#COMP_WORDS[@]}; _i++)); do`) |
| 324 | lines.push(` _piece="\${COMP_WORDS[_i]}"`) |
| 325 | lines.push(` if [[ "$_line" == [[:blank:]]* ]]; then`) |
| 326 | lines.push(` ((_j++))`) |
| 327 | lines.push(` words[_j]="$_piece"`) |
| 328 | lines.push(` else`) |
| 329 | lines.push(` words[_j]="\${words[_j]}$_piece"`) |
| 330 | lines.push(` fi`) |
| 331 | lines.push(` ((_i == COMP_CWORD)) && cword=$_j`) |
| 332 | lines.push(` _line="\${_line#*"$_piece"}"`) |
| 333 | lines.push(` done`) |
| 334 | lines.push(` cur="\${words[cword]}"`) |
| 335 | lines.push(` prev=""`) |
| 336 | lines.push(` ((cword > 0)) && prev="\${words[cword-1]}"`) |
| 337 | lines.push(` return 0`) |
| 338 | lines.push(` }`) |
| 339 | lines.push(`fi`) |
| 340 | lines.push(``) |
| 341 | |
| 342 | choicesHelper(helperName, lines) |
| 343 | generateFunction(descriptor, [], lines, helperName) |
| 344 | |
| 345 | lines.push(`complete -F _${safeName} ${escapeForBash(executableName)}`) |
| 346 | lines.push(`###-end-${escapeForBash(executableName)}-completions-###`) |
| 347 | |
| 348 | return lines.join("\n") |
| 349 | } |