| 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 | */ |
| 9 | import type * as Completions from "../../Completions.ts" |
| 10 | |
| 11 | // --------------------------------------------------------------------------- |
| 12 | // Helpers |
| 13 | // --------------------------------------------------------------------------- |
| 14 | |
| 15 | const escapeFishString = (s: string): string => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") |
| 16 | |
| 17 | /** Escape choices for Fish's second expansion of `complete -a`. */ |
| 18 | const 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 | */ |
| 27 | const 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 | */ |
| 50 | const 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. */ |
| 71 | const 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 | |
| 79 | const 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 | |
| 98 | const 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 | |
| 113 | const 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 | |
| 128 | const 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 | */ |
| 240 | export 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 | } |