/** * Zsh completion generator. Emits a single `#compdef ` script that uses * `_arguments -C` for nested-subcommand parsing and supports descriptions * inline (the main zsh-over-bash win). The bin name is injected by the caller. * * Architecture mirrors bash.ts: * - Static tables for subcommands + options keyed by path * - ` __complete ` callback for dynamic values * - Driver function walks $words to determine the current command path */ import type { CommandSpec, OptionSpec, PositionalSpec } from "./walk.js"; /** Derive a valid shell function-name prefix from the bin name. */ function fnPrefix(binName: string): string { return `_${binName.replace(/[^a-zA-Z0-9_]/g, "_")}`; } function valueSpec(opt: { dynamicProvider?: string; valueChoices?: string[] }): string { if (opt.dynamicProvider) return `DYN:${opt.dynamicProvider}`; if (opt.valueChoices && opt.valueChoices.length > 0) { return `ENUM:${opt.valueChoices.join(" ")}`; } return ""; } /** Escape a string for single-quoted zsh string. Same rule as bash. */ function zshEscape(s: string): string { return s.replace(/'/g, `'\\''`); } function zshPath(path: string): string { return path === "" ? "ROOT" : path.replace(/ /g, "__"); } export function generateZsh(root: CommandSpec, binName: string): string { const fn = fnPrefix(binName); const subcommandsByPath = new Map>(); const optionsByPath = new Map(); const positionalsByPath = new Map(); const walk = (s: CommandSpec): void => { subcommandsByPath.set( s.path, s.subcommands .map((c) => ({ name: c.name, description: c.description })) .sort((a, b) => a.name.localeCompare(b.name)), ); optionsByPath.set(s.path, s.options); positionalsByPath.set(s.path, s.positionals); for (const sub of s.subcommands) walk(sub); }; walk(root); const out: string[] = []; out.push(`#compdef ${binName}`); out.push(`# ${binName} zsh completion, generated by \`${binName} completion zsh\`. Do not edit.`); out.push(`# Source via: eval "$(${binName} completion zsh)"`); out.push(""); out.push(`${fn}_subcommands_with_desc() {`); out.push(` case "$1" in`); for (const [path, subs] of [...subcommandsByPath.entries()].sort()) { if (subs.length === 0) continue; const lines = subs.map( (s) => `'${zshEscape(s.name)}:${zshEscape(truncate(s.description, 80))}'`, ); out.push(` ${zshPath(path)}) print -l ${lines.join(" ")} ;;`); } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_options_with_desc() {`); out.push(` case "$1" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { if (opts.length === 0) continue; const lines: string[] = []; for (const o of opts) { const flags = [o.long, o.short].filter((f) => f.length > 0); for (const flag of flags) { lines.push(`'${zshEscape(flag)}:${zshEscape(truncate(o.description, 80))}'`); } } out.push(` ${zshPath(path)}) print -l ${lines.join(" ")} ;;`); } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_option_takes_value() {`); out.push(` case "$1|$2" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { for (const o of opts) { if (!o.takesValue) continue; for (const flag of [o.long, o.short].filter((f) => f.length > 0)) { out.push(` ${zshPath(path)}\\|${zshEscape(flag)}) echo 1 ;;`); } } } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_option_value_spec() {`); out.push(` case "$1|$2" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { for (const o of opts) { if (!o.takesValue) continue; const spec = valueSpec(o); if (!spec) continue; for (const flag of [o.long, o.short].filter((f) => f.length > 0)) { out.push(` ${zshPath(path)}\\|${zshEscape(flag)}) echo '${zshEscape(spec)}' ;;`); } } } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_positional_value_spec() {`); out.push(` case "$1|$2" in`); for (const [path, positionals] of [...positionalsByPath.entries()].sort()) { positionals.forEach((p, i) => { const spec = valueSpec(p); if (!spec) return; out.push(` ${zshPath(path)}\\|${i}) echo '${zshEscape(spec)}' ;;`); }); } out.push(" esac"); out.push("}"); out.push(""); out.push(zshDriver(fn, binName)); out.push(""); out.push(`compdef ${fn} ${binName}`); return `${out.join("\n")}\n`; } function truncate(s: string, n: number): string { if (!s) return ""; return s.length > n ? `${s.slice(0, n - 1)}…` : s; } /** * DYNAMIC zsh completion: a thin, tree-independent shim that calls * ` __complete-line` on every (see bash.ts generateBashDynamic for * the rationale). Candidate lines are `value\tdescription`; the shim converts * the tab to `:` so `_describe` renders descriptions. The trailing `\x1f:` * line carries the directive (bit 0 = file fallback → `_files`). */ export function generateZshDynamic(binName: string): string { const fn = fnPrefix(binName); return `#compdef ${binName} # ${binName} zsh completion (dynamic; install once, never stale). Do not edit. ${fn}() { emulate -L zsh local line directive=0 local -a raw display raw=( "\${(@f)$(${binName} __complete-line $((CURRENT-1)) -- "\${words[@]}" 2>/dev/null)}" ) for line in $raw; do [ -z "$line" ] && continue if [[ "$line" == $'\\x1f:'* ]]; then directive="\${line#$'\\x1f:'}" else display+=( "\${line//$'\\t'/:}" ) fi done if (( directive & 1 )); then _files return fi _describe -t values 'completions' display } compdef ${fn} ${binName} `; } /** * The driver walks `$words` to determine the current command path, then * dispatches to subcommand / option / value completion. `fn` is the * function-name prefix; `binName` is the CLI name for the `__complete` callback. */ function zshDriver(fn: string, binName: string): string { return `${fn}() { local cur prev path_str path_parts local -a words words=( "\${(@)words}" ) local cword=$CURRENT cur="\${words[$cword]}" prev="\${words[$((cword-1))]}" # Walk words[2..cword-1] (zsh is 1-indexed; words[1] is the bin) to determine path. path_str="ROOT" local -a path_parts local i=2 while [ $i -lt $cword ]; do local w="\${words[$i]}" case "$w" in -*) if [ "$(${fn}_option_takes_value "$path_str" "$w")" = "1" ]; then i=$((i+1)) fi ;; *) path_parts+=("$w") local joined="\${(j:__:)path_parts}" local known known=$(${fn}_subcommands_with_desc "$path_str" | awk -F: '{print $1}' | tr -d "'") if [[ " $known " == *" $w "* ]]; then path_str="$joined" fi ;; esac i=$((i+1)) done # Case 1: prev is an option that takes a value. if [ -n "$prev" ] && [ "$(${fn}_option_takes_value "$path_str" "$prev")" = "1" ]; then local spec=$(${fn}_option_value_spec "$path_str" "$prev") ${fn}_emit_values "$spec" "$cur" return fi # Case 2: completing an option (cur starts with -). if [[ "$cur" == -* ]]; then local -a opt_descs opt_descs=( \${(f)"$(${fn}_options_with_desc "$path_str")"} ) if [ \${#opt_descs[@]} -gt 0 ]; then _describe 'options' opt_descs fi return fi # Case 3: subcommands at this level. local -a sub_descs sub_descs=( \${(f)"$(${fn}_subcommands_with_desc "$path_str")"} ) if [ \${#sub_descs[@]} -gt 0 ]; then _describe 'subcommands' sub_descs return fi # Case 4: positional value. Count non-option, non-subcommand words after path. local after_cmd=0 local seen_path="ROOT" local j=2 while [ $j -lt $cword ]; do local w="\${words[$j]}" case "$w" in -*) if [ "$(${fn}_option_takes_value "$seen_path" "$w")" = "1" ]; then j=$((j+1)) fi ;; *) local joined if [ "$seen_path" = "ROOT" ]; then joined="$w"; else joined="\${seen_path}__\${w}"; fi local known known=$(${fn}_subcommands_with_desc "$seen_path" | awk -F: '{print $1}' | tr -d "'") if [[ " $known " == *" $w "* ]]; then seen_path="$joined" else after_cmd=$((after_cmd+1)) fi ;; esac j=$((j+1)) done local pos_spec=$(${fn}_positional_value_spec "$path_str" "$after_cmd") if [ -n "$pos_spec" ]; then ${fn}_emit_values "$pos_spec" "$cur" return fi _files } ${fn}_emit_values() { local spec="$1" local cur="$2" if [[ "$spec" == DYN:* ]]; then local provider="\${spec#DYN:}" local -a values values=( \${(f)"$(${binName} __complete "$provider" -- "$cur" 2>/dev/null)"} ) if [ \${#values[@]} -gt 0 ]; then compadd -a values fi elif [[ "$spec" == ENUM:* ]]; then local choices="\${spec#ENUM:}" local -a values values=( \${(s: :)choices} ) compadd -a values fi }`; }