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 */
9import type * as Completions from "../../Completions.ts"
10
11// ---------------------------------------------------------------------------
12// Helpers
13// ---------------------------------------------------------------------------
14
15const escapeForBash = (s: string): string => s.replace(/'/g, "'\\''")
16
17const sanitizeFunctionName = (s: string): string => s.replace(/[^a-zA-Z0-9_]/g, "_")
18
19const 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. */
31const 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 */
59const 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
112const choiceCompletion = (helperName: string, values: ReadonlyArray<string>): string =>
113 `${helperName} "$cur" "$_comp_word" ${values.map((value) => `'${escapeForBash(value)}'`).join(" ")}`
114
115const 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
129const 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
145const 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 */
295export 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}