1/**
2 * Static Zsh 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 escapeZsh = (s: string): string => s.replace(/\\/g, "\\\\").replace(/'/g, "'\\''").replace(/:/g, "\\:")
16
17/** Escape choices for the second parse of a Zsh `_arguments` action. */
18const escapeZshChoice = (s: string): string => s.replace(/[^A-Za-z0-9_.,/@%+-]/gu, "\\$&").replace(/'/g, "'\\''")
19
20const sanitize = (s: string): string => s.replace(/[^a-zA-Z0-9_]/g, "_")
21
22/**
23 * All forms of a flag (--name, -alias, --no-name) for the exclusion group.
24 */
25const allForms = (flag: Completions.FlagDescriptor): Array<string> => {
26 const forms: Array<string> = [`--${flag.name}`]
27 for (const alias of flag.aliases) {
28 forms.push(alias.length === 1 ? `-${alias}` : `--${alias}`)
29 }
30 if (flag.type._tag === "Boolean") {
31 forms.push(`--no-${flag.name}`)
32 }
33 return forms
34}
35
36const valueAction = (type: Completions.FlagType): string => {
37 switch (type._tag) {
38 case "Boolean":
39 return ""
40 case "Choice":
41 return `:value:(${type.values.map(escapeZshChoice).join(" ")})`
42 case "Path":
43 return type.pathType === "directory" ? `:directory:_directories` : `:file:_files`
44 case "Int":
45 return `:integer:`
46 case "Finite":
47 return `:float:`
48 case "Date":
49 return `:date:`
50 default:
51 return `:value:`
52 }
53}
54
55const argAction = (type: Completions.ArgumentType): string => {
56 switch (type._tag) {
57 case "Choice":
58 return `(${type.values.map(escapeZshChoice).join(" ")})`
59 case "Path":
60 return type.pathType === "directory" ? `_directories` : `_files`
61 default:
62 return ``
63 }
64}
65
66/**
67 * Produce _arguments optspecs for a single flag. Each spec is a complete,
68 * single-quoted string — no brace expansion, no line continuations.
69 */
70const flagSpecs = (flag: Completions.FlagDescriptor): Array<string> => {
71 const specs: Array<string> = []
72 const desc = flag.description ? `[${escapeZsh(flag.description)}]` : ""
73 const action = valueAction(flag.type)
74 const excl = `(${allForms(flag).join(" ")})`
75
76 // Primary long name
77 specs.push(`'${excl}--${flag.name}${desc}${action}'`)
78
79 // Aliases
80 for (const alias of flag.aliases) {
81 const prefix = alias.length === 1 ? "-" : "--"
82 specs.push(`'${excl}${prefix}${alias}${desc}${action}'`)
83 }
84
85 // Boolean negation
86 if (flag.type._tag === "Boolean") {
87 const negDesc = flag.description ? `[${escapeZsh(`Disable ${flag.name}`)}]` : ""
88 specs.push(`'${excl}--no-${flag.name}${negDesc}'`)
89 }
90
91 return specs
92}
93
94const argSpec = (arg: Completions.ArgumentDescriptor): string => {
95 const desc = arg.description ? escapeZsh(arg.description) : arg.name
96 const action = argAction(arg.type)
97 const prefix = arg.variadic ? "*" : ""
98 return `'${prefix}:${desc}:${action}'`
99}
100
101// ---------------------------------------------------------------------------
102// Generator
103// ---------------------------------------------------------------------------
104
105const generateFunction = (
106 descriptor: Completions.CommandDescriptor,
107 parentPath: ReadonlyArray<string>,
108 lines: Array<string>
109): void => {
110 const currentPath = [...parentPath, descriptor.name]
111 const funcName = `_${currentPath.map(sanitize).join("_")}`
112
113 lines.push(`${funcName}() {`)
114
115 if (descriptor.subcommands.length > 0) {
116 // _arguments -C requires these locals for the state machine
117 lines.push(` local context state state_descr line`)
118 lines.push(` typeset -A opt_args`)
119 lines.push(``)
120
121 // Subcommand list
122 lines.push(` local -a commands`)
123 lines.push(` commands=(`)
124 for (const sub of descriptor.subcommands) {
125 const desc = sub.description ? escapeZsh(sub.description) : ""
126 lines.push(` '${sub.name}:${desc}'`)
127 }
128 lines.push(` )`)
129 lines.push(``)
130
131 // Build specs array to avoid brace expansion / line continuation issues
132 lines.push(` local -a specs`)
133 lines.push(` specs=(`)
134 for (const flag of descriptor.flags) {
135 for (const spec of flagSpecs(flag)) {
136 lines.push(` ${spec}`)
137 }
138 }
139 if (descriptor.arguments.length > 0) {
140 lines.push(` -`)
141 lines.push(` parent-arguments`)
142 for (const arg of descriptor.arguments) {
143 lines.push(` ${argSpec(arg)}`)
144 }
145 lines.push(` -`)
146 lines.push(` subcommands`)
147 }
148 lines.push(` '1:command:->command'`)
149 lines.push(` '*::arg:->args'`)
150 lines.push(` )`)
151 lines.push(``)
152 lines.push(` _arguments -C "\${specs[@]}"`)
153 lines.push(``)
154
155 // State dispatch
156 lines.push(` case "$state" in`)
157 lines.push(` command)`)
158 lines.push(` _describe -t commands 'commands' commands`)
159 lines.push(` ;;`)
160 lines.push(` args)`)
161 lines.push(` case "$words[1]" in`)
162 for (const sub of descriptor.subcommands) {
163 const subFunc = `_${[...currentPath, sub.name].map(sanitize).join("_")}`
164 lines.push(` ${sub.name})`)
165 lines.push(` ${subFunc}`)
166 lines.push(` ;;`)
167 }
168 lines.push(` esac`)
169 lines.push(` ;;`)
170 lines.push(` esac`)
171 } else {
172 // Leaf command — no subcommands
173 const allSpecs: Array<string> = []
174 for (const flag of descriptor.flags) {
175 allSpecs.push(...flagSpecs(flag))
176 }
177 for (const arg of descriptor.arguments) {
178 allSpecs.push(argSpec(arg))
179 }
180 if (allSpecs.length > 0) {
181 lines.push(` local -a specs`)
182 lines.push(` specs=(`)
183 for (const spec of allSpecs) {
184 lines.push(` ${spec}`)
185 }
186 lines.push(` )`)
187 lines.push(` _arguments "\${specs[@]}"`)
188 }
189 }
190
191 lines.push(`}`)
192 lines.push(``)
193
194 for (const sub of descriptor.subcommands) {
195 generateFunction(sub, currentPath, lines)
196 }
197}
198
199/**
200 * @internal
201 */
202export const generate = (
203 executableName: string,
204 descriptor: Completions.CommandDescriptor
205): string => {
206 const lines: Array<string> = []
207 const safeName = sanitize(executableName)
208
209 lines.push(`#compdef ${executableName}`)
210 lines.push(`###-begin-${executableName}-completions-###`)
211 lines.push(`#`)
212 lines.push(`# Static completion script for Zsh`)
213 lines.push(`#`)
214 lines.push(`# Installation:`)
215 lines.push(`# ${executableName} --completions zsh > ~/.zsh/completions/_${executableName}`)
216 lines.push(`# then add ~/.zsh/completions to your fpath`)
217 lines.push(`#`)
218 lines.push(``)
219
220 generateFunction(descriptor, [], lines)
221
222 lines.push(`# Handle both direct invocation and autoload`)
223 lines.push(`if [[ "\${zsh_eval_context[-1]}" == "loadautofunc" ]]; then`)
224 lines.push(` _${safeName} "$@"`)
225 lines.push(`else`)
226 lines.push(` compdef _${safeName} ${executableName}`)
227 lines.push(`fi`)
228 lines.push(`###-end-${executableName}-completions-###`)
229
230 return lines.join("\n")
231}