/** * Fish completion generator. Fish has the cleanest completion grammar of the * three shells: one `complete` line per option per command. No driver function * needed; fish's built-in `__fish_seen_subcommand_from` and friends do the * subcommand parsing for us. The bin name is injected by the caller. * * Layout per command path: * complete -c -n '' -a -d '' * complete -c -n '' -l [-s ] -d '' [-r] [-x] [-a ''] * * Dynamic values: fish runs the command in `-a '(...)'` at completion time, * giving us per-tab callbacks for free. */ import type { CommandSpec } from "./walk.js"; function fishEscape(s: string): string { // Inside single-quoted fish strings, escape \\ and '. return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } function truncate(s: string, n: number): string { if (!s) return ""; return s.length > n ? `${s.slice(0, n - 1)}…` : s; } /** * Returns the fish condition expression for "we are inside the command path". * Path "" (root) means "no subcommand seen yet". */ function pathCondition(path: string): string { if (path === "") { return "__fish_use_subcommand"; } const parts = path.split(" "); // Build: __fish_seen_subcommand_from a; and __fish_seen_subcommand_from b; ... // Fish's __fish_seen_subcommand_from accepts a list of allowed words at a // given depth. For a 2-deep path "anthropic cost", the condition is: // __fish_seen_subcommand_from anthropic; and __fish_seen_subcommand_from cost const clauses = parts.map((p) => `__fish_seen_subcommand_from ${p}`); return clauses.join("; and "); } function valueAction( opt: { dynamicProvider?: string; valueChoices?: string[] }, binName: string, ): { flag: string; arg: string; } { // Fish flag semantics: // -r/--require-parameter : the flag requires an argument // -x/--exclusive : flag requires an argument AND no further completion after // -a '' : completions for the argument if (opt.dynamicProvider) { return { flag: "-r", arg: `-a '(${binName} __complete ${opt.dynamicProvider} -- (commandline -ct))'`, }; } if (opt.valueChoices && opt.valueChoices.length > 0) { return { flag: "-r", arg: `-a '${opt.valueChoices.map(fishEscape).join(" ")}'`, }; } return { flag: "-r", arg: "" }; } /** * DYNAMIC fish completion: a thin, tree-independent shim that calls * ` __complete-line` on every (see bash.ts generateBashDynamic for * the rationale). Fish renders `value\tdescription` lines natively, so the * helper just strips the trailing `\x1f:` directive line. Note: fish's * declarative `complete -a` cannot switch to file completion from inside the * helper, so the File directive is a no-op here — use static fish completion * (`completion fish`) if you need file fallback on a path-valued positional. */ export function generateFishDynamic(binName: string): string { const fn = `__${binName.replace(/[^a-zA-Z0-9_]/g, "_")}_complete`; return `# ${binName} fish completion (dynamic; install once, never stale). Do not edit. # Source via: ${binName} completion fish --dynamic | source function ${fn} set -l toks (commandline -opc) set -l cur (commandline -ct) ${binName} __complete-line (count $toks) -- $toks $cur 2>/dev/null | string match -rv '^\\x1f:' end complete -c ${binName} -f -a '(${fn})' `; } export function generateFish(root: CommandSpec, binName: string): string { const out: string[] = []; out.push( `# ${binName} fish completion, generated by \`${binName} completion fish\`. Do not edit.`, ); out.push(`# Source via: ${binName} completion fish | source`); out.push( `# Or install: ${binName} completion fish > ~/.config/fish/completions/${binName}.fish`, ); out.push(""); const walk = (s: CommandSpec): void => { const cond = pathCondition(s.path); // Subcommands at this depth for (const sub of s.subcommands) { const desc = truncate(sub.description, 80); out.push( `complete -c ${binName} -f -n '${cond}' -a '${fishEscape(sub.name)}' -d '${fishEscape(desc)}'`, ); } // Options for this command for (const o of s.options) { const long = o.long ? o.long.replace(/^--/, "") : ""; const short = o.short ? o.short.replace(/^-/, "") : ""; const desc = truncate(o.description, 80); let line = `complete -c ${binName} -n '${cond}'`; if (long) line += ` -l ${long}`; if (short) line += ` -s ${short}`; if (o.takesValue) { const v = valueAction(o, binName); line += ` ${v.flag}`; if (v.arg) line += ` ${v.arg}`; else line += " -f"; } else { line += " -f"; } line += ` -d '${fishEscape(desc)}'`; out.push(line); } // Positional values: fish doesn't natively handle Nth positional, but we // can suggest values when no subcommand follows. The condition becomes: // "in this path AND not seen another subcommand from this path's subs". if (s.positionals.length > 0) { const subNames = s.subcommands.map((c) => c.name).join(" "); const positionalCond = s.subcommands.length > 0 ? `${cond}; and not __fish_seen_subcommand_from ${subNames}` : cond; // We only render the first positional; fish has limited support for // distinguishing positional indices in this completion grammar. const p = s.positionals[0]; if (p) { const v = valueAction(p, binName); if (v.arg) { out.push(`complete -c ${binName} -f -n '${positionalCond}' ${v.arg}`); } } } for (const sub of s.subcommands) walk(sub); }; walk(root); return `${out.join("\n")}\n`; }