diff --git a/README.md b/README.md index 559e8845..abb96f1e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+
# CodeGraph @@ -326,6 +326,7 @@ codegraph files [path] # Show file structure (--format, --filter, --m codegraph context # Build context for AI (--format, --max-nodes) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server +codegraph completions # Generate shell completions for zsh/bash/fish/powershell (see below) ``` ### `codegraph affected` @@ -356,6 +357,48 @@ if [ -n "$AFFECTED" ]; then fi ``` +### `codegraph completions` + +Generate a static completion script for your shell. Supported shells: `zsh`, `bash`, `fish`, `powershell` (aliases: `pwsh`, `ps`). + +```bash +codegraph completions zsh --install # auto-detects best path +codegraph completions bash --install +codegraph completions fish --install +codegraph completions powershell --install +``` + +With `--install`, codegraph picks the right location for your environment and writes there. The exact path is reported as `(detected: )` so you can see which rule fired. **Restart your shell** (or `exec $SHELL`) to pick up the new completions. + +| Shell | Detection priority | +|---|---| +| **zsh** | oh-my-zsh (`$ZSH/completions/`) → `/share/zsh/site-functions/` if writable → `~/.zsh/completions/` (fallback; prints `fpath` hint) | +| **bash** | `/etc/bash_completion.d/` if writable → XDG `~/.local/share/bash-completion/completions/` | +| **fish** | `~/.config/fish/completions/` (auto-discovered) | +| **powershell** | Standalone `.ps1` in `~/.config/powershell/` (`~/Documents/PowerShell/` on Windows) + idempotent dot-source line in `$PROFILE`. Re-running `--install` won't duplicate the line. | + +**Without `--install`** the script goes to stdout — pipe it wherever you want: + +```bash +codegraph completions zsh > ~/.zsh/completions/_codegraph +codegraph completions bash > ~/.local/share/bash-completion/completions/codegraph +codegraph completions fish > ~/.config/fish/completions/codegraph.fish +codegraph completions powershell > ~/codegraph-completion.ps1 # then dot-source from $PROFILE +``` + +**Per-shell requirements:** +- **bash**: needs the `bash-completion` package. macOS: `brew install bash-completion@2` (and follow its post-install instructions). +- **zsh** (fallback tier only): if codegraph wrote to `~/.zsh/completions/_codegraph`, add this to `~/.zshrc` *before* `compinit`: + ```zsh + fpath=(~/.zsh/completions $fpath) + autoload -Uz compinit && compinit + ``` + If the install reported `(detected: oh-my-zsh)` or `(detected: zsh-site-functions)`, this step is unnecessary. +- **fish**: no extra config — auto-discovered. +- **powershell**: works in PowerShell 7.x (`pwsh`). Windows PowerShell 5.1 also works but uses a different `$PROFILE` path; if our chosen path doesn't match yours, dot-source the `.ps1` manually. + +If you're on a shell we don't recognize (`nushell`, `xonsh`, etc.), the command exits non-zero with a hint and writes nothing. + --- ## MCP Tools diff --git a/__tests__/completions.test.ts b/__tests__/completions.test.ts new file mode 100644 index 00000000..87148354 --- /dev/null +++ b/__tests__/completions.test.ts @@ -0,0 +1,411 @@ +/** + * Completion-emitter tests + * + * Builds a small commander program with every option/argument shape + * the emitters need to handle, then asserts each shell's output + * contains the expected lines. We don't snapshot the full script — + * that would break every time someone edits a description — but we do + * pin the structural pieces (function names, value hints, alias dispatch). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + emit, + parseShell, + SUPPORTED_SHELLS, + detectInstallTarget, +} from '../src/completions'; + +const buildProgram = (): Command => { + const program = new Command(); + program.name('codegraph').description('test program').version('0.0.0'); + + program + .command('init [path]') + .description('Initialize CodeGraph') + .option('-i, --index', 'Run initial indexing') + .option('-v, --verbose', 'Verbose output') + .action(() => {}); + + program + .command('query ') + .description('Search for symbols') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '10') + .option('-j, --json', 'Output as JSON') + .action(() => {}); + + program + .command('affected [files...]') + .description('Find affected tests') + .alias('a') + .option('--stdin', 'Read from stdin') + .action(() => {}); + + return program; +}; + +describe('completions/parseShell', () => { + it('accepts supported shells case-insensitively', () => { + for (const s of SUPPORTED_SHELLS) { + expect(parseShell(s)).toBe(s); + expect(parseShell(s.toUpperCase())).toBe(s); + } + }); + + it('resolves common powershell aliases', () => { + expect(parseShell('pwsh')).toBe('powershell'); + expect(parseShell('PS')).toBe('powershell'); + expect(parseShell('ps1')).toBe('powershell'); + }); + + it('rejects unknown shells', () => { + expect(parseShell('nushell')).toBeNull(); + expect(parseShell('')).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// detectInstallTarget — exercises each tier by manipulating env + tmp +// dirs so we don't depend on whether the test machine has oh-my-zsh, +// Homebrew, etc. Each test owns its own tmp tree to avoid cross-talk. +// ───────────────────────────────────────────────────────────────────── + +describe('completions/detectInstallTarget', () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-install-')); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe('zsh', () => { + it('tier 1: picks oh-my-zsh when $ZSH points to a writable dir', () => { + const zshDir = path.join(tmpHome, '.oh-my-zsh'); + fs.mkdirSync(zshDir, { recursive: true }); + const target = detectInstallTarget('zsh', { ZSH: zshDir }, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('oh-my-zsh'); + expect(target!.path).toBe(path.join(zshDir, 'completions', '_codegraph')); + expect(target!.postInstallHint).toBeUndefined(); + }); + + it('tier 3: falls back to ~/.zsh/completions with fpath hint when no signals', () => { + // Empty env (no ZSH), and we pass a tmpHome that doesn't have + // /opt/homebrew or /usr/local — but `detectZsh` checks real + // filesystem paths for Homebrew, not under tmpHome. To make the + // test deterministic regardless of host, we accept that + // Homebrew tier may pick up if the test machine has it. The + // assertion below holds either way: when no oh-my-zsh signal is + // present, source is one of homebrew-zsh / zsh-fallback. + const target = detectInstallTarget('zsh', {}, tmpHome); + expect(target).not.toBeNull(); + expect(['zsh-site-functions', 'zsh-fallback']).toContain(target!.source); + if (target!.source === 'zsh-fallback') { + expect(target!.path).toBe( + path.join(tmpHome, '.zsh', 'completions', '_codegraph'), + ); + expect(target!.postInstallHint).toMatch(/fpath/); + } + }); + }); + + describe('bash', () => { + it('falls back to XDG bash-completion path under HOME when no Homebrew', () => { + // Same caveat as zsh: if test machine has /opt/homebrew/etc/ + // bash_completion.d writable, that tier wins. Otherwise XDG. + const target = detectInstallTarget('bash', {}, tmpHome); + expect(target).not.toBeNull(); + expect(['homebrew-bash-completion', 'xdg-bash-completion']).toContain( + target!.source, + ); + if (target!.source === 'xdg-bash-completion') { + expect(target!.path).toBe( + path.join( + tmpHome, + '.local', + 'share', + 'bash-completion', + 'completions', + 'codegraph', + ), + ); + } + }); + + it('honors $XDG_DATA_HOME override', () => { + const xdg = path.join(tmpHome, 'custom-xdg'); + fs.mkdirSync(xdg, { recursive: true }); + const target = detectInstallTarget('bash', { XDG_DATA_HOME: xdg }, tmpHome); + // Only assert XDG-tier behavior; Homebrew tier (if it wins on + // the host) doesn't read XDG_DATA_HOME so this check still + // exercises the XDG branch on most dev machines. + if (target?.source === 'xdg-bash-completion') { + expect(target.path).toBe( + path.join(xdg, 'bash-completion', 'completions', 'codegraph'), + ); + } + }); + }); + + describe('fish', () => { + it('always returns ~/.config/fish/completions/codegraph.fish', () => { + const target = detectInstallTarget('fish', {}, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('fish-config'); + expect(target!.path).toBe( + path.join(tmpHome, '.config', 'fish', 'completions', 'codegraph.fish'), + ); + }); + }); + + describe('powershell', () => { + it('returns a standalone .ps1 path + a profile path + a dot-source line', () => { + const target = detectInstallTarget('powershell', {}, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('pwsh-profile-dir'); + // Linux/macOS test runner — Windows branch tested in CI on win. + expect(target!.path).toContain(path.join('.config', 'powershell')); + expect(target!.path).toMatch(/codegraph\.ps1$/); + expect(target!.profilePath).toMatch(/Microsoft\.PowerShell_profile\.ps1$/); + expect(target!.profileLine).toMatch(/^\. '.*codegraph\.ps1'/); + expect(target!.profileLine).toContain('# codegraph completions'); + }); + }); +}); + +describe('completions/zsh', () => { + const out = emit(buildProgram(), 'zsh'); + + it('starts with #compdef directive', () => { + expect(out.startsWith('#compdef codegraph\n')).toBe(true); + }); + + it('emits a per-subcommand function for each command', () => { + expect(out).toContain('_codegraph_init()'); + expect(out).toContain('_codegraph_query()'); + expect(out).toContain('_codegraph_affected()'); + }); + + it('emits paired short/long option specs with descriptions', () => { + expect(out).toContain("'(-i --index)'{-i,--index}'[Run initial indexing]'"); + }); + + it('emits value hints for options with -style values', () => { + // -p/--path takes a value; valueName is "path" which triggers _files hint. + expect(out).toContain(':path:_files'); + }); + + it('routes aliases to the same function as the canonical name', () => { + // `affected` has alias `a` — both should dispatch to _codegraph_affected. + expect(out).toMatch(/affected\|a\) _codegraph_affected/); + }); + + it('treats variadic positional as *', () => { + expect(out).toContain("'*:files:"); + }); +}); + +describe('completions/bash', () => { + const out = emit(buildProgram(), 'bash'); + + it('defines and registers _codegraph', () => { + expect(out).toContain('_codegraph() {'); + expect(out).toContain('complete -F _codegraph codegraph'); + }); + + it('lists all subcommands (canonical + alias) for top-level completion', () => { + expect(out).toMatch(/init uninit|init query affected a/); + // Loose check: every canonical name + the alias should appear in the + // subcommand word list. + for (const name of ['init', 'query', 'affected', 'a']) { + expect(out).toContain(name); + } + }); + + it('case-matches alias to the same arm', () => { + expect(out).toMatch(/affected\|a\)/); + }); + + it('triggers file completion after an option whose value is path-like', () => { + expect(out).toMatch(/-p\|--path\)\s*\n\s*COMPREPLY=\( \$\(compgen -f --/); + }); +}); + +describe('completions/fish', () => { + const out = emit(buildProgram(), 'fish'); + + it('starts with a comment header', () => { + expect(out.startsWith('# Fish completion for codegraph.')).toBe(true); + }); + + it('emits a __fish_use_subcommand line per subcommand (canonical + alias)', () => { + expect(out).toContain("-a 'init'"); + expect(out).toContain("-a 'affected'"); + expect(out).toContain("-a 'a'"); // alias + }); + + it('flags options that take a value with -r and value hints', () => { + // --path uses -F (file hint) because valueName "path" is in the file set. + expect(out).toContain('-l path -r -F'); + // --limit takes a value but valueName is "number" -> -x (no file hint). + expect(out).toContain('-l limit -r -x'); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Help/version coverage — guards the introspect layer's behavior of +// injecting --help/-h on every command (commander doesn't expose them +// via .options) and only emitting --version once (commander's +// .version() puts -V/--version on .options exactly once). +// ───────────────────────────────────────────────────────────────────── + +describe('completions/help-version', () => { + // Count occurrences of an exact-match flag token in the emitter output. + // Looks for `--flag` either standalone, in option specs ('--flag'), + // or in CompletionResult literals — each emitter has its own format + // so we count distinct flag mentions rather than line counts. + const countFlag = (out: string, flag: string): number => { + // Match the flag bounded by non-flag characters or end-of-line. + // Excludes prefix-overlaps (e.g. --version vs --version-foo). + const re = new RegExp(`(? { + const out = emit(buildProgram(), 'zsh'); + // Zsh paired-spec emits --version twice in the `'(-V --version)'{-V,--version}'[…]'` + // syntax; that's intentional (it's one option spec). Hardcoded + // duplicate would have produced two separate _arguments lines. + expect(out).toMatch(/'\(-V --version\)'\{-V,--version\}/); + // Should be exactly one matching pair, not two. + expect((out.match(/'\(-V --version\)'/g) ?? []).length).toBe(1); + }); + + it('bash: --version appears once in the rootFlags list', () => { + const out = emit(buildProgram(), 'bash'); + // The root-flag list is a single compgen -W "..." string; --version + // should appear once. Duplicates would show as two tokens. + const rootFlagsLine = out.match(/compgen -W "([^"]*)" -- "\$cur"/)?.[1] ?? ''; + expect(rootFlagsLine.split(/\s+/).filter((f) => f === '--version').length).toBe(1); + }); + + it('fish: --version appears once (as -l version spec, not literal)', () => { + // Fish encodes flags via `-s X -l XX` rather than emitting the + // literal `--X`. One occurrence of `-l version` per command path. + const out = emit(buildProgram(), 'fish'); + expect((out.match(/-l version\b/g) ?? []).length).toBe(1); + }); + + it('powershell: --version appears once in root switch arm', () => { + const out = emit(buildProgram(), 'powershell'); + // PS emits each CompletionResult as `[CompletionResult]::new('--version', '--version', …)` + // — two text occurrences per emission. One emission = 2 matches. + expect(countFlag(out, '--version')).toBe(2); + }); + + it('zsh: --help spec appears on root + every subcommand', () => { + // Zsh emits `'(-h --help)'{-h,--help}'[…]'` per command via _arguments. + // Test program: root + 3 subs = 4 total. + const out = emit(buildProgram(), 'zsh'); + expect((out.match(/'\(-h --help\)'\{-h,--help\}/g) ?? []).length).toBe(4); + }); + + it('bash: --help appears in every flag-completion compgen list', () => { + // Bash emits `compgen -W ""` lists for both flag-completion + // (where words look like `-x --xx -h --help`) and subcommand-name + // completion (`init query affected a`). Only the flag lists need + // --help; capture and filter on the word-list content (a flag list + // is one where the first word starts with `-`). + const out = emit(buildProgram(), 'bash'); + const wordLists: string[] = []; + const re = /compgen -W "([^"]*)" -- "\$cur"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(out)) !== null) wordLists.push(m[1]); + const flagLists = wordLists.filter((w) => w.trim().startsWith('-')); + expect(flagLists.length).toBeGreaterThanOrEqual(4); // root flags + 3 subs + for (const list of flagLists) { + expect(list.split(/\s+/)).toContain('--help'); + } + }); + + it('fish: -l help appears on root + every subcommand condition block', () => { + // Fish emits one `complete -c codegraph -n '' -s h -l help …` + // line per command path. Conditions can be `__fish_use_subcommand` + // (root) or `__fish_seen_subcommand_from [ …]` + // (per-sub, possibly with aliases joined by spaces). + const out = emit(buildProgram(), 'fish'); + expect((out.match(/-s h -l help\b/g) ?? []).length).toBeGreaterThanOrEqual(4); + }); + + it('powershell: --help/-h appear in every switch arm', () => { + const out = emit(buildProgram(), 'powershell'); + // Root arm + per-sub arms + per-alias arms. Each gets help injected. + const arms = out.match(/'codegraph(;[^']+)?' \{[\s\S]*?break\n \}/g) ?? []; + expect(arms.length).toBeGreaterThanOrEqual(4); + for (const arm of arms) { + expect(arm).toContain("'--help'"); + expect(arm).toContain("'-h'"); + } + }); +}); + +describe('completions/powershell', () => { + const out = emit(buildProgram(), 'powershell'); + + it('opens with using-namespace declarations', () => { + expect(out).toContain('using namespace System.Management.Automation'); + expect(out).toContain('using namespace System.Management.Automation.Language'); + }); + + it('registers a Native completer for codegraph', () => { + expect(out).toContain( + "Register-ArgumentCompleter -Native -CommandName 'codegraph'", + ); + }); + + it('builds command path by joining elements with semicolons', () => { + expect(out).toContain("-join ';'"); + }); + + it('emits a switch arm for each canonical subcommand', () => { + expect(out).toContain("'codegraph;init' {"); + expect(out).toContain("'codegraph;query' {"); + expect(out).toContain("'codegraph;affected' {"); + }); + + it('emits a switch arm for each alias surface (so `a` works like `affected`)', () => { + expect(out).toContain("'codegraph;a' {"); + }); + + it('emits CompletionResult entries with ParameterName for flags', () => { + expect(out).toMatch( + /\[CompletionResult\]::new\('--index', '--index', \[CompletionResultType\]::ParameterName/, + ); + }); + + it('emits CompletionResult entries with ParameterValue for subcommands', () => { + expect(out).toMatch( + /\[CompletionResult\]::new\('init', 'init', \[CompletionResultType\]::ParameterValue/, + ); + }); + + it('filters by $wordToComplete prefix at the end', () => { + expect(out).toContain('$_.CompletionText -like "$wordToComplete*"'); + }); + + it("escapes single quotes in descriptions (PS '' escape rule)", () => { + const prog = new Command(); + prog.name('foo').version('0'); + prog.command('weird').description("it's tricky").action(() => {}); + const psOut = emit(prog, 'powershell'); + expect(psOut).toContain("it''s tricky"); + }); +}); diff --git a/docker/smoke-completions/Dockerfile b/docker/smoke-completions/Dockerfile new file mode 100644 index 00000000..a9bc8079 --- /dev/null +++ b/docker/smoke-completions/Dockerfile @@ -0,0 +1,43 @@ +# Smoke-test image for `codegraph completions` output. Runs the actual +# generated zsh/bash/fish/powershell scripts under their real shells +# against pinned versions. Image is intentionally minimal — no extras +# the test doesn't need. See README.md for what's verified and what isn't. +FROM node:22-bookworm-slim + +# pwsh isn't in Microsoft's Debian apt repo for arm64, but the GitHub +# release tarballs are multi-arch. Fetch the right one based on the +# image's architecture so the smoke runs natively on Apple Silicon and +# x86_64 alike. Pinned to a specific PS version for build determinism. +ARG POWERSHELL_VERSION=7.6.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + zsh \ + bash \ + bash-completion \ + fish \ + ca-certificates \ + curl \ + libicu72 \ + && arch=$(dpkg --print-architecture) \ + && case "$arch" in \ + amd64) ps_arch=x64 ;; \ + arm64) ps_arch=arm64 ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac \ + && mkdir -p /opt/microsoft/powershell/7 \ + && curl -fsSL -o /tmp/pwsh.tgz \ + "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-${ps_arch}.tar.gz" \ + && tar -xzf /tmp/pwsh.tgz -C /opt/microsoft/powershell/7 \ + && chmod +x /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \ + && rm /tmp/pwsh.tgz \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY run.sh test-bash.sh test-fish.sh test-zsh.sh test-powershell.sh test-zsh-ohmyzsh.sh /smoke/ +RUN chmod +x /smoke/*.sh + +WORKDIR /smoke +ENTRYPOINT ["/smoke/run.sh"] diff --git a/docker/smoke-completions/README.md b/docker/smoke-completions/README.md new file mode 100644 index 00000000..547ba04d --- /dev/null +++ b/docker/smoke-completions/README.md @@ -0,0 +1,102 @@ +# Smoke test: `codegraph completions` + +End-to-end verification that `codegraph completions ` produces a +script that actually works when installed into a real shell. Runs in a +pinned Docker image so the result doesn't depend on the developer's +local zsh/bash/fish/pwsh versions. + +## Run + +```bash +npm run smoke:completions +``` + +Expected final line on success: `smoke: zsh bash fish powershell OK`. + +## What this proves + +- `npm pack` artifact installs cleanly via `npm install -g`. +- `codegraph completions --install` writes to the correct + detected path for each of zsh / bash / fish / powershell, and the + resulting file is non-empty. +- **Auto-detection (zsh tier 1)**: with `$ZSH` set to an oh-my-zsh + directory, the installer picks `$ZSH/completions/_codegraph` instead + of the `~/.zsh/completions` fallback. The installer messages report + `(detected: oh-my-zsh)`. +- **PowerShell idempotency**: re-running `--install` for powershell + does NOT duplicate the dot-source line in `$PROFILE`. +- **bash**: the installed completion, sourced via `bash-completion`, + registers via `complete -F` and produces the expected COMPREPLY for + top-level commands, subcommand flags, options with `` values, + and the global `--help` / `--version`. +- **fish**: `complete -C "codegraph …"` returns the expected suggestions + for top-level commands, subcommand flags, and file-hint options. +- **zsh**: the script parses (`zsh -n`), is registered as an + autoloadable completion by `compinit`, and a representative + cross-section of per-subcommand helper functions is defined after + autoload (proves the file body ran to completion — if any helper is + missing, the script crashed mid-way). +- **powershell**: dot-sourced in a `pwsh -NoProfile` session, `TabExpansion2` + returns the expected `CompletionMatches` for top-level subcommands, + global flags, and subcommand flag completion. + +## What this does NOT prove + +zsh completion output is **not** content-verified. The actual +suggestions zsh produces require `_main_complete` running under a real +ZLE widget context, which scripts cannot manufacture — `_values` and +`_arguments` short-circuit on missing `$compstate`/`$state` setup, so +even a `compadd` shim captures nothing. The only way to drive the full +path is PTY + `expect`, which is too flaky across zsh 5.7/5.8/5.9 and +across `TERM`/locale/`zle` configurations to belong in CI. + +Concretely, a subtle bug inside an `_arguments` spec (e.g., malformed +quoting that breaks one specific state) would pass our structural +checks and still misbehave for users. Industry consensus on this +exact tradeoff: clap_complete, oclif, click, and Commander.js itself +all ship structural-only zsh tests. We match that bar. + +If you want stronger zsh coverage in the future, the realistic path +is `zpty`-based testing (what Fig uses) — that runs a real interactive +zsh under a pseudo-terminal and pipes keystrokes. It's a separate +engineering effort from this smoke harness. + +## What's tested + +| Shell | Mechanism | Strength | +|---|---|---| +| bash | Source script, set `COMP_*`, call `_codegraph`, assert `COMPREPLY` | Full content | +| fish | `complete -C "codegraph …"` stdout assertions | Full content | +| zsh | Syntax + `compinit` load + cross-section of helpers defined | Structural | +| powershell | Dot-source in `pwsh -NoProfile`, drive `TabExpansion2`, assert `CompletionMatches` | Full content | + +## Architecture + +``` +host: container: + ┌────────────────────────────────┐ +npm run build │ node:22 + zsh + bash + fish │ +npm pack ───── /pkg.tgz ──────────▶ │ npm install -g /pkg.tgz │ +docker run │ codegraph completions … --install │ + │ /smoke/test-{bash,fish,zsh}.sh │ + └────────────────────────────────┘ +``` + +The image is bind-mounted **only** with the tarball — test scripts +are baked in at build time so the image is self-contained. The host +wrapper at `run-host.sh` does build, pack, image-build, and run. + +## Why Docker (not just a local script) + +- Pins shell versions (Debian Bookworm: zsh 5.9, bash 5.2, fish 3.6) so + results are deterministic across developer machines and CI. +- Avoids polluting the developer's shell config with test completions. +- Sidesteps macOS-specific bash 3.2 quirks (the bundled bash on macOS + is too old for the `_init_completion` helper). + +## Isolation from the main test suite + +The smoke test is **not** wired into `npm test` or vitest. It has its +own opt-in npm script (`smoke:completions`) and lives entirely under +`docker/`, which is excluded from `npm pack` by the `files:` allowlist +in `package.json`. The existing vitest suite is untouched. diff --git a/docker/smoke-completions/run-host.sh b/docker/smoke-completions/run-host.sh new file mode 100755 index 00000000..0bf0a2a6 --- /dev/null +++ b/docker/smoke-completions/run-host.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Host wrapper for the completion smoke test. Builds, packs, runs the +# image. Idempotent — cleans up the tarball on exit. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +echo "smoke: building dist/" +npm run build >/dev/null + +echo "smoke: packing" +PKG=$(npm pack --silent) +trap 'rm -f "$PKG"' EXIT + +echo "smoke: building image" +docker build --quiet -t codegraph-smoke docker/smoke-completions >/dev/null + +echo "smoke: running container" +docker run --rm \ + -v "$PWD/$PKG:/pkg.tgz:ro" \ + codegraph-smoke diff --git a/docker/smoke-completions/run.sh b/docker/smoke-completions/run.sh new file mode 100755 index 00000000..9904558b --- /dev/null +++ b/docker/smoke-completions/run.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Container entrypoint. Installs the codegraph tarball mounted at +# /pkg.tgz, exercises `codegraph completions --install` for +# each shell, then runs the per-shell assertions against the path the +# installer actually wrote to (varies by tier — see install.ts). +set -euo pipefail + +if [[ ! -f /pkg.tgz ]]; then + echo "smoke: /pkg.tgz not found. Mount the npm-pack tarball: docker run -v \$PWD/pkg.tgz:/pkg.tgz:ro …" >&2 + exit 2 +fi + +echo "smoke: installing codegraph from /pkg.tgz" +npm install -g /pkg.tgz >/dev/null + +# Capture the absolute install path from each installer run. The line +# we want is "✓ Installed completions to ". Strip ANSI +# color so the parse is stable across terminal types. +strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; } + +install_and_capture() { + local shell=$1 + local out + out=$(codegraph completions "$shell" --install 2>&1 | strip_ansi) + # Mirror the installer's output to stderr so it's visible in logs + # without polluting stdout, which the caller captures into a variable. + echo "$out" >&2 + local p + p=$(echo "$out" | grep -E "Installed .* completions to " | sed 's/.* to //' | head -1) + if [[ -z "$p" || ! -s "$p" ]]; then + echo "smoke: FAIL — couldn't determine install path or file empty for $shell" >&2 + exit 1 + fi + printf '%s' "$p" +} + +echo "smoke: installing completions (auto-detected tier per shell)" +ZSH_PATH=$(install_and_capture zsh) +BASH_PATH=$(install_and_capture bash) +FISH_PATH=$(install_and_capture fish) +PS_PATH=$(install_and_capture powershell) +export ZSH_PATH BASH_PATH FISH_PATH PS_PATH + +# The PowerShell profile must contain our dot-source line. +PS_PROFILE="$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" +if ! grep -q "codegraph completions" "$PS_PROFILE"; then + echo "smoke: FAIL — $PS_PROFILE missing the dot-source line" >&2 + exit 1 +fi + +echo "smoke: running bash assertions" +/smoke/test-bash.sh + +echo "smoke: running fish assertions" +/smoke/test-fish.sh + +echo "smoke: running zsh assertions" +/smoke/test-zsh.sh + +echo "smoke: running powershell assertions" +/smoke/test-powershell.sh + +# Tier 1 zsh detection (oh-my-zsh): create a fake $ZSH dir, re-install, +# assert the file lands in $ZSH/completions instead of wherever the +# default install went. +echo "smoke: running zsh oh-my-zsh tier check" +/smoke/test-zsh-ohmyzsh.sh + +# Idempotency: re-running --install for powershell must NOT duplicate +# the dot-source line. +echo "smoke: powershell install idempotency check" +before=$(grep -c "codegraph completions" "$PS_PROFILE" || echo 0) +codegraph completions powershell --install >/dev/null +after=$(grep -c "codegraph completions" "$PS_PROFILE" || echo 0) +if [[ "$before" != "$after" ]]; then + echo "smoke: FAIL — powershell --install duplicated profile line ($before → $after)" >&2 + exit 1 +fi + +# Graceful no-op for unknown shells. +echo "smoke: unsupported shell check" +if codegraph completions nushell 2>/tmp/nu.err; then + echo "smoke: FAIL — unknown shell should error out, exited 0" >&2 + exit 1 +fi +if ! grep -q "Unsupported shell" /tmp/nu.err; then + echo "smoke: FAIL — unknown shell error doesn't mention 'Unsupported shell'" >&2 + cat /tmp/nu.err >&2 + exit 1 +fi + +echo "smoke: zsh bash fish powershell OK" diff --git a/docker/smoke-completions/test-bash.sh b/docker/smoke-completions/test-bash.sh new file mode 100755 index 00000000..c58031a8 --- /dev/null +++ b/docker/smoke-completions/test-bash.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Bash completion smoke. Sources the installed script, sets the +# COMP_* env that bash-completion populates during a real Tab, calls +# `_codegraph`, and asserts COMPREPLY contents. This is the standard +# non-PTY pattern (used by oclif, click, gh's own completion tests). +set -euo pipefail + +source /usr/share/bash-completion/bash_completion +# $BASH_PATH is exported by run.sh — wherever the installer wrote the script. +source "${BASH_PATH:-$HOME/.local/share/bash-completion/completions/codegraph}" + +declare -i fail=0 + +# 0. Registration: sourcing must register the completion for the +# `codegraph` command via `complete -F`. Without this, Tab does +# nothing — even if the function below works when called directly. +if ! complete -p codegraph 2>/dev/null | grep -q '_codegraph'; then + echo "FAIL [bash:registration]: sourcing didn't register a completion for 'codegraph'" >&2 + echo " complete -p codegraph: $(complete -p codegraph 2>&1 || echo 'unregistered')" >&2 + fail=1 +fi + +# Drive a single completion attempt: pre-populates the COMP_* vars +# from a command line + cursor position, calls `_codegraph`, returns +# the joined COMPREPLY for the caller to assert against. +complete_line() { + local line=$1 + local point=${2:-${#line}} + local cur prev + COMP_LINE=$line + COMP_POINT=$point + # Tokenize the line; cursor word is the last token (or empty if line ends in space). + read -ra COMP_WORDS <<<"$line" + if [[ "${line: -1}" == " " ]]; then + COMP_WORDS+=("") + fi + COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )) + COMPREPLY=() + _codegraph + printf '%s\n' "${COMPREPLY[@]:-}" +} + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + if ! grep -qx -- "$needle" <<<"$haystack"; then + echo "FAIL [bash:$label]: expected '$needle' in completions, got:" >&2 + echo "$haystack" | sed 's/^/ /' >&2 + fail=1 + fi +} + +assert_nonempty() { + local label=$1 haystack=$2 + if [[ -z "${haystack//[[:space:]]/}" ]]; then + echo "FAIL [bash:$label]: expected non-empty completions, got nothing" >&2 + fail=1 + fi +} + +# 1. Top-level subcommand list. +out=$(complete_line "codegraph ") +assert_contains "top-level/init" "init" "$out" +assert_contains "top-level/query" "query" "$out" +assert_contains "top-level/completions" "completions" "$out" + +# 2. Subcommand flag completion (-i, --index for `init`). +out=$(complete_line "codegraph init -") +assert_contains "init/-i" "-i" "$out" +assert_contains "init/--index" "--index" "$out" + +# 3. Value-hint: --path expects a file. With no cwd files we should +# still get *some* completion candidates (compgen -f lists cwd). +cd /tmp # ensure compgen has something to enumerate +touch /tmp/.smoke-sentinel +out=$(complete_line "codegraph query --path ") +assert_nonempty "query/--path" "$out" +rm -f /tmp/.smoke-sentinel + +# 4. Help/version are recognized at top level. +out=$(complete_line "codegraph -") +assert_contains "top-level/--help" "--help" "$out" +assert_contains "top-level/--version" "--version" "$out" + +# 5. --version must appear exactly once — regression guard for the +# pre-cb2389e bug where commander's auto-registered --version +# collided with a hardcoded --version in the emitter. +ver_count=$(grep -cx -- "--version" <<<"$out" || true) +if [[ "$ver_count" != "1" ]]; then + echo "FAIL [bash:--version-dedupe]: expected --version exactly once, got $ver_count" >&2 + fail=1 +fi + +# 6. Subcommand --help / -h. Commander doesn't surface help in +# cmd.options; the introspect layer injects it. Regression guard +# so a future refactor doesn't drop it. +out=$(complete_line "codegraph init -") +assert_contains "init/--help" "--help" "$out" +assert_contains "init/-h" "-h" "$out" + +exit $fail diff --git a/docker/smoke-completions/test-fish.sh b/docker/smoke-completions/test-fish.sh new file mode 100755 index 00000000..0280232f --- /dev/null +++ b/docker/smoke-completions/test-fish.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Fish completion smoke. `complete -C ""` returns suggestions on +# stdout, one per line, tab-separating the candidate from its description. +# Fish auto-loads anything in ~/.config/fish/completions/ so no source needed. +set -euo pipefail + +declare -i fail=0 + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + # Each fish completion line is "namedescription"; match the name column. + if ! awk -F '\t' -v n="$needle" '$1 == n {found=1} END {exit !found}' <<<"$haystack"; then + echo "FAIL [fish:$label]: expected '$needle' in completions, got:" >&2 + echo "$haystack" | sed 's/^/ /' >&2 + fail=1 + fi +} + +assert_nonempty() { + local label=$1 haystack=$2 + if [[ -z "${haystack//[[:space:]]/}" ]]; then + echo "FAIL [fish:$label]: expected non-empty completions, got nothing" >&2 + fail=1 + fi +} + +# 1. Top-level subcommand list. +out=$(fish -c 'complete -C "codegraph "') +assert_contains "top-level/init" "init" "$out" +assert_contains "top-level/query" "query" "$out" +assert_contains "top-level/completions" "completions" "$out" + +# 2. Subcommand flag completion — both per-sub options AND the +# --help/-h injected by the introspect layer. +out=$(fish -c 'complete -C "codegraph init -"') +assert_contains "init/--index" "--index" "$out" +assert_contains "init/--help" "--help" "$out" +assert_contains "init/-h" "-h" "$out" + +# 3. Value-hint: --path should trigger file completion. With files in +# cwd, fish lists them. +out=$(fish -c 'cd /tmp; touch .smoke-sentinel; complete -C "codegraph query --path "; rm -f .smoke-sentinel') +assert_nonempty "query/--path" "$out" + +exit $fail diff --git a/docker/smoke-completions/test-powershell.sh b/docker/smoke-completions/test-powershell.sh new file mode 100755 index 00000000..7cd7fe50 --- /dev/null +++ b/docker/smoke-completions/test-powershell.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# PowerShell completion smoke. Dot-sources the installed script in a +# pwsh subshell and drives `TabExpansion2` (the non-interactive +# equivalent of `complete -C` for fish). Asserts completion content. +# +# We write the pwsh script to a temp file and pass it via `-File` +# instead of inlining via `-Command` — quoting pwsh inside a bash +# single-quoted string is unmanageable (the dollar signs needed for +# $env:VAR collide with bash's expansion rules and break silently). +set -euo pipefail + +PS_PATH="${PS_PATH:-$HOME/.config/powershell/codegraph.ps1}" +export PS_PATH + +tmp=$(mktemp /tmp/codegraph-smoke-XXXXXX.ps1) +trap 'rm -f "$tmp"' EXIT + +cat > "$tmp" <<'PSSCRIPT' +$ErrorActionPreference = "Stop" +. $env:PS_PATH + +$script:fail = $false + +function Assert-Contains { + param([string]$Label, [string]$Needle, [string[]]$Haystack) + if ($Haystack -notcontains $Needle) { + Write-Host "FAIL [powershell:$Label]: missing '$Needle' in [$($Haystack -join ', ')]" + $script:fail = $true + } +} + +function Assert-NonEmpty { + param([string]$Label, [object]$Result) + if ($Result.CompletionMatches.Count -eq 0) { + Write-Host "FAIL [powershell:$Label]: expected non-empty completions" + $script:fail = $true + } +} + +# 1. Top-level subcommand list. +$r = TabExpansion2 -inputScript "codegraph " -cursorColumn ("codegraph ".Length) +Assert-NonEmpty "top-level" $r +$names = $r.CompletionMatches.CompletionText +Assert-Contains "top-level/init" "init" $names +Assert-Contains "top-level/query" "query" $names +Assert-Contains "top-level/completions" "completions" $names + +# 2. Top-level flags surface from the root switch arm. +Assert-Contains "top-level/--help" "--help" $names +Assert-Contains "top-level/-h" "-h" $names +Assert-Contains "top-level/--version" "--version" $names +Assert-Contains "top-level/-V" "-V" $names + +# 2a. --version exactly once — regression guard for the pre-cb2389e +# duplicate (commander's auto --version + a hardcoded one). +$verCount = ($names | Where-Object { $_ -eq "--version" }).Count +if ($verCount -ne 1) { + Write-Host "FAIL [powershell:--version-dedupe]: expected --version exactly once, got $verCount" + $script:fail = $true +} + +# 3. Subcommand flag completion — per-sub flags AND injected help. +$line = "codegraph init -" +$r = TabExpansion2 -inputScript $line -cursorColumn $line.Length +Assert-NonEmpty "init/-" $r +$flags = $r.CompletionMatches.CompletionText +Assert-Contains "init/--index" "--index" $flags +Assert-Contains "init/-i" "-i" $flags +Assert-Contains "init/--help" "--help" $flags +Assert-Contains "init/-h" "-h" $flags + +if ($script:fail) { exit 1 } else { Write-Output "powershell smoke OK"; exit 0 } +PSSCRIPT + +pwsh -NoProfile -File "$tmp" diff --git a/docker/smoke-completions/test-zsh-ohmyzsh.sh b/docker/smoke-completions/test-zsh-ohmyzsh.sh new file mode 100755 index 00000000..85958078 --- /dev/null +++ b/docker/smoke-completions/test-zsh-ohmyzsh.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Tier-1 zsh detection check. Creates a fake oh-my-zsh layout (just an +# empty $ZSH dir), re-runs `codegraph completions zsh --install`, and +# asserts the file lands in $ZSH/completions/ — proving the detection +# picks the oh-my-zsh path over the ~/.zsh fallback. +set -euo pipefail + +FAKE_ZSH="$HOME/.oh-my-zsh-fake" +mkdir -p "$FAKE_ZSH" + +# Re-install with $ZSH set. We don't want to disturb the existing +# tier-3 install at ~/.zsh/completions/_codegraph (the prior test +# asserted it landed there), so this is a strict additive check. +ZSH="$FAKE_ZSH" codegraph completions zsh --install >/tmp/ohmyzsh-install.out + +target="$FAKE_ZSH/completions/_codegraph" +if [[ ! -s "$target" ]]; then + echo "FAIL [zsh:tier-1]: expected oh-my-zsh tier to write $target" >&2 + echo "--- installer output ---" >&2 + cat /tmp/ohmyzsh-install.out >&2 + exit 1 +fi + +# The installer message should report the oh-my-zsh source. +if ! grep -q "oh-my-zsh" /tmp/ohmyzsh-install.out; then + echo "FAIL [zsh:tier-1]: installer didn't report oh-my-zsh as detected source" >&2 + cat /tmp/ohmyzsh-install.out >&2 + exit 1 +fi + +# Cleanup so subsequent test runs in the same image don't leak state. +rm -rf "$FAKE_ZSH" + +echo "zsh oh-my-zsh tier OK" diff --git a/docker/smoke-completions/test-zsh.sh b/docker/smoke-completions/test-zsh.sh new file mode 100755 index 00000000..7ee2d03e --- /dev/null +++ b/docker/smoke-completions/test-zsh.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env zsh +# Zsh completion smoke. zsh has no `complete -C` equivalent, and full +# completion output requires `_main_complete` running under a real ZLE +# widget context (which scripts can't manufacture — `_values` / +# `_arguments` short-circuit on missing `$compstate` / `$state` and a +# `compadd` shim captures nothing). PTY + expect is the only way to +# drive the full path, and that's too flaky for CI. +# +# We do the next-best thing: structural assertions that catch the +# common failure modes (script doesn't parse, file body crashed mid-way, +# helpers not defined). The deep-`_arguments` gap is documented in +# README.md and matches what clap_complete / oclif / Commander.js +# itself ship for zsh. +set -e + +declare -i fail=0 + +# 1. Syntax check. +zsh -n "${ZSH_PATH:-$HOME/.zsh/completions/_codegraph}" || { echo "FAIL [zsh:syntax]" >&2; exit 1; } + +# 2. compinit must load the file as an autoloadable function. Add the +# install dir to $fpath — for tier-2 installs into +# /usr/local/share/zsh/site-functions this is a no-op (already there); +# for tier-3 (~/.zsh/completions fallback) it's required. +fpath=("${ZSH_PATH:A:h}" $fpath) +autoload -Uz compinit +# -u: don't bail on insecure dirs (container HOME is owned by root, fine) +# -d: per-run dump in /tmp so we don't pollute HOME +compinit -u -d /tmp/.zcompdump-smoke + +# `whence -w` prints "name: function" or "name: autoload" for loaded +# completion functions; "none" means not registered. +whence_kind=$(whence -w _codegraph 2>/dev/null || echo "_codegraph: none") +case "$whence_kind" in + *": function"|*": autoload") ;; + *) echo "FAIL [zsh:autoload]: _codegraph not registered (whence: $whence_kind)" >&2; fail=1 ;; +esac + +# 3. Force the autoload to actually source the file. We invoke +# `_codegraph` directly; this errors because `_arguments` isn't in a +# real completion context, but the side effect is the file body runs +# and defines the per-subcommand helpers. Discard the expected error. +_codegraph >/dev/null 2>&1 || true + +# Spot-check a representative cross-section: one helper from each +# emitted class (no-args, with-args, dispatcher). If any of these +# isn't defined, the file body crashed partway through and the +# generated script is broken. +for helper in _codegraph_init _codegraph_query _codegraph_commands _codegraph_dispatch; do + if ! typeset -f "$helper" >/dev/null 2>&1; then + echo "FAIL [zsh:helper-defined]: $helper not defined after autoload" >&2 + fail=1 + fi +done + +exit $fail diff --git a/package.json b/package.json index 58f9f0ab..3425728d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:watch": "vitest", "test:eval": "vitest run __tests__/evaluation/", "eval": "npm run build && npx tsx __tests__/evaluation/runner.ts", + "smoke:completions": "bash docker/smoke-completions/run-host.sh", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"" }, "keywords": [ diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index de608c36..20ebd3c4 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1382,6 +1382,69 @@ program } }); +/** + * codegraph completions [--install] + * + * Without --install: prints the script to stdout. Safe to redirect. + * With --install: detects the best target for the environment + * (oh-my-zsh, Homebrew zsh/bash-completion, fish config, pwsh $PROFILE) + * and writes there. If no target can be detected (e.g., pwsh on a + * system with no detectable profile dir), exits non-zero with a hint. + */ +program + .command('completions ') + .description('Generate shell completions (zsh, bash, fish, powershell)') + .option('--install', 'Auto-install to the best location for your environment') + .action(async (shellArg: string, options: { install?: boolean }) => { + const { parseShell, emit, installCompletions, SUPPORTED_SHELLS } = await import( + '../completions' + ); + const shell = parseShell(shellArg); + if (!shell) { + error( + `Unsupported shell '${shellArg}'. Supported: ${SUPPORTED_SHELLS.join(', ')} (aliases: pwsh, ps).`, + ); + process.exit(1); + } + const script = emit(program, shell); + if (!options.install) { + process.stdout.write(script); + return; + } + try { + const result = installCompletions(shell, script); + if (!result) { + error( + `Could not detect an install location for ${shell} on this system.`, + ); + console.log(''); + console.log('Write the script yourself:'); + console.log(` codegraph completions ${shell} > /path/of/your/choice`); + if (shell === 'powershell') { + console.log('Then dot-source it from your $PROFILE.'); + } + process.exit(1); + } + const { target, profileUpdated } = result; + success(`Installed ${shell} completions to ${target.path}`); + console.log(` (detected: ${target.source})`); + if (shell === 'powershell' && target.profilePath) { + if (profileUpdated === true) { + console.log(` Appended dot-source line to ${target.profilePath}`); + } else if (profileUpdated === false) { + console.log(` ${target.profilePath} already references this script — left untouched`); + } + } + if (target.postInstallHint) { + console.log(''); + console.log(target.postInstallHint); + } + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + // Parse and run program.parse(); diff --git a/src/completions/bash.ts b/src/completions/bash.ts new file mode 100644 index 00000000..62e5ba8d --- /dev/null +++ b/src/completions/bash.ts @@ -0,0 +1,116 @@ +/** + * Bash completion emitter. Produces a `_codegraph` function registered + * via `complete -F _codegraph codegraph`. Uses `compgen -W` for word + * lists and `compgen -f` / `-d` for file/dir completion. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +const allFlagsFor = (opts: OptionDesc[]): string[] => { + const out: string[] = []; + for (const o of opts) { + if (o.short) out.push(o.short); + if (o.long) out.push(o.long); + } + return out; +}; + +// Options whose previous word means "complete a path", not "complete a flag". +const valueOptionsByHint = (opts: OptionDesc[]) => { + const file: string[] = []; + const dir: string[] = []; + for (const o of opts) { + if (!o.takesValue) continue; + const flags = [o.short, o.long].filter(Boolean) as string[]; + if (o.valueHint === 'file') file.push(...flags); + else if (o.valueHint === 'directory') dir.push(...flags); + } + return { file, dir }; +}; + +const subcommandCase = (root: CommandDesc): string => { + const arms = root.subcommands.map((sub) => { + const surface = [sub.name, ...sub.aliases].join('|'); + const flags = allFlagsFor(sub.options); + const { file: fileOpts, dir: dirOpts } = valueOptionsByHint(sub.options); + // Positional hint: if any arg wants files/dirs, fall back to file + // completion when the user isn't completing a flag. + const argHint = sub.args.find((a) => a.hint !== 'none')?.hint ?? 'none'; + + const flagList = flags.join(' '); + const valueHandling: string[] = []; + if (fileOpts.length > 0) { + valueHandling.push(` ${fileOpts.join('|')})`); + valueHandling.push(` COMPREPLY=( $(compgen -f -- "$cur") ); return 0 ;;`); + } + if (dirOpts.length > 0) { + valueHandling.push(` ${dirOpts.join('|')})`); + valueHandling.push(` COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;;`); + } + + const positionalFallback = + argHint === 'file' + ? `\n if [[ "$cur" != -* ]]; then\n COMPREPLY=( $(compgen -f -- "$cur") ); return 0\n fi` + : argHint === 'directory' + ? `\n if [[ "$cur" != -* ]]; then\n COMPREPLY=( $(compgen -d -- "$cur") ); return 0\n fi` + : ''; + + return ` ${surface}) + case "$prev" in +${valueHandling.length > 0 ? valueHandling.join('\n') + '\n' : ''} esac${positionalFallback} + COMPREPLY=( $(compgen -W "${flagList}" -- "$cur") ) + return 0 + ;;`; + }); + + return arms.join('\n'); +}; + +export const emitBash = (program: Command): string => { + const root = describeCommand(program); + const subNames = root.subcommands.flatMap((s) => [s.name, ...s.aliases]).join(' '); + const rootFlags = allFlagsFor(root.options).join(' '); + + return `# Bash completion for codegraph. +# Generated by \`codegraph completions bash\` — do not edit by hand. +# Install: source this file, or drop it in +# ~/.local/share/bash-completion/completions/codegraph +# (requires the bash-completion package). + +_codegraph() { + local cur prev words cword + _init_completion 2>/dev/null || { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + # Find the subcommand position (first non-flag token after the script). + local sub="" + local i + for (( i=1; i < cword; i++ )); do + if [[ "\${words[i]}" != -* ]]; then + sub="\${words[i]}" + break + fi + done + + if [[ -z "$sub" ]]; then + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "${rootFlags}" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "${subNames}" -- "$cur") ) + fi + return 0 + fi + + case "$sub" in +${subcommandCase(root)} + esac +} + +complete -F _codegraph codegraph +`; +}; diff --git a/src/completions/fish.ts b/src/completions/fish.ts new file mode 100644 index 00000000..8f3d4f77 --- /dev/null +++ b/src/completions/fish.ts @@ -0,0 +1,83 @@ +/** + * Fish completion emitter. Each `complete` line registers a single + * piece of completion metadata; fish stitches them together at + * tab-time. No script, no functions — just declarations. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +const fishEscape = (s: string): string => s.replace(/'/g, "\\'"); + +const subcommandNamesExpr = (root: CommandDesc): string => + root.subcommands.flatMap((s) => [s.name, ...s.aliases]).join(' '); + +const optionLine = (cmdCondition: string, opt: OptionDesc): string => { + const parts: string[] = [`complete -c codegraph -n '${cmdCondition}'`]; + if (opt.short) parts.push(`-s ${opt.short.replace(/^-/, '')}`); + if (opt.long) parts.push(`-l ${opt.long.replace(/^--/, '')}`); + if (opt.takesValue) { + parts.push('-r'); // option requires an argument + if (opt.valueHint === 'file') parts.push('-F'); + else if (opt.valueHint === 'directory') parts.push('-F'); // fish has no -D; -F + dir hint description + else parts.push('-x'); // arg required, not a file + } + if (opt.description) parts.push(`-d '${fishEscape(opt.description)}'`); + return parts.join(' '); +}; + +export const emitFish = (program: Command): string => { + const root = describeCommand(program); + const subs = subcommandNamesExpr(root); + + // Root: list subcommands when none chosen yet. + const lines: string[] = [ + '# Fish completion for codegraph.', + "# Generated by `codegraph completions fish` — do not edit by hand.", + '# Install: place in ~/.config/fish/completions/codegraph.fish', + '', + "complete -c codegraph -n '__fish_use_subcommand' -f", + ]; + + // Top-level option flags (apply when no subcommand chosen yet). + for (const opt of root.options) { + lines.push(optionLine('__fish_use_subcommand', opt)); + } + + // Each subcommand as a candidate at position 1. + for (const sub of root.subcommands) { + const desc = fishEscape(sub.description); + for (const name of [sub.name, ...sub.aliases]) { + lines.push( + `complete -c codegraph -n '__fish_use_subcommand' -a '${fishEscape(name)}' -d '${desc}'`, + ); + } + } + + lines.push(''); + + // Per-subcommand options + positional file completion. + for (const sub of root.subcommands) { + const allNames = [sub.name, ...sub.aliases]; + // `__fish_seen_subcommand_from` accepts space-separated names. + const cond = `__fish_seen_subcommand_from ${allNames.join(' ')}`; + for (const opt of sub.options) { + lines.push(optionLine(cond, opt)); + } + const fileArg = sub.args.find((a) => a.hint === 'file'); + const dirArg = sub.args.find((a) => a.hint === 'directory'); + if (fileArg) { + lines.push(`complete -c codegraph -n '${cond}' -F`); + } else if (dirArg) { + lines.push(`complete -c codegraph -n '${cond}' -a '(__fish_complete_directories)'`); + } + lines.push(''); + } + + // Suppress fallthrough file completion when no completion matches — + // by default fish would offer files for any command, which is noisy + // for commands that don't take a path. + void subs; + + return lines.join('\n') + '\n'; +}; diff --git a/src/completions/index.ts b/src/completions/index.ts new file mode 100644 index 00000000..8267a6b9 --- /dev/null +++ b/src/completions/index.ts @@ -0,0 +1,50 @@ +/** + * Shell-completion script generator. Walks a commander `Command` tree + * and emits a shell-native completion script. Each shell gets its own + * emitter because the formats diverge sharply. + * + * Pattern mirrors hand-written generators in other CLIs (sema, fedit): + * we generate a static script the user installs once, rather than + * hooking completion through a runtime callback. Static scripts work + * without `codegraph` having to spawn a Node process on every Tab, + * and they survive when the binary is uninstalled mid-shell-session. + */ + +import type { Command } from 'commander'; +import { emitZsh } from './zsh'; +import { emitBash } from './bash'; +import { emitFish } from './fish'; +import { emitPowershell } from './powershell'; + +export type Shell = 'zsh' | 'bash' | 'fish' | 'powershell'; + +export const SUPPORTED_SHELLS: readonly Shell[] = ['zsh', 'bash', 'fish', 'powershell'] as const; + +// Accept common spellings (`pwsh`, `ps`) — easier than asking users to remember. +const SHELL_ALIASES: Record = { + pwsh: 'powershell', + ps: 'powershell', + ps1: 'powershell', +}; + +export const parseShell = (s: string): Shell | null => { + const lower = s.toLowerCase(); + if ((SUPPORTED_SHELLS as readonly string[]).includes(lower)) return lower as Shell; + return SHELL_ALIASES[lower] ?? null; +}; + +export const emit = (program: Command, shell: Shell): string => { + switch (shell) { + case 'zsh': + return emitZsh(program); + case 'bash': + return emitBash(program); + case 'fish': + return emitFish(program); + case 'powershell': + return emitPowershell(program); + } +}; + +export { installCompletions, detectInstallTarget } from './install'; +export type { InstallTarget, InstallResult } from './install'; diff --git a/src/completions/install.ts b/src/completions/install.ts new file mode 100644 index 00000000..4932ef95 --- /dev/null +++ b/src/completions/install.ts @@ -0,0 +1,257 @@ +/** + * Detects the best install location for each shell, given the running + * environment, and writes the generated script there. The detection + * priorities are deliberate — the goal is "Tab works after install, + * with zero follow-up config" wherever possible. + * + * Why per-shell tiers (not a single fixed path): + * - On zsh, ~/.zsh/completions isn't on $fpath by default, so the + * naive "drop a file there" install requires the user to edit + * ~/.zshrc. We can avoid that entirely by detecting oh-my-zsh + * (always on $fpath via $ZSH/completions) or Homebrew zsh (on + * $fpath via `brew shellenv`) and using those when present. + * - On bash, Homebrew bash-completion@2 lives at a different path + * than the Linux XDG convention. + * - On PowerShell, there is no completions directory at all — only + * $PROFILE dot-sourcing works. The install writes a standalone + * .ps1 next to $PROFILE and idempotently appends a dot-source line. + * + * Detection is best-effort and conservative: every tier checks that + * its target is writable before committing. The final fallback always + * returns *something* on a recognized platform; only obscure setups + * (e.g., codegraph on Windows targeting bash) return null. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Shell } from './index'; + +export interface InstallTarget { + /** Absolute path the script will be written to. */ + path: string; + /** Which detection rule picked this path (used in messages). */ + source: + | 'oh-my-zsh' + | 'zsh-site-functions' + | 'homebrew-bash-completion' + | 'xdg-bash-completion' + | 'fish-config' + | 'pwsh-profile-dir' + | 'zsh-fallback'; + /** Human-readable explanation of how to make the install effective if needed. */ + postInstallHint?: string; + /** + * For PowerShell: append this line to $PROFILE to load the script. + * The installer handles the append idempotently; this is also surfaced + * in messages for transparency. + */ + profileLine?: string; + /** + * Path to the PowerShell $PROFILE file that needs the dot-source line. + * Null if we couldn't determine it (e.g., pwsh not in PATH on this OS). + */ + profilePath?: string; +} + +export interface InstallResult { + target: InstallTarget; + /** True if we appended to $PROFILE; false if it was already wired up. */ + profileUpdated?: boolean; +} + +// ───────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────── + +const isWritableDir = (dir: string): boolean => { + try { + fs.accessSync(dir, fs.constants.W_OK); + const st = fs.statSync(dir); + return st.isDirectory(); + } catch { + return false; + } +}; + +const canCreateIn = (dir: string): boolean => { + // Walk up to the first existing ancestor; if it's writable, we can + // mkdir -p. Returns false if no ancestor is writable. + let cur = dir; + while (cur && cur !== path.dirname(cur)) { + if (fs.existsSync(cur)) return isWritableDir(cur); + cur = path.dirname(cur); + } + return false; +}; + +// ───────────────────────────────────────────────────────────────────── +// Per-shell detection +// ───────────────────────────────────────────────────────────────────── + +const detectZsh = (env: NodeJS.ProcessEnv, home: string): InstallTarget => { + // Tier 1: oh-my-zsh. $ZSH points at the oh-my-zsh install dir and + // $ZSH/completions is always on $fpath via oh-my-zsh's bootstrap. + // No .zshrc edit needed — true zero-config install. + const zshDir = env.ZSH; + if (zshDir && fs.existsSync(zshDir) && isWritableDir(zshDir)) { + const target = path.join(zshDir, 'completions', '_codegraph'); + if (canCreateIn(path.dirname(target))) { + return { path: target, source: 'oh-my-zsh' }; + } + } + + // Tier 2: `/share/zsh/site-functions`. Apple Silicon Homebrew + // is /opt/homebrew, Intel Homebrew + most Linux distros use /usr/local. + // Homebrew puts this on $fpath via `brew shellenv`; zsh adds /usr/local + // to its default $fpath at compile time on most Linux builds. If the + // dir exists and is writable, the install is zero-config. + for (const prefix of ['/opt/homebrew', '/usr/local']) { + const dir = path.join(prefix, 'share', 'zsh', 'site-functions'); + if (isWritableDir(dir)) { + return { path: path.join(dir, '_codegraph'), source: 'zsh-site-functions' }; + } + } + + // Tier 3: ~/.zsh/completions fallback. Not on default $fpath; print + // the .zshrc snippet so users can wire it up. + return { + path: path.join(home, '.zsh', 'completions', '_codegraph'), + source: 'zsh-fallback', + postInstallHint: + '~/.zsh/completions is not on your $fpath by default.\n' + + 'Add to ~/.zshrc (before `compinit`):\n' + + ' fpath=(~/.zsh/completions $fpath)\n' + + ' autoload -Uz compinit && compinit', + }; +}; + +const detectBash = (home: string): InstallTarget | null => { + // macOS Homebrew bash-completion@2 lives at $(brew --prefix)/etc/ + // bash_completion.d/. If that dir exists and is writable, use it — + // bash-completion auto-loads from there with no further config. + for (const prefix of ['/opt/homebrew', '/usr/local']) { + const dir = path.join(prefix, 'etc', 'bash_completion.d'); + if (isWritableDir(dir)) { + return { path: path.join(dir, 'codegraph'), source: 'homebrew-bash-completion' }; + } + } + + // XDG user-local path that bash-completion v2 auto-loads. Works on + // Linux + macOS-without-Homebrew, provided bash-completion is + // installed (we can't check this from Node, so just print a hint). + const xdg = + process.env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const target = path.join(xdg, 'bash-completion', 'completions', 'codegraph'); + // canCreateIn lets us return this path even if the directory doesn't + // yet exist — mkdir -p handles creation. The check guards against + // permission failures (e.g., XDG_DATA_HOME pointing at a read-only path). + if (canCreateIn(path.dirname(target))) { + return { + path: target, + source: 'xdg-bash-completion', + postInstallHint: + 'Requires the `bash-completion` package. On macOS install via\n' + + ' brew install bash-completion@2\n' + + 'and follow the post-install steps to source it from your bashrc.', + }; + } + return null; +}; + +const detectFish = (home: string): InstallTarget => ({ + path: path.join(home, '.config', 'fish', 'completions', 'codegraph.fish'), + source: 'fish-config', +}); + +const detectPowershell = (env: NodeJS.ProcessEnv, home: string): InstallTarget | null => { + // PowerShell has no completions directory. The convention is to keep + // a standalone .ps1 and dot-source it from $PROFILE. + // + // We can't run pwsh to read $PROFILE (it may not be on PATH); use the + // documented per-OS default that pwsh itself uses on first launch. + // + // Windows pwsh 7: ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1 + // Windows PS 5.1: ~/Documents/WindowsPowerShell/... + // macOS/Linux pwsh: ~/.config/powershell/Microsoft.PowerShell_profile.ps1 + let profileDir: string; + if (process.platform === 'win32') { + // Prefer pwsh 7 path. PS 5.1 users have to install manually — they + // can still source the .ps1 by hand if our chosen profile path + // doesn't match. + profileDir = path.join(home, 'Documents', 'PowerShell'); + } else { + profileDir = path.join(home, '.config', 'powershell'); + } + // We need at least to be able to create the dir — pwsh creates it + // on first run, but if we're installing without pwsh ever having run, + // the parent of profileDir must exist and be writable. + if (!canCreateIn(profileDir)) return null; + + const scriptPath = path.join(profileDir, 'codegraph.ps1'); + const profilePath = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1'); + // The exact line we append to $PROFILE. The leading marker comment + // is used by the installer to detect "already wired up" and skip + // re-appending on repeat runs. + const profileLine = `. '${scriptPath}' # codegraph completions`; + void env; + return { + path: scriptPath, + source: 'pwsh-profile-dir', + profilePath, + profileLine, + postInstallHint: + `Dot-sourced from $PROFILE on next pwsh launch.\n` + + `If your $PROFILE is somewhere else, add manually:\n ${profileLine}`, + }; +}; + +// ───────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────── + +export const detectInstallTarget = ( + shell: Shell, + env: NodeJS.ProcessEnv = process.env, + home: string = os.homedir(), +): InstallTarget | null => { + switch (shell) { + case 'zsh': + return detectZsh(env, home); + case 'bash': + return detectBash(home); + case 'fish': + return detectFish(home); + case 'powershell': + return detectPowershell(env, home); + } +}; + +export const installCompletions = (shell: Shell, script: string): InstallResult | null => { + const target = detectInstallTarget(shell); + if (!target) return null; + + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, script, 'utf8'); + + // PowerShell: idempotently append a dot-source line to $PROFILE so + // the registration actually fires in new pwsh sessions. If the line + // is already there (substring match on the marker comment), skip. + let profileUpdated: boolean | undefined; + if (shell === 'powershell' && target.profilePath && target.profileLine) { + const marker = '# codegraph completions'; + const existing = fs.existsSync(target.profilePath) + ? fs.readFileSync(target.profilePath, 'utf8') + : ''; + if (existing.includes(marker)) { + profileUpdated = false; + } else { + fs.mkdirSync(path.dirname(target.profilePath), { recursive: true }); + const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + fs.appendFileSync(target.profilePath, `${prefix}${target.profileLine}\n`); + profileUpdated = true; + } + } + + return { target, profileUpdated }; +}; diff --git a/src/completions/introspect.ts b/src/completions/introspect.ts new file mode 100644 index 00000000..fa4cb5e1 --- /dev/null +++ b/src/completions/introspect.ts @@ -0,0 +1,105 @@ +/** + * Normalize a commander `Command` tree into a plain descriptor each + * shell emitter walks. Keeps commander coupling in one place; if + * commander's introspection surface changes, only this file moves. + */ + +import type { Command, Option as CommanderOption, Argument as CommanderArgument } from 'commander'; + +export type CompletionHint = 'file' | 'directory' | 'none'; + +export interface OptionDesc { + short?: string; // e.g. "-p" + long?: string; // e.g. "--path" + flags: string; // raw flag spec: "-p, --path " + description: string; + takesValue: boolean; + valueName?: string; + valueHint: CompletionHint; + negate: boolean; // --no-xxx +} + +export interface ArgDesc { + name: string; + required: boolean; + variadic: boolean; + hint: CompletionHint; +} + +export interface CommandDesc { + name: string; + aliases: string[]; + description: string; + args: ArgDesc[]; + options: OptionDesc[]; + subcommands: CommandDesc[]; +} + +// Argument-name heuristic: positional names that look like filesystem +// paths get a `_files` hint. Names matter — commander doesn't expose +// per-argument completion metadata, so the name is all we have. +const FILE_HINT_NAMES = new Set(['path', 'file', 'files', 'source', 'output']); +const DIR_HINT_NAMES = new Set(['dir', 'directory', 'folder']); + +const hintForName = (name: string | undefined): CompletionHint => { + if (!name) return 'none'; + const lower = name.toLowerCase(); + if (DIR_HINT_NAMES.has(lower)) return 'directory'; + if (FILE_HINT_NAMES.has(lower)) return 'file'; + return 'none'; +}; + +const describeOption = (opt: CommanderOption): OptionDesc => { + // Commander's `Option` exposes `short`, `long`, `flags`, `description`, + // `required` (value-required ``), `optional` (value-optional `[x]`), + // `negate` (--no-x), `mandatory` (option itself required). + const takesValue = Boolean(opt.required || opt.optional); + // Value name lives inside `flags`, e.g. "-p, --path ". Pull the + // first `<...>` or `[...]` token; commander doesn't surface it directly. + let valueName: string | undefined; + if (takesValue) { + const match = opt.flags.match(/[<[]([^>\]]+)[>\]]/); + if (match) valueName = match[1]; + } + return { + short: opt.short ?? undefined, + long: opt.long ?? undefined, + flags: opt.flags, + description: opt.description ?? '', + takesValue, + valueName, + valueHint: hintForName(valueName), + negate: Boolean(opt.negate), + }; +}; + +const describeArg = (arg: CommanderArgument): ArgDesc => ({ + name: arg.name(), + required: arg.required, + variadic: arg.variadic, + hint: hintForName(arg.name()), +}); + +// Commander auto-registers `-h, --help` on every command but doesn't +// expose it via `cmd.options` (it lives behind internal helpOption +// machinery). Inject it explicitly so completion menus actually +// include it — every other CLI's user expects --help on Tab. +const helpOption: OptionDesc = { + short: '-h', + long: '--help', + flags: '-h, --help', + description: 'Display help for command', + takesValue: false, + valueHint: 'none', + negate: false, +}; + +export const describeCommand = (cmd: Command): CommandDesc => ({ + name: cmd.name(), + aliases: cmd.aliases(), + description: cmd.description(), + // `registeredArguments` is commander 10+ — the project pins ^14, so safe. + args: (cmd as Command & { registeredArguments: CommanderArgument[] }).registeredArguments.map(describeArg), + options: [...cmd.options.map(describeOption), helpOption], + subcommands: cmd.commands.map(describeCommand), +}); diff --git a/src/completions/powershell.ts b/src/completions/powershell.ts new file mode 100644 index 00000000..8b89728a --- /dev/null +++ b/src/completions/powershell.ts @@ -0,0 +1,127 @@ +/** + * PowerShell completion emitter. Hand-written, static (no callback + * into the binary). Follows the clap_complete idiom exactly because + * it's the most-deployed PS completion pattern (every Rust CLI uses + * it) and PowerShell's `TabExpansion2` testability makes the static + * path much friendlier than zsh's. + * + * The shape: + * Register-ArgumentCompleter -Native -CommandName codegraph -ScriptBlock { + * # Walk $commandAst.CommandElements → join non-flag, non-value + * # tokens with ';' → "codegraph", "codegraph;init", etc. + * # switch on that path string → emit subcommand + option candidates + * # filter by $wordToComplete prefix + * } + * + * Known clap_complete bugs we sidestep: + * #5820 — single-quote escaping. PS single-quoted strings escape + * a single quote by doubling it. We double-escape descriptions. + * #5341 — empty help strings break completions. We default to the + * item name when description is empty. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +// PowerShell single-quoted strings: '' is the only escape needed. +const psEscape = (s: string): string => s.replace(/'/g, "''"); + +// Empty descriptions break completion rendering; fall back to the +// item itself so the menu always has something to show. +const descOrName = (desc: string, name: string): string => + desc.trim().length > 0 ? desc : name; + +const optionResult = (opt: OptionDesc): string[] => { + // Emit one CompletionResult line per surface form. Short and long + // forms get separate entries so both show up in the menu. + const desc = psEscape(descOrName(opt.description, opt.long ?? opt.short ?? '')); + const out: string[] = []; + for (const flag of [opt.short, opt.long]) { + if (!flag) continue; + out.push( + ` [CompletionResult]::new('${psEscape(flag)}', '${psEscape(flag)}', [CompletionResultType]::ParameterName, '${desc}')`, + ); + } + return out; +}; + +const commandResult = (sub: CommandDesc): string[] => { + // Subcommand candidate plus all its aliases. Each gets its own + // CompletionResult so they all appear in the menu. + const desc = psEscape(descOrName(sub.description, sub.name)); + return [sub.name, ...sub.aliases].map( + (n) => + ` [CompletionResult]::new('${psEscape(n)}', '${psEscape(n)}', [CompletionResultType]::ParameterValue, '${desc}')`, + ); +}; + +const switchCaseFor = (commandPath: string, cmd: CommandDesc, isRoot: boolean): string => { + // PS switch case: 'codegraph;init' or 'codegraph'. The body lists + // valid candidates at that point: subcommands (if any) + options. + const lines: string[] = []; + // Subcommands first (only meaningful for the root; per-sub cases + // don't dispatch deeper because codegraph is flat — no sub-subs). + for (const sub of cmd.subcommands) { + lines.push(...commandResult(sub)); + } + // Options. + for (const opt of cmd.options) { + lines.push(...optionResult(opt)); + } + // --help / --version are now surfaced via the introspect layer + // (helpOption is injected; --version comes from .version() being + // registered as a regular option). No special-casing needed here. + void isRoot; + return ` '${psEscape(commandPath)}' { +${lines.join('\n')} + break + }`; +}; + +export const emitPowershell = (program: Command): string => { + const root = describeCommand(program); + const cmdName = root.name; + + const cases: string[] = [switchCaseFor(cmdName, root, true)]; + for (const sub of root.subcommands) { + // Cover canonical name + each alias as a separate switch arm so + // `codegraph a` (alias for `affected`) works the same as + // `codegraph affected`. + for (const surface of [sub.name, ...sub.aliases]) { + cases.push(switchCaseFor(`${cmdName};${surface}`, sub, false)); + } + } + + return `using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +# PowerShell completion for ${cmdName}. +# Generated by \`${cmdName} completions powershell\` — do not edit by hand. +# Install: dot-source this file from your \$PROFILE, e.g. +# Add-Content \$PROFILE ". ''" + +Register-ArgumentCompleter -Native -CommandName '${psEscape(cmdName)}' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + '${psEscape(cmdName)}' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { break } + $element.Value + } + ) -join ';' + + $completions = @(switch ($command) { +${cases.join('\n')} + }) + + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText +} +`; +}; diff --git a/src/completions/zsh.ts b/src/completions/zsh.ts new file mode 100644 index 00000000..874d0e4d --- /dev/null +++ b/src/completions/zsh.ts @@ -0,0 +1,122 @@ +/** + * Zsh completion emitter. Produces an `_codegraph` function suitable + * for installation on the `fpath`. Uses the `_arguments` / `_values` + * idiom — the same style sema-lisp and fedit generate. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type CompletionHint, type OptionDesc } from './introspect'; + +// Inside single-quoted zsh strings we only need to handle `'` and the +// `[…]` description delimiters of `_arguments`. Keeping the escape set +// minimal avoids surprising users who read the script. +const zshEscape = (s: string): string => + s.replace(/'/g, "'\\''").replace(/\[/g, '\\[').replace(/\]/g, '\\]'); + +const actionFor = (hint: CompletionHint): string => { + switch (hint) { + case 'file': + return '_files'; + case 'directory': + return '_files -/'; + case 'none': + return ''; + } +}; + +const optionSpec = (opt: OptionDesc): string => { + // `_arguments` spec format: + // '(-x --xx)'{-x,--xx}'[description]:value name:action' + // For boolean flags (no value): '--flag[description]' + // For both short + long: paired exclusion + brace expansion. + const desc = zshEscape(opt.description); + const valuePart = opt.takesValue + ? `:${zshEscape(opt.valueName ?? 'value')}:${actionFor(opt.valueHint)}` + : ''; + + if (opt.short && opt.long) { + return `'(${opt.short} ${opt.long})'{${opt.short},${opt.long}}'[${desc}]${valuePart}'`; + } + const flag = opt.long ?? opt.short!; + return `'${flag}[${desc}]${valuePart}'`; +}; + +const argSpec = (idx: number, arg: { name: string; required: boolean; variadic: boolean; hint: CompletionHint }): string => { + const action = actionFor(arg.hint) || '( )'; + if (arg.variadic) { + return `'*:${zshEscape(arg.name)}:${action}'`; + } + const prefix = arg.required ? `${idx + 1}` : `:${idx + 1}`; + return `'${prefix}:${zshEscape(arg.name)}:${action}'`; +}; + +const subcommandDispatchFn = (root: CommandDesc): string => { + // Per-subcommand argument spec functions, named `_codegraph_`. + const perSub = root.subcommands.map((sub) => { + const argLines = sub.args.map((a, i) => ` ${argSpec(i, a)}`); + const optLines = sub.options.map((o) => ` ${optionSpec(o)}`); + const lines = [...optLines, ...argLines]; + const body = lines.length > 0 ? `\n _arguments -s -S \\\n${lines.join(' \\\n')}\n` : '\n return 0\n'; + return `_codegraph_${sub.name}() {${body}}`; + }); + + // The values list passed to `_values` — `subname[description]` per + // canonical name. Aliases get their own line so they show up in tab + // listings. + const valueLines = root.subcommands.flatMap((sub) => { + const desc = zshEscape(sub.description); + return [sub.name, ...sub.aliases].map((n) => ` '${zshEscape(n)}[${desc}]'`); + }); + + // Case dispatch from canonical name OR alias to the per-sub function. + const caseArms = root.subcommands + .map((sub) => { + const pattern = [sub.name, ...sub.aliases].map(zshEscape).join('|'); + return ` ${pattern}) _codegraph_${sub.name} ;;`; + }) + .join('\n'); + + return `${perSub.join('\n\n')} + +_codegraph_commands() { + local -a _commands + _values 'codegraph command' \\ +${valueLines.join(' \\\n')} +} + +_codegraph_dispatch() { + case $words[1] in +${caseArms} + esac +}`; +}; + +export const emitZsh = (program: Command): string => { + const root = describeCommand(program); + const rootOptions = root.options.map((o) => ` ${optionSpec(o)}`).join(' \\\n'); + + return `#compdef codegraph +# Zsh completion for codegraph. +# Generated by \`codegraph completions zsh\` — do not edit by hand. +# Install: place on your \$fpath (e.g. ~/.zsh/completions/_codegraph), +# then \`autoload -Uz compinit && compinit\`. + +${subcommandDispatchFn(root)} + +_codegraph() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \\ +${rootOptions} \\ + '1: :_codegraph_commands' \\ + '*::arg:->args' + + case $state in + args) _codegraph_dispatch ;; + esac +} + +_codegraph "$@" +`; +};