1/**
2 * Static Fish 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 escapeFishString = (s: string): string => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
16
17/** Escape choices for Fish's second expansion of `complete -a`. */
18const escapeFishChoice = (s: string): string => escapeFishString(s.replace(/[^A-Za-z0-9_.,/@%+-]/gu, "\\$&"))
19
20/**
21 * Build a Fish condition that checks the current subcommand context.
22 *
23 * For root-level completions with subcommands: `__fish_use_subcommand`
24 * For nested commands: verify the full parent path is active AND none of its
25 * child subcommands have been entered yet.
26 */
27const subcommandCondition = (
28 parentPath: ReadonlyArray<string>,
29 childSubcommandNames: ReadonlyArray<string>
30): string => {
31 if (parentPath.length === 0) {
32 if (childSubcommandNames.length > 0) {
33 return `__fish_use_subcommand`
34 }
35 return ``
36 }
37 const parentCondition = parentPath.map((parent) => `__fish_seen_subcommand_from ${parent}`).join("; and ")
38 if (childSubcommandNames.length > 0) {
39 // Show only when the parent path is active but no child subcommand has been entered
40 return `${parentCondition}; and not __fish_seen_subcommand_from ${childSubcommandNames.join(" ")}`
41 }
42 return parentCondition
43}
44
45/**
46 * Build a __fish_contains_opt condition that checks whether any form of this
47 * flag has already been typed. Returns the condition string without wrapping
48 * quotes (the caller adds those).
49 */
50const flagContainsOptCondition = (flag: Completions.FlagDescriptor): string => {
51 const optArgs: Array<string> = []
52 for (const alias of flag.aliases) {
53 if (alias.length === 1) {
54 optArgs.push(`-s ${alias}`)
55 }
56 }
57 // Long names (fish __fish_contains_opt uses bare words for long opts)
58 optArgs.push(flag.name)
59 for (const alias of flag.aliases) {
60 if (alias.length > 1) {
61 optArgs.push(alias)
62 }
63 }
64 if (flag.type._tag === "Boolean") {
65 optArgs.push(`no-${flag.name}`)
66 }
67 return `not __fish_contains_opt ${optArgs.join(" ")}`
68}
69
70/** Hide used value flags without suppressing their value completions. */
71const valueFlagDedupCondition = (flag: Completions.FlagDescriptor): string => {
72 const forms = [`--${flag.name}`]
73 for (const alias of flag.aliases) {
74 forms.push(alias.length === 1 ? `-${alias}` : `--${alias}`)
75 }
76 return `begin; ${flagContainsOptCondition(flag)}; or contains -- (commandline -poc)[-1] ${forms.join(" ")}; end`
77}
78
79const flagCompletionArgs = (flag: Completions.FlagDescriptor): Array<string> => {
80 const args: Array<string> = [`-l ${flag.name}`]
81 for (const alias of flag.aliases) {
82 if (alias.length === 1) {
83 args.push(`-s ${alias}`)
84 } else {
85 args.push(`-l ${alias}`)
86 }
87 }
88 if (flag.description) {
89 args.push(`-d '${escapeFishString(flag.description)}'`)
90 }
91 const valueArgs = flagValueArgs(flag.type)
92 if (valueArgs) {
93 args.push(valueArgs)
94 }
95 return args
96}
97
98const flagValueArgs = (type: Completions.FlagType): string | undefined => {
99 switch (type._tag) {
100 case "Boolean":
101 return undefined
102 case "Choice":
103 return `-r -f -a '${type.values.map(escapeFishChoice).join(" ")}'`
104 case "Path":
105 if (type.pathType === "directory") return `-r -F`
106 return `-r -F`
107 default:
108 // -r: requires a value, -f: don't fall back to file completion
109 return `-r -f`
110 }
111}
112
113const argValueArgs = (type: Completions.ArgumentType): string | undefined => {
114 switch (type._tag) {
115 case "Choice":
116 return `-r -f -a '${type.values.map(escapeFishChoice).join(" ")}'`
117 case "Path":
118 return `-r -F`
119 default:
120 return undefined
121 }
122}
123
124// ---------------------------------------------------------------------------
125// Generator
126// ---------------------------------------------------------------------------
127
128const generateCompletions = (
129 executableName: string,
130 descriptor: Completions.CommandDescriptor,
131 parentPath: ReadonlyArray<string>,
132 lines: Array<string>
133): void => {
134 const allSubNames = descriptor.subcommands.map((s) => s.name)
135 const condition = subcommandCondition(parentPath, allSubNames)
136 const conditionArg = condition ? `-n '${condition}'` : ``
137
138 // Suppress default file completion unless the command has path-type
139 // positional arguments. Without this, fish falls back to listing files
140 // even when only flags are valid.
141 const hasPathArgs = descriptor.arguments.some((a) => a.type._tag === "Path")
142 if (!hasPathArgs) {
143 const parts = [`complete -c ${executableName}`]
144 if (conditionArg) parts.push(conditionArg)
145 parts.push(`-f`)
146 lines.push(parts.join(" "))
147 }
148
149 // Subcommand completions
150 for (const sub of descriptor.subcommands) {
151 const parts = [`complete -c ${executableName}`]
152 if (conditionArg) parts.push(conditionArg)
153 parts.push(`-f -a '${escapeFishString(sub.name)}'`)
154 if (sub.description) {
155 parts.push(`-d '${escapeFishString(sub.description)}'`)
156 }
157 lines.push(parts.join(" "))
158 }
159
160 // Flag completions
161 for (const flag of descriptor.flags) {
162 const isBoolean = flag.type._tag === "Boolean"
163 const dedup = isBoolean ? flagContainsOptCondition(flag) : valueFlagDedupCondition(flag)
164 const flagCondition = condition ? `${condition}; and ${dedup}` : dedup
165 const flagCondArg = flagCondition ? `-n '${flagCondition}'` : ``
166
167 const parts = [`complete -c ${executableName}`]
168 if (flagCondArg) parts.push(flagCondArg)
169 parts.push(...flagCompletionArgs(flag))
170 lines.push(parts.join(" "))
171
172 // Boolean negation
173 if (isBoolean) {
174 const negParts = [`complete -c ${executableName}`]
175 if (flagCondArg) negParts.push(flagCondArg)
176 negParts.push(`-l no-${flag.name}`)
177 if (flag.description) {
178 negParts.push(`-d '${escapeFishString(`Disable ${flag.name}`)}'`)
179 }
180 lines.push(negParts.join(" "))
181 }
182 }
183
184 // Flags as -a entries for bare <TAB> visibility.
185 // Fish only shows -l/-s entries when the current word starts with -, so
186 // without this, flags are invisible on bare <TAB>. Guarded to only show
187 // when NOT already typing an option (avoids duplication with -l/-s above)
188 // and hidden once a flag has been used (__fish_contains_opt).
189 if (descriptor.flags.length > 0) {
190 const notDash = `not string match -q -- "-*" (commandline -ct)`
191 const bareBase = condition ? `${condition}; and ${notDash}` : notDash
192
193 for (const flag of descriptor.flags) {
194 const bareCondition = `${bareBase}; and ${flagContainsOptCondition(flag)}`
195 const isBoolean = flag.type._tag === "Boolean"
196
197 const parts = [`complete -c ${executableName}`]
198 parts.push(`-n '${bareCondition}'`)
199 parts.push(`-f -a '--${flag.name}'`)
200 if (flag.description) {
201 parts.push(`-d '${escapeFishString(flag.description)}'`)
202 }
203 lines.push(parts.join(" "))
204
205 if (isBoolean) {
206 const negParts = [`complete -c ${executableName}`]
207 negParts.push(`-n '${bareCondition}'`)
208 negParts.push(`-f -a '--no-${flag.name}'`)
209 if (flag.description) {
210 negParts.push(`-d '${escapeFishString(`Disable ${flag.name}`)}'`)
211 }
212 lines.push(negParts.join(" "))
213 }
214 }
215 }
216
217 // Argument completions (type hints only)
218 for (const arg of descriptor.arguments) {
219 const valueArg = argValueArgs(arg.type)
220 if (valueArg) {
221 const parts = [`complete -c ${executableName}`]
222 if (conditionArg) parts.push(conditionArg)
223 parts.push(valueArg)
224 if (arg.description) {
225 parts.push(`-d '${escapeFishString(arg.description)}'`)
226 }
227 lines.push(parts.join(" "))
228 }
229 }
230
231 // Recurse into subcommands
232 for (const sub of descriptor.subcommands) {
233 generateCompletions(executableName, sub, [...parentPath, sub.name], lines)
234 }
235}
236
237/**
238 * @internal
239 */
240export const generate = (
241 executableName: string,
242 descriptor: Completions.CommandDescriptor
243): string => {
244 const lines: Array<string> = []
245
246 lines.push(`###-begin-${executableName}-completions-###`)
247 lines.push(`#`)
248 lines.push(`# Static completion script for Fish`)
249 lines.push(`#`)
250 lines.push(`# Installation:`)
251 lines.push(`# ${executableName} --completions fish > ~/.config/fish/completions/${executableName}.fish`)
252 lines.push(`#`)
253 lines.push(``)
254
255 generateCompletions(executableName, descriptor, [], lines)
256
257 lines.push(``)
258 lines.push(`###-end-${executableName}-completions-###`)
259
260 return lines.join("\n")
261}