From 33baaba6333c7b901dba35bb7520dd2683f0cd2d Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 13:32:42 -0700 Subject: [PATCH 1/4] feat(cli): add `dub completion ` and `dub man` Adds shell-completion script generation for bash, zsh, and fish, plus a roff man-page generator, both driven by introspecting the live commander.js program tree. Output streams to stdout for users to redirect into the shell completion or MANPATH location of their choice. Completion covers top-level subcommands, per-command flags (read from commander metadata), local-branch completion for co/up/down/delete/ track/untrack via `git for-each-ref`, and file completion where commands take file args. The Homebrew formula now bundles `dub.1` and installs completions for all three shells via `generate_completions_from_executable`, so packaged users get them without extra steps. Docs updated in apps/docs/content/docs/guides/shell-integration.mdx and README.md. 21 new tests cover bash/zsh/fish syntax (validated against the host shell when present) and mandoc/groff rendering of the man page. Completes DUB-67 --- README.md | 21 + .../content/docs/guides/shell-integration.mdx | 44 ++ homebrew/dubstack.rb | 7 + packages/cli/src/commands/completion.test.ts | 158 ++++++ packages/cli/src/commands/completion.ts | 26 + packages/cli/src/commands/man.test.ts | 123 +++++ packages/cli/src/commands/man.ts | 9 + packages/cli/src/index.ts | 32 ++ packages/cli/src/lib/completion.ts | 471 ++++++++++++++++++ packages/cli/src/lib/man.ts | 109 ++++ 10 files changed, 1000 insertions(+) create mode 100644 packages/cli/src/commands/completion.test.ts create mode 100644 packages/cli/src/commands/completion.ts create mode 100644 packages/cli/src/commands/man.test.ts create mode 100644 packages/cli/src/commands/man.ts create mode 100644 packages/cli/src/lib/completion.ts create mode 100644 packages/cli/src/lib/man.ts diff --git a/README.md b/README.md index 9859a2ee..f1bf3993 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,27 @@ Undo the last `dub create`, `dub restack`, `dub rename`, `dub move`, `dub pop`, dub undo ``` +### `dub completion ` and `dub man` + +Generate shell completions and a man page from the live commander definitions. + +```bash +# bash, zsh, fish — pipe into the location your shell loads completions from +dub completion bash > ~/.local/share/bash-completion/completions/dub +dub completion zsh > "${fpath[1]}/_dub" +dub completion fish > ~/.config/fish/completions/dub.fish + +# roff man page — drop into any MANPATH location +mkdir -p ~/.local/share/man/man1 +dub man > ~/.local/share/man/man1/dub.1 +man dub +``` + +Completions cover top-level subcommands, per-command flags, and local branch +names for `co`, `up`, `down`, `delete`, `track`, and `untrack`. Regenerate +after upgrading `dub` to pick up new commands and flags. Full docs: +[`apps/docs/content/docs/guides/shell-integration.mdx`](apps/docs/content/docs/guides/shell-integration.mdx). + ### `dub skills` Install or remove packaged agent skills. diff --git a/apps/docs/content/docs/guides/shell-integration.mdx b/apps/docs/content/docs/guides/shell-integration.mdx index 507c6a5a..1b465675 100644 --- a/apps/docs/content/docs/guides/shell-integration.mdx +++ b/apps/docs/content/docs/guides/shell-integration.mdx @@ -115,6 +115,50 @@ Enable the plugin in `~/.zshrc`: plugins=(... dubstack) ``` +## Shell completions + +`dub completion ` writes a completion script to stdout. Pipe it into +the location your shell loads completions from: + +```bash +# bash +dub completion bash > ~/.local/share/bash-completion/completions/dub + +# zsh — drop into any directory on $fpath, then restart your shell +dub completion zsh > "${fpath[1]}/_dub" + +# fish +dub completion fish > ~/.config/fish/completions/dub.fish +``` + +The generated script completes: + +- Top-level subcommands (`dub `). +- Local branch names for branch-arg commands (`dub co `, `dub up `, + `dub down `, `dub delete `, `dub track `, `dub untrack `). +- Per-command flags read directly from the `dub` binary's commander metadata, + so completions stay in sync with the CLI version installed in `$PATH`. +- File paths for commands that take a file argument. + +Regenerate the script after upgrading `dub` so new flags and commands show up. + +## Man page + +`dub man` emits a roff-formatted man page to stdout. Redirect it into any +directory on your `MANPATH`: + +```bash +mkdir -p ~/.local/share/man/man1 +dub man > ~/.local/share/man/man1/dub.1 +man dub +``` + +The page is generated from the same commander.js definitions that power `dub +--help`, so every subcommand and option is documented automatically. + +Distributors can bundle the man page alongside the binary — for example, the +Homebrew formula installs `dub.1` into `share/man/man1/`. + ## Refreshing the cache Most shell integrations should rely on the default (cache-first) behavior — diff --git a/homebrew/dubstack.rb b/homebrew/dubstack.rb index b025801c..b555bc5f 100644 --- a/homebrew/dubstack.rb +++ b/homebrew/dubstack.rb @@ -12,9 +12,16 @@ class Dubstack < Formula def install system "npm", "install", *std_npm_args bin.install_symlink libexec.glob("bin/*") + + # Generate the man page and shell completions from the installed binary + # so they always match the version users are running. + man1.mkpath + (man1/"dub.1").write Utils.safe_popen_read("#{bin}/dub", "man") + generate_completions_from_executable(bin/"dub", "completion") end test do assert_match version.to_s, shell_output("#{bin}/dub --version") + assert_match ".TH DUB 1", shell_output("#{bin}/dub man") end end diff --git a/packages/cli/src/commands/completion.test.ts b/packages/cli/src/commands/completion.test.ts new file mode 100644 index 00000000..0d6f7d61 --- /dev/null +++ b/packages/cli/src/commands/completion.test.ts @@ -0,0 +1,158 @@ +import { spawnSync } from 'node:child_process'; +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { DubError } from '../lib/errors'; +import { completion } from './completion'; + +function buildFixtureProgram(): Command { + const program = new Command(); + program.name('dub').description('manage stacked diffs'); + program + .command('checkout') + .alias('co') + .argument('[branch]', 'Branch to checkout') + .description('Checkout a branch'); + program + .command('up') + .argument('[steps]', 'Number of levels') + .option('-n, --steps ', 'Number of levels') + .description('Move up'); + program + .command('track') + .argument('[branch]', 'Branch to track') + .option('-p, --parent ', 'Parent branch') + .description('Track a branch'); + program + .command('submit') + .option('--branch ', 'Submit one branch') + .option('--dry-run', 'Preview only') + .description('Submit stack'); + program + .command('split') + .option('--by-file ', 'Move specific files') + .description('Split branch'); + const trunk = program.command('trunk').description('Show or manage trunks'); + trunk.command('list').description('List configured trunks'); + trunk.command('add').argument('').description('Add a trunk'); + return program; +} + +describe('completion', () => { + it('rejects unsupported shells with an actionable DubError', () => { + const program = buildFixtureProgram(); + expect(() => completion(program, 'powershell')).toThrow(DubError); + try { + completion(program, 'powershell'); + } catch (err) { + expect(err).toBeInstanceOf(DubError); + const message = (err as DubError).message; + expect(message).toContain("'powershell'"); + } + }); + + describe('bash', () => { + it('emits a complete -F registration and references known commands', () => { + const out = completion(buildFixtureProgram(), 'bash'); + expect(out).toContain('complete -F _dub dub'); + expect(out).toContain('checkout'); + expect(out).toContain('co'); + expect(out).toContain('track'); + expect(out).toContain('submit'); + }); + + it('completes branch names for branch-arg commands', () => { + const out = completion(buildFixtureProgram(), 'bash'); + expect(out).toContain('__dub_branches'); + expect(out).toContain('git for-each-ref'); + // The branch-arg case pattern must include the spec commands. + expect(out).toMatch(/checkout\|co/); + expect(out).toContain('up'); + expect(out).toContain('track'); + }); + + it('emits flag completions per command from commander metadata', () => { + const out = completion(buildFixtureProgram(), 'bash'); + expect(out).toContain('--dry-run'); + expect(out).toContain('--branch'); + expect(out).toContain('--parent'); + }); + + it('parses as valid bash syntax (when bash is available)', () => { + const out = completion(buildFixtureProgram(), 'bash'); + const result = spawnSync('bash', ['-n'], { input: out }); + if ( + result.error && + (result.error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return; // bash not installed; skip syntax check + } + expect(result.status, result.stderr?.toString()).toBe(0); + }); + }); + + describe('zsh', () => { + it('starts with the #compdef magic comment', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + expect(out.startsWith('#compdef dub')).toBe(true); + }); + + it('describes top-level commands with their descriptions', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + expect(out).toContain('checkout:Checkout a branch'); + expect(out).toContain('Submit stack'); + }); + + it('references __dub_branches for branch-arg commands', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + expect(out).toContain('__dub_branches'); + expect(out).toContain('git for-each-ref'); + }); + + it('parses as valid zsh syntax (when zsh is available)', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + const result = spawnSync('zsh', ['-n'], { input: out }); + if ( + result.error && + (result.error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return; // zsh not installed; skip syntax check + } + expect(result.status, result.stderr?.toString()).toBe(0); + }); + }); + + describe('fish', () => { + it('uses fish complete -c dub syntax', () => { + const out = completion(buildFixtureProgram(), 'fish'); + expect(out).toContain('complete -c dub'); + expect(out).toContain('__dub_no_subcommand'); + expect(out).toContain('__dub_using_command'); + }); + + it('lists top-level commands as completion candidates', () => { + const out = completion(buildFixtureProgram(), 'fish'); + expect(out).toMatch(/-a 'checkout'/); + expect(out).toMatch(/-a 'co'/); + expect(out).toMatch(/-a 'track'/); + expect(out).toMatch(/-a 'submit'/); + }); + + it('emits branch completion for branch-arg commands', () => { + const out = completion(buildFixtureProgram(), 'fish'); + expect(out).toContain('function __dub_branches'); + expect(out).toContain('(__dub_branches)'); + }); + + it('parses as valid fish syntax (when fish is available)', () => { + const out = completion(buildFixtureProgram(), 'fish'); + const result = spawnSync('fish', ['-n'], { input: out }); + if ( + result.error && + (result.error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return; // fish not installed; skip syntax check + } + expect(result.status, result.stderr?.toString()).toBe(0); + }); + }); +}); diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts new file mode 100644 index 00000000..b94bbaed --- /dev/null +++ b/packages/cli/src/commands/completion.ts @@ -0,0 +1,26 @@ +import type { Command } from 'commander'; +import { + type CompletionShell, + generateBashCompletion, + generateFishCompletion, + generateZshCompletion, +} from '../lib/completion'; +import { DubError } from '../lib/errors'; + +export function completion(program: Command, shell: string): string { + switch (shell) { + case 'bash': + return generateBashCompletion(program); + case 'zsh': + return generateZshCompletion(program); + case 'fish': + return generateFishCompletion(program); + default: + throw new DubError(`Unsupported shell '${shell}'.`, [ + "Pass 'bash', 'zsh', or 'fish' as the shell argument.", + "Example: 'dub completion zsh > ~/.zsh/completions/_dub'.", + ]); + } +} + +export type { CompletionShell }; diff --git a/packages/cli/src/commands/man.test.ts b/packages/cli/src/commands/man.test.ts new file mode 100644 index 00000000..b3c339b4 --- /dev/null +++ b/packages/cli/src/commands/man.test.ts @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process'; +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { man } from './man'; + +function buildFixtureProgram(): Command { + const program = new Command(); + program + .name('dub') + .description('manage stacked diffs') + .option('--verbose', 'Print verbose output'); + program + .command('init') + .description('Initialize DubStack in the current repository'); + program + .command('create') + .argument('[branch]', 'Name of the new branch') + .option('-m, --message ', 'Commit message') + .description('Create a stacked branch'); + const trunk = program + .command('trunk') + .description('Show or manage configured trunks'); + trunk.command('list').description('List configured trunks'); + trunk.command('add').argument('').description('Register a trunk'); + return program; +} + +describe('man', () => { + it('starts with a .TH header carrying the supplied version and section', () => { + const out = man(buildFixtureProgram(), { + version: '9.9.9', + date: '2026-05-25', + }); + const firstLine = out.split('\n')[0]; + // Hyphens in date / pre-release versions become \- after roff escape. + expect(firstLine).toBe( + '.TH DUB 1 "2026\\-05\\-25" "DubStack 9.9.9" "User Commands"', + ); + }); + + it('escapes hyphens in pre-release version tags in the .TH header', () => { + const out = man(buildFixtureProgram(), { + version: '1.0.0-beta.1', + date: '2026-01-02', + }); + expect(out.split('\n')[0]).toBe( + '.TH DUB 1 "2026\\-01\\-02" "DubStack 1.0.0\\-beta.1" "User Commands"', + ); + }); + + it('includes NAME, SYNOPSIS, DESCRIPTION, and COMMANDS sections', () => { + const out = man(buildFixtureProgram(), { version: '1.0.0' }); + expect(out).toContain('.SH NAME'); + expect(out).toContain('.SH SYNOPSIS'); + expect(out).toContain('.SH DESCRIPTION'); + expect(out).toContain('.SH COMMANDS'); + expect(out).toContain('.SH SEE ALSO'); + }); + + it('documents each subcommand with its description', () => { + const out = man(buildFixtureProgram(), { version: '1.0.0' }); + expect(out).toContain('init'); + expect(out).toContain('Initialize DubStack'); + expect(out).toContain('create'); + expect(out).toContain('Create a stacked branch'); + }); + + it('emits nested subcommand documentation', () => { + const out = man(buildFixtureProgram(), { version: '1.0.0' }); + expect(out).toContain('trunk list'); + expect(out).toContain('trunk add'); + }); + + it('escapes hyphens as \\- so man rendering preserves them', () => { + const out = man(buildFixtureProgram(), { version: '1.0.0' }); + // Description "Print verbose output" contains no hyphen, but + // the SEE ALSO line references gh(1) without one. Use the SYNOPSIS + // glyphs which always carry hyphens. + expect(out).toContain('\\-'); + }); + + it('escapes a backslash in user-supplied descriptions', () => { + const program = new Command(); + program.name('dub').description('manage stacked diffs'); + program.command('weird').description('contains \\backslash here'); + const out = man(program, { version: '1.0.0' }); + // Original backslash must be doubled so groff treats it as a literal. + expect(out).toContain('\\\\backslash'); + }); + + it('renders as valid roff via a system roff formatter when available', () => { + const out = man(buildFixtureProgram(), { version: '1.0.0' }); + // Prefer mandoc (BSD/macOS), fall back to groff (GNU). Both consume the + // same input via stdin and emit a plain-text rendering on stdout. + const candidates = [ + ['mandoc', ['-Tutf8']], + ['groff', ['-man', '-Tutf8']], + ] as const; + for (const [bin, args] of candidates) { + const result = spawnSync(bin, args, { input: out, encoding: 'utf8' }); + if ( + result.error && + (result.error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + continue; + } + expect(result.status, result.stderr).toBe(0); + // Strip backspace overstrike (used by formatters to bold/underline) so + // assertions match the rendered text regardless of formatter choice. + const backspace = String.fromCharCode(8); + const stripped = result.stdout.replace( + new RegExp(`.${backspace}`, 'g'), + '', + ); + expect(stripped).toContain('DUB(1)'); + expect(stripped).toContain('NAME'); + expect(stripped).toContain('init'); + return; + } + // Neither formatter installed; structural assertions above already + // exercise the contract — treat this as a soft skip. + }); +}); diff --git a/packages/cli/src/commands/man.ts b/packages/cli/src/commands/man.ts new file mode 100644 index 00000000..8b95a890 --- /dev/null +++ b/packages/cli/src/commands/man.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { generateManPage } from '../lib/man'; + +export function man( + program: Command, + options: { version: string; date?: string }, +): string { + return generateManPage(program, options); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index af17d5a5..65d2e06d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,6 +31,7 @@ import { resolveCheckoutTrunk, } from './commands/checkout'; import { children } from './commands/children'; +import { completion } from './commands/completion'; import { continueCommand } from './commands/continue'; import { create } from './commands/create'; import { deleteCommand } from './commands/delete'; @@ -42,6 +43,7 @@ import { freeze } from './commands/freeze'; import { init } from './commands/init'; import { type InstallRecipe, install } from './commands/install'; import { log, logJson, styleLogOutput } from './commands/log'; +import { man } from './commands/man'; import { mcp } from './commands/mcp'; import { mergeCheck, runMergeCheck } from './commands/merge-check'; import { mergeNext } from './commands/merge-next'; @@ -285,6 +287,36 @@ Examples: }, ); +program + .command('completion') + .argument('', 'Shell to generate completions for: bash, zsh, or fish') + .description('Print a shell completion script to stdout') + .addHelpText( + 'after', + ` +Examples: + $ dub completion bash >> ~/.bashrc + $ dub completion zsh > "\${fpath[1]}/_dub" + $ dub completion fish > ~/.config/fish/completions/dub.fish`, + ) + .action((shell: string) => { + process.stdout.write(completion(program, shell)); + }); + +program + .command('man') + .description('Print a roff-formatted man page for `dub` to stdout') + .addHelpText( + 'after', + ` +Examples: + $ dub man > ~/.local/share/man/man1/dub.1 + $ mandb --user-db # then 'man dub' renders the page`, + ) + .action(() => { + process.stdout.write(man(program, { version })); + }); + program .command('docs') .description('Open the DubStack docs website in your browser') diff --git a/packages/cli/src/lib/completion.ts b/packages/cli/src/lib/completion.ts new file mode 100644 index 00000000..e1763518 --- /dev/null +++ b/packages/cli/src/lib/completion.ts @@ -0,0 +1,471 @@ +import type { Command } from 'commander'; + +/** + * Generates shell completion scripts for the `dub` CLI by introspecting the + * commander.js program tree. Output is emitted to stdout and intended to be + * sourced from the user's shell config. + */ + +export type CompletionShell = 'bash' | 'zsh' | 'fish'; + +/** + * Subcommands that accept a branch name as their primary positional argument. + * Used to drive shell-level branch-name completion via `git for-each-ref`. + */ +const BRANCH_ARG_COMMANDS = [ + 'checkout', + 'co', + 'up', + 'down', + 'delete', + 'untrack', + 'track', +] as const; + +/** + * Flags whose value should be completed with branch names rather than free + * text. Surfaces a small but high-value set; everything else falls back to + * default shell completion. + */ +const BRANCH_VALUE_FLAGS = ['--parent', '--branch', '--before', '--after']; + +/** + * Flags whose value should be completed with file paths (default shell + * filename completion). + */ +const FILE_VALUE_FLAGS = ['--input-file', '--profile']; + +interface OptionSpec { + flags: string; + long: string | null; + short: string | null; + takesValue: boolean; + description: string; +} + +interface CommandSpec { + name: string; + aliases: string[]; + description: string; + options: OptionSpec[]; + subcommands: CommandSpec[]; + /** Quoted to keep brackets/angle-brackets out of generated docs. */ + usageArgs: string; + takesBranchArg: boolean; + takesFileArg: boolean; +} + +interface ProgramSpec { + name: string; + description: string; + options: OptionSpec[]; + commands: CommandSpec[]; +} + +interface CommandLike { + name(): string; + aliases(): string[]; + description(): string; + options: CommanderOption[]; + commands: CommandLike[]; + _args?: Array<{ name?: () => string; _name?: string }>; +} + +interface CommanderOption { + flags: string; + long: string | null; + short: string | null; + description: string; + required?: boolean; + optional?: boolean; +} + +export function describeProgram(program: Command): ProgramSpec { + const node = program as unknown as CommandLike; + return { + name: node.name(), + description: node.description(), + options: node.options.map(describeOption), + commands: node.commands.map((cmd) => describeCommand(cmd, [node.name()])), + }; +} + +function describeCommand(cmd: CommandLike, ancestors: string[]): CommandSpec { + const name = cmd.name(); + const argSpecs = (cmd._args ?? []).map((arg) => { + const argName = + typeof arg.name === 'function' ? arg.name() : (arg._name ?? ''); + return argName; + }); + const usageArgs = argSpecs.join(' '); + const lower = `${name} ${usageArgs}`.toLowerCase(); + const branchPositional = + BRANCH_ARG_COMMANDS.includes( + name as (typeof BRANCH_ARG_COMMANDS)[number], + ) || /\b(branch|target|trunk)\b/.test(lower); + return { + name, + aliases: cmd.aliases(), + description: cmd.description(), + options: cmd.options.map(describeOption), + subcommands: cmd.commands.map((c) => + describeCommand(c, [...ancestors, name]), + ), + usageArgs, + takesBranchArg: branchPositional, + takesFileArg: /(); + for (const cmd of spec.commands) { + names.add(cmd.name); + for (const alias of cmd.aliases) names.add(alias); + } + return [...names].sort(); +} + +function flagTokens(option: OptionSpec): string[] { + const out: string[] = []; + if (option.long) out.push(option.long); + if (option.short) out.push(option.short); + return out; +} + +function commandFlagList(cmd: CommandSpec): string[] { + return cmd.options.flatMap(flagTokens); +} + +export function generateBashCompletion(program: Command): string { + const spec = describeProgram(program); + const topLevel = collectCommandNames(spec).join(' '); + const branchValueFlags = BRANCH_VALUE_FLAGS.join('|'); + const fileValueFlags = FILE_VALUE_FLAGS.join('|'); + + // Build a case branch per command resolving to its flag set + arg behavior. + const cmdCases: string[] = []; + const branchArgCmds: string[] = []; + const fileArgCmds: string[] = []; + for (const cmd of spec.commands) { + const flagList = commandFlagList(cmd); + const subNames = cmd.subcommands.flatMap((s) => [s.name, ...s.aliases]); + // Single-quote the completion list. Flags from commander are kebab-case + // (`--foo-bar`, `-x`), so they cannot contain single quotes; this keeps + // any `$` or `"` in a hypothetical future flag from being expanded by + // bash inside the generated case body. + const completions = [...flagList, ...subNames].join(' '); + const patterns = [cmd.name, ...cmd.aliases].join('|'); + cmdCases.push( + ` ${patterns})\n __dub_complete_words '${completions}'\n ;;`, + ); + if (cmd.takesBranchArg) { + branchArgCmds.push(...[cmd.name, ...cmd.aliases]); + } + if (cmd.takesFileArg) { + fileArgCmds.push(...[cmd.name, ...cmd.aliases]); + } + } + + const branchArgPattern = branchArgCmds.join('|'); + const fileArgPattern = fileArgCmds.join('|'); + + // Wrap each per-pattern case in a conditional so empty patterns do not + // produce `case "$x" in )` — bash treats that as a syntax error. + const branchArgCase = branchArgPattern + ? ` if [[ "$cur" != -* ]] && [[ "$cword" -eq 2 ]]; then + case "$subcmd" in + ${branchArgPattern}) + __dub_compreply_lines "$(__dub_branches)" + return 0 + ;; + esac + fi` + : ''; + const fileArgCase = fileArgPattern + ? ` if [[ "$cur" != -* ]]; then + case "$subcmd" in + ${fileArgPattern}) + COMPREPLY=( $(compgen -f -- "$cur") ) + return 0 + ;; + esac + fi` + : ''; + + return `# dub bash completion +# Source this file (or save under /etc/bash_completion.d/) to enable. +# +# Usage: +# eval "$(dub completion bash)" +# # or +# dub completion bash > ~/.local/share/bash-completion/completions/dub + +__dub_branches() { + git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null +} + +# Set IFS to newline so branch names with shell-special characters survive +# word-splitting by compgen. Used by callers that pipe __dub_branches into +# completion candidates. +__dub_compreply_lines() { + local IFS=$'\\n' + COMPREPLY=( $(compgen -W "$1" -- "$cur") ) +} + +__dub_complete_words() { + local words="$1" + COMPREPLY=( $(compgen -W "$words" -- "$cur") ) +} + +_dub() { + local cur prev words cword + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=( "\${COMP_WORDS[@]}" ) + cword=$COMP_CWORD + + # Branch-valued option flag just before the cursor wins over command-arg. + case "$prev" in + ${branchValueFlags}) + __dub_compreply_lines "$(__dub_branches)" + return 0 + ;; + ${fileValueFlags}) + COMPREPLY=( $(compgen -f -- "$cur") ) + return 0 + ;; + esac + + # Top-level: complete subcommand names. + if [ "$cword" -le 1 ]; then + COMPREPLY=( $(compgen -W "${topLevel}" -- "$cur") ) + return 0 + fi + + local subcmd="\${words[1]}" + + # Branch-arg subcommands: complete with local branches when the user is on + # the first positional and the current token is not a flag. +${branchArgCase} + + # File-arg subcommands fall through to default file completion. +${fileArgCase} + + # Otherwise complete that command's flags/subcommands. + case "$subcmd" in +${cmdCases.join('\n')} + *) + COMPREPLY=( $(compgen -W "--help" -- "$cur") ) + ;; + esac +} + +complete -F _dub dub +`; +} + +export function generateZshCompletion(program: Command): string { + const spec = describeProgram(program); + + // Top-level subcommand descriptions for `_describe`. + const topDescribe = spec.commands + .map((cmd) => ` '${cmd.name}:${escapeZsh(cmd.description)}'`) + .concat( + spec.commands.flatMap((cmd) => + cmd.aliases.map( + (alias) => ` '${alias}:${escapeZsh(`alias for ${cmd.name}`)}'`, + ), + ), + ) + .join('\n'); + + const subcommandCases = spec.commands + .map((cmd) => { + const patterns = [cmd.name, ...cmd.aliases].join('|'); + const flagSpecs = cmd.options + .map(zshOptionSpec) + .filter(Boolean) + .join(' \\\n '); + const argSpec = zshArgSpec(cmd); + return ` ${patterns}) + _arguments -s \\ + ${flagSpecs || ':: :->done'} \\ + ${argSpec} + ;;`; + }) + .join('\n'); + + return `#compdef dub +# dub zsh completion +# +# Usage: +# dub completion zsh > "\${fpath[1]}/_dub" +# # then restart your shell or run: compinit + +__dub_branches() { + local -a branches + branches=("\${(@f)$(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)}") + _describe -t branches 'branch' branches +} + +_dub() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \\ + '1: :->cmd' \\ + '*:: :->args' + + case $state in + cmd) + local -a subcmds + subcmds=( +${topDescribe} + ) + _describe -t commands 'dub command' subcmds + ;; + args) + case "\${line[1]}" in +${subcommandCases} + *) + _message 'no more arguments' + ;; + esac + ;; + esac +} + +_dub "$@" +`; +} + +function zshOptionSpec(option: OptionSpec): string { + const desc = escapeZsh(option.description || ''); + const tokens = flagTokens(option); + if (tokens.length === 0) return ''; + // Combine short + long into a (--long -s) cluster so completion knows they + // are aliases for the same option. + const head = tokens.length > 1 ? `(${tokens.join(' ')})` : ''; + const flag = tokens[0]; + if (option.takesValue) { + return `'${head}${flag}[${desc}]:value:'`; + } + return `'${head}${flag}[${desc}]'`; +} + +function zshArgSpec(cmd: CommandSpec): string { + if (cmd.takesBranchArg) { + return `'*::branch:__dub_branches'`; + } + if (cmd.takesFileArg) { + return `'*::file:_files'`; + } + if (cmd.subcommands.length > 0) { + const subs = cmd.subcommands + .map((s) => `'${s.name}:${escapeZsh(s.description)}'`) + .join(' '); + return `'1:subcommand:((${subs}))'`; + } + return `':: :->done'`; +} + +function escapeZsh(value: string): string { + return value.replace(/'/g, "'\\''").replace(/[[\]:]/g, '\\$&'); +} + +export function generateFishCompletion(program: Command): string { + const spec = describeProgram(program); + const out: string[] = []; + + out.push('# dub fish completion'); + out.push('#'); + out.push('# Usage:'); + out.push('# dub completion fish > ~/.config/fish/completions/dub.fish'); + out.push(''); + out.push('function __dub_branches'); + out.push( + " git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null", + ); + out.push('end'); + out.push(''); + out.push('function __dub_using_command'); + out.push(' set -l cmd (commandline -opc)'); + out.push(' test (count $cmd) -ge 2; and test $cmd[2] = $argv[1]'); + out.push('end'); + out.push(''); + out.push('function __dub_no_subcommand'); + out.push(' set -l cmd (commandline -opc)'); + out.push(' test (count $cmd) -eq 1'); + out.push('end'); + out.push(''); + + // Top-level subcommands + for (const cmd of spec.commands) { + const desc = escapeFish(cmd.description); + out.push( + `complete -c dub -n '__dub_no_subcommand' -f -a '${cmd.name}' -d '${desc}'`, + ); + for (const alias of cmd.aliases) { + out.push( + `complete -c dub -n '__dub_no_subcommand' -f -a '${alias}' -d 'alias for ${cmd.name}'`, + ); + } + } + out.push(''); + + // Per-subcommand flag + arg completions + for (const cmd of spec.commands) { + const names = [cmd.name, ...cmd.aliases]; + for (const name of names) { + // Branch arg + if (cmd.takesBranchArg) { + out.push( + `complete -c dub -n '__dub_using_command ${name}' -f -a '(__dub_branches)'`, + ); + } else if (cmd.takesFileArg) { + out.push(`complete -c dub -n '__dub_using_command ${name}' -F`); + } + // Flags + for (const option of cmd.options) { + const long = option.long ? option.long.replace(/^--/, '') : ''; + const short = option.short ? option.short.replace(/^-/, '') : ''; + const desc = escapeFish(option.description || ''); + const parts = [`complete -c dub -n '__dub_using_command ${name}'`]; + if (long) parts.push(`-l ${long}`); + if (short) parts.push(`-s ${short}`); + if (option.takesValue) parts.push('-r'); + parts.push(`-d '${desc}'`); + out.push(parts.join(' ')); + } + // Nested subcommand names + for (const sub of cmd.subcommands) { + const subDesc = escapeFish(sub.description); + out.push( + `complete -c dub -n '__dub_using_command ${name}' -f -a '${sub.name}' -d '${subDesc}'`, + ); + } + } + } + + return `${out.join('\n')}\n`; +} + +function escapeFish(value: string): string { + // Strip newlines and escape single quotes for the surrounding fish string. + return value.replace(/[\r\n]+/g, ' ').replace(/'/g, "\\'"); +} diff --git a/packages/cli/src/lib/man.ts b/packages/cli/src/lib/man.ts new file mode 100644 index 00000000..42b61d5d --- /dev/null +++ b/packages/cli/src/lib/man.ts @@ -0,0 +1,109 @@ +import type { Command } from 'commander'; +import { describeProgram } from './completion'; + +/** + * Generates roff (groff_man) markup for `dub.1`. Output goes to stdout and is + * intended to be redirected into `~/.local/share/man/man1/dub.1` (or another + * MANPATH location). The page documents the top-level `dub` invocation plus + * every subcommand in a SUBCOMMANDS section. + */ +export function generateManPage( + program: Command, + options: { version: string; date?: string } = { version: 'dev' }, +): string { + const spec = describeProgram(program); + const date = options.date ?? new Date().toISOString().slice(0, 10); + const lines: string[] = []; + + // Apply escapeRoff to version + date even though both originate from + // package.json / new Date() — keeps the .TH line consistent with how every + // other roff value is treated and immune to pre-release tags like + // "1.0.0-beta.1" whose hyphen renders better as \-. + lines.push( + `.TH DUB 1 "${escapeRoff(date)}" "DubStack ${escapeRoff(options.version)}" "User Commands"`, + ); + lines.push('.SH NAME'); + lines.push(`dub \\- ${escapeRoff(spec.description)}`); + lines.push('.SH SYNOPSIS'); + lines.push('.B dub'); + lines.push('[\\fIGLOBAL OPTIONS\\fR]'); + lines.push('\\fICOMMAND\\fR'); + lines.push('[\\fIARGS\\fR]'); + lines.push('.SH DESCRIPTION'); + lines.push( + 'DubStack is a local-first CLI for managing chains of dependent git branches (stacked diffs).', + ); + lines.push( + 'It keeps stack metadata in .git/dubstack and integrates with GitHub PRs.', + ); + + if (spec.options.length > 0) { + lines.push('.SH GLOBAL OPTIONS'); + for (const option of spec.options) { + lines.push('.TP'); + lines.push(`\\fB${escapeRoff(option.flags)}\\fR`); + lines.push(escapeRoff(option.description || '')); + } + } + + lines.push('.SH COMMANDS'); + for (const cmd of spec.commands) { + const aliasSuffix = + cmd.aliases.length > 0 ? ` (aliases: ${cmd.aliases.join(', ')})` : ''; + lines.push('.TP'); + lines.push(`\\fB${escapeRoff(cmd.name)}\\fR${escapeRoff(aliasSuffix)}`); + lines.push(escapeRoff(cmd.description || '')); + if (cmd.options.length > 0) { + lines.push('.RS'); + for (const option of cmd.options) { + lines.push('.TP'); + lines.push(`\\fB${escapeRoff(option.flags)}\\fR`); + lines.push(escapeRoff(option.description || '')); + } + lines.push('.RE'); + } + if (cmd.subcommands.length > 0) { + lines.push('.RS'); + lines.push('.B Subcommands:'); + for (const sub of cmd.subcommands) { + lines.push('.TP'); + lines.push(`\\fB${escapeRoff(`${cmd.name} ${sub.name}`)}\\fR`); + lines.push(escapeRoff(sub.description || '')); + } + lines.push('.RE'); + } + } + + lines.push('.SH FILES'); + lines.push('.TP'); + lines.push('\\fB.git/dubstack/state.json\\fR'); + lines.push('Per-repo stack state. Created by \\fBdub init\\fR.'); + + lines.push('.SH SEE ALSO'); + lines.push('\\fBgit\\fR(1), \\fBgh\\fR(1)'); + + lines.push('.SH AUTHOR'); + lines.push('DubStack contributors. https://github.com/wiseiodev/dubstack'); + + return `${lines.join('\n')}\n`; +} + +/** + * Roff treats lines starting with `.` or `\\'` as commands. We never want + * user-supplied text (description strings, option flags) to accidentally + * trigger a directive; escape leading dots and any backslashes. + */ +function escapeRoff(value: string): string { + if (!value) return ''; + // Collapse newlines so a single description never spans multiple lines. + const collapsed = value.replace(/[\r\n]+/g, ' ').trim(); + const backslashEscaped = collapsed.replace(/\\/g, '\\\\'); + // Hyphens render best as \- in roff so they survive man's hyphenation pass. + const hyphenEscaped = backslashEscaped.replace(/-/g, '\\-'); + // Escape a leading dot or apostrophe so it does not start a directive when + // the value happens to sit at column 0 (rare but possible). + if (hyphenEscaped.startsWith('.') || hyphenEscaped.startsWith("'")) { + return `\\&${hyphenEscaped}`; + } + return hyphenEscaped; +} From ccf069603413ca2c16d53a4c10b5f89c6cb82a71 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 13:51:47 -0700 Subject: [PATCH 2/4] fix(completion): drop branch-arg regex heuristic so subcommands win Adversarial review round 2 found two correctness bugs in the branch- completion router: dub revert (whose target positional is a PR number or SHA) was routed to git-branch completion, and dub trunk returned local branches instead of its subcommand list. Replace the regex with an explicit allow-list (co, up, down, delete, track, untrack) and have zsh prefer subcommand completion whenever a command has any. Add negative tests covering both regressions. Also: escapeFish now doubles backslashes; escapeZsh now also escapes backticks and dollar signs; the zsh __dub_branches helper early-returns on an empty repo so _describe does not show a spurious blank candidate; main installs an EPIPE handler so dub man piped to head exits cleanly; the Homebrew formula passes err: :merge so a broken dub man surfaces in brew test instead of writing a partial man page; docs recommend a user-writable zsh completions directory over the system fpath entry. Completes DUB-67 --- .../content/docs/guides/shell-integration.mdx | 13 +++- homebrew/dubstack.rb | 6 +- packages/cli/src/commands/completion.test.ts | 62 +++++++++++++++++++ packages/cli/src/index.ts | 8 +++ packages/cli/src/lib/completion.ts | 61 +++++++++++++----- 5 files changed, 131 insertions(+), 19 deletions(-) diff --git a/apps/docs/content/docs/guides/shell-integration.mdx b/apps/docs/content/docs/guides/shell-integration.mdx index 1b465675..40d92268 100644 --- a/apps/docs/content/docs/guides/shell-integration.mdx +++ b/apps/docs/content/docs/guides/shell-integration.mdx @@ -124,13 +124,22 @@ the location your shell loads completions from: # bash dub completion bash > ~/.local/share/bash-completion/completions/dub -# zsh — drop into any directory on $fpath, then restart your shell -dub completion zsh > "${fpath[1]}/_dub" +# zsh — write to a user-owned directory you put on $fpath before compinit +mkdir -p ~/.zsh/completions +dub completion zsh > ~/.zsh/completions/_dub +# then ensure these two lines are in ~/.zshrc before any `compinit` call: +# fpath=(~/.zsh/completions $fpath) +# autoload -Uz compinit && compinit # fish dub completion fish > ~/.config/fish/completions/dub.fish ``` +`${fpath[1]}` on most systems points at a root-owned directory like +`/usr/local/share/zsh/site-functions`. Writing there works for system-wide +installs (with `sudo`), but the user-owned path above is the recommended +default. + The generated script completes: - Top-level subcommands (`dub `). diff --git a/homebrew/dubstack.rb b/homebrew/dubstack.rb index b555bc5f..1b6b8b86 100644 --- a/homebrew/dubstack.rb +++ b/homebrew/dubstack.rb @@ -14,9 +14,11 @@ def install bin.install_symlink libexec.glob("bin/*") # Generate the man page and shell completions from the installed binary - # so they always match the version users are running. + # so they always match the version users are running. `err: :merge` folds + # any stderr into the captured output so a failed `dub man` surfaces + # during `brew test` instead of silently writing a partial man page. man1.mkpath - (man1/"dub.1").write Utils.safe_popen_read("#{bin}/dub", "man") + (man1/"dub.1").write Utils.safe_popen_read(bin/"dub", "man", err: :merge) generate_completions_from_executable(bin/"dub", "completion") end diff --git a/packages/cli/src/commands/completion.test.ts b/packages/cli/src/commands/completion.test.ts index 0d6f7d61..a85a4f4e 100644 --- a/packages/cli/src/commands/completion.test.ts +++ b/packages/cli/src/commands/completion.test.ts @@ -31,6 +31,13 @@ function buildFixtureProgram(): Command { .command('split') .option('--by-file ', 'Move specific files') .description('Split branch'); + // `revert ` — positional argName "target" must NOT trigger branch + // completion. `target` is a PR number or commit SHA. + program + .command('revert') + .argument('', 'PR number or commit SHA') + .description('Revert a merged PR or commit'); + // `trunk` has subcommands AND a `[branch]` positional. Subcommands win. const trunk = program.command('trunk').description('Show or manage trunks'); trunk.command('list').description('List configured trunks'); trunk.command('add').argument('').description('Add a trunk'); @@ -88,6 +95,25 @@ describe('completion', () => { } expect(result.status, result.stderr?.toString()).toBe(0); }); + + it('does not branch-complete `revert` (positional is a PR/SHA, not a branch)', () => { + const out = completion(buildFixtureProgram(), 'bash'); + // The branch-arg case enumerates a `|`-separated pattern list. `revert` + // must not appear inside it. We also make sure `revert` is in the + // top-level command list so the negative assertion is meaningful. + expect(out).toContain('revert'); + const branchCase = out.split('Branch-arg subcommands')[1] ?? ''; + const branchPattern = branchCase.split('esac')[0] ?? ''; + expect(branchPattern).not.toMatch(/\brevert\b/); + }); + + it('does not branch-complete `trunk` (subcommands take priority)', () => { + const out = completion(buildFixtureProgram(), 'bash'); + expect(out).toContain('trunk'); + const branchCase = out.split('Branch-arg subcommands')[1] ?? ''; + const branchPattern = branchCase.split('esac')[0] ?? ''; + expect(branchPattern).not.toMatch(/\btrunk\b/); + }); }); describe('zsh', () => { @@ -119,6 +145,26 @@ describe('completion', () => { } expect(result.status, result.stderr?.toString()).toBe(0); }); + + it('prefers subcommand completion over branch completion for parents with both', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + // The `trunk` arm should resolve to a subcommand spec, not branches. + const trunkArm = out.split('trunk)')[1]?.split(';;')[0] ?? ''; + expect(trunkArm).toContain('subcommand'); + expect(trunkArm).not.toContain('__dub_branches'); + }); + + it('does not route `revert` to __dub_branches', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + const revertArm = out.split('revert)')[1]?.split(';;')[0] ?? ''; + expect(revertArm).not.toContain('__dub_branches'); + }); + + it('guards __dub_branches against empty repos', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + // Empty `git for-each-ref` output should short-circuit before _describe. + expect(out).toMatch(/\[\[ -z \$raw \]\] && return/); + }); }); describe('fish', () => { @@ -154,5 +200,21 @@ describe('completion', () => { } expect(result.status, result.stderr?.toString()).toBe(0); }); + + it('escapes backslashes in option descriptions', () => { + const program = new Command(); + program.name('dub'); + program + .command('weird') + .option('--path

', 'Windows-style path like C:\\Users\\foo'); + const out = completion(program, 'fish'); + // Backslash must be doubled so fish does not interpret it as an escape. + expect(out).toContain('C:\\\\Users\\\\foo'); + }); + + it('does not branch-complete `revert`', () => { + const out = completion(buildFixtureProgram(), 'fish'); + expect(out).not.toMatch(/__dub_using_command revert.*\(__dub_branches\)/); + }); }); }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 65d2e06d..25494343 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3658,6 +3658,14 @@ program.hook('postAction', async () => { }); async function main() { + // `dub completion bash | source /dev/stdin`, `dub man | head -1`, and + // similar pipelines close stdout before we finish writing. Without this + // listener Node 22 surfaces EPIPE as an unhandled error and exits 1. + // Treat a closed stdout as a normal early-exit signal. + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0); + }); + try { const rawArgs = process.argv.slice(2); historyArgsForCapture = rawArgs; diff --git a/packages/cli/src/lib/completion.ts b/packages/cli/src/lib/completion.ts index e1763518..480afc5c 100644 --- a/packages/cli/src/lib/completion.ts +++ b/packages/cli/src/lib/completion.ts @@ -92,20 +92,28 @@ export function describeProgram(program: Command): ProgramSpec { function describeCommand(cmd: CommandLike, ancestors: string[]): CommandSpec { const name = cmd.name(); + const aliases = cmd.aliases(); const argSpecs = (cmd._args ?? []).map((arg) => { const argName = typeof arg.name === 'function' ? arg.name() : (arg._name ?? ''); return argName; }); const usageArgs = argSpecs.join(' '); - const lower = `${name} ${usageArgs}`.toLowerCase(); + // Allow-list only. A broader regex over the description used to match + // `dub revert ` (a PR number / SHA) and `dub trunk` (which has + // subcommands), producing misleading branch completions. const branchPositional = BRANCH_ARG_COMMANDS.includes( name as (typeof BRANCH_ARG_COMMANDS)[number], - ) || /\b(branch|target|trunk)\b/.test(lower); + ) || + aliases.some((alias) => + BRANCH_ARG_COMMANDS.includes( + alias as (typeof BRANCH_ARG_COMMANDS)[number], + ), + ); return { name, - aliases: cmd.aliases(), + aliases, description: cmd.description(), options: cmd.options.map(describeOption), subcommands: cmd.commands.map((c) => @@ -314,12 +322,18 @@ export function generateZshCompletion(program: Command): string { # dub zsh completion # # Usage: -# dub completion zsh > "\${fpath[1]}/_dub" -# # then restart your shell or run: compinit +# mkdir -p ~/.zsh/completions +# dub completion zsh > ~/.zsh/completions/_dub +# # ensure ~/.zshrc has, before compinit: +# # fpath=(~/.zsh/completions $fpath) +# # autoload -Uz compinit && compinit __dub_branches() { local -a branches - branches=("\${(@f)$(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)}") + local raw + raw=$(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null) + [[ -z $raw ]] && return + branches=("\${(@f)raw}") _describe -t branches 'branch' branches } @@ -369,23 +383,33 @@ function zshOptionSpec(option: OptionSpec): string { } function zshArgSpec(cmd: CommandSpec): string { - if (cmd.takesBranchArg) { - return `'*::branch:__dub_branches'`; - } - if (cmd.takesFileArg) { - return `'*::file:_files'`; - } + // Subcommands take priority: `dub trunk ` must offer list/add/remove, + // not branch names, even if the parent declares a [branch] positional. if (cmd.subcommands.length > 0) { const subs = cmd.subcommands .map((s) => `'${s.name}:${escapeZsh(s.description)}'`) .join(' '); return `'1:subcommand:((${subs}))'`; } + if (cmd.takesBranchArg) { + return `'*::branch:__dub_branches'`; + } + if (cmd.takesFileArg) { + return `'*::file:_files'`; + } return `':: :->done'`; } function escapeZsh(value: string): string { - return value.replace(/'/g, "'\\''").replace(/[[\]:]/g, '\\$&'); + // Single-quoted zsh strings are mostly literal — the only character that + // needs special treatment is `'` itself (close-quote-then-reopen idiom). + // Escape `[`, `]`, and `:` because they are syntactically meaningful inside + // _arguments option specs and _describe candidate strings. Also escape + // backticks and dollar signs defensively: even inside single quotes they + // do not expand, but when an _arguments spec is reconstructed by zsh some + // contexts re-evaluate the description, so neutralising them keeps the + // generator output robust against future zsh quirks. + return value.replace(/'/g, "'\\''").replace(/[[\]:`$]/g, '\\$&'); } export function generateFishCompletion(program: Command): string { @@ -466,6 +490,13 @@ export function generateFishCompletion(program: Command): string { } function escapeFish(value: string): string { - // Strip newlines and escape single quotes for the surrounding fish string. - return value.replace(/[\r\n]+/g, ' ').replace(/'/g, "\\'"); + // Fish single-quoted strings honor `\\` and `\'` as escapes, so a literal + // backslash in a description must be doubled before any single-quote + // escaping happens. Order matters: backslash first, then quote, then + // collapse newlines so a multi-line description never breaks the + // surrounding `complete -d '...'`. + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/[\r\n]+/g, ' '); } From 6ad8cde406cba4d11936626938578fb0b433e739 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 14:01:32 -0700 Subject: [PATCH 3/4] fix(completion): nested command paths, branch-valued flag routing, escape hardening Addresses Copilot and CodeQL review feedback on PR #107: Bash now walks the argv tokens to find the deepest matching command path, so dub config ai-provider -- dispatches to ai-provider's flags rather than config's. Zsh emits a second-level state machine for parent commands with subcommands; fish gains a __dub_using_nested predicate. Branch-valued option flags (--parent, --branch, --before, --after) and file-valued flags now drive the right completer in every shell, not just bash. Branch-name completion is now allow-list only: up and down were removed (they take a numeric step count), and the broad regex over the command description is gone so dub revert no longer offers branches for what is a PR number or SHA. escapeZsh now escapes backslashes too (CodeQL js/incomplete-sanitization on alerts 7+8). FILE_VALUE_FLAGS now lists the real --by-file flag instead of a phantom --input-file. The man-page renderer recurses through nested subcommands so dub config ai-provider and similar are documented with their own options instead of just their names; the alias suffix now keeps its leading space outside escapeRoff so labels read "checkout (aliases: co)". Tests: +7 covering nested paths, branch-valued zsh/fish routing, alias spacing, and CodeQL backslash escape coverage. 35 completion + man tests pass, full suite 1480/1480. Completes DUB-67 --- README.md | 9 +- .../content/docs/guides/shell-integration.mdx | 12 +- packages/cli/src/commands/completion.test.ts | 86 ++++- packages/cli/src/commands/man.test.ts | 30 ++ packages/cli/src/lib/completion.ts | 318 ++++++++++++++---- packages/cli/src/lib/man.ts | 63 ++-- 6 files changed, 411 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index f1bf3993..648e707f 100644 --- a/README.md +++ b/README.md @@ -771,9 +771,12 @@ dub man > ~/.local/share/man/man1/dub.1 man dub ``` -Completions cover top-level subcommands, per-command flags, and local branch -names for `co`, `up`, `down`, `delete`, `track`, and `untrack`. Regenerate -after upgrading `dub` to pick up new commands and flags. Full docs: +Completions cover top-level subcommands, nested subcommands and their flags +(e.g. `dub config ai-provider `), per-command flags, and local branch +names for `co`/`checkout`, `delete`, `track`, and `untrack`. Branch-valued +flags (`--parent`, `--branch`, `--before`, `--after`) also complete local +branches. Regenerate after upgrading `dub` to pick up new commands and +flags. Full docs: [`apps/docs/content/docs/guides/shell-integration.mdx`](apps/docs/content/docs/guides/shell-integration.mdx). ### `dub skills` diff --git a/apps/docs/content/docs/guides/shell-integration.mdx b/apps/docs/content/docs/guides/shell-integration.mdx index 40d92268..43e17d3e 100644 --- a/apps/docs/content/docs/guides/shell-integration.mdx +++ b/apps/docs/content/docs/guides/shell-integration.mdx @@ -143,11 +143,17 @@ default. The generated script completes: - Top-level subcommands (`dub `). -- Local branch names for branch-arg commands (`dub co `, `dub up `, - `dub down `, `dub delete `, `dub track `, `dub untrack `). +- Nested subcommands and their flags (e.g. `dub config ai-provider `, + `dub trunk add `). +- Local branch names for branch-arg commands: `dub co ` (alias + `checkout`), `dub delete `, `dub track `, `dub untrack `. + (`dub up` and `dub down` take a numeric step count, not a branch.) +- Branch names for branch-valued flags: `--parent`, `--branch`, `--before`, + `--after`. +- File paths for `--by-file` and for commands that take positional file + arguments. - Per-command flags read directly from the `dub` binary's commander metadata, so completions stay in sync with the CLI version installed in `$PATH`. -- File paths for commands that take a file argument. Regenerate the script after upgrading `dub` so new flags and commands show up. diff --git a/packages/cli/src/commands/completion.test.ts b/packages/cli/src/commands/completion.test.ts index a85a4f4e..587c6c7e 100644 --- a/packages/cli/src/commands/completion.test.ts +++ b/packages/cli/src/commands/completion.test.ts @@ -4,6 +4,20 @@ import { describe, expect, it } from 'vitest'; import { DubError } from '../lib/errors'; import { completion } from './completion'; +/** + * Extracts the body of the bash `case "$path" in ... esac` branch-arg + * dispatch block, which lists every command path that triggers branch-name + * completion. Used to assert presence / absence of specific paths without + * relying on fragile substring matches against the surrounding generator + * boilerplate. + */ +function extractBashBranchCase(out: string): string { + const match = out.match( + /Branch-arg commands:[\s\S]*?case "\$path" in([\s\S]*?)esac/, + ); + return match ? match[1] : ''; +} + function buildFixtureProgram(): Command { const program = new Command(); program.name('dub').description('manage stacked diffs'); @@ -98,21 +112,31 @@ describe('completion', () => { it('does not branch-complete `revert` (positional is a PR/SHA, not a branch)', () => { const out = completion(buildFixtureProgram(), 'bash'); - // The branch-arg case enumerates a `|`-separated pattern list. `revert` - // must not appear inside it. We also make sure `revert` is in the - // top-level command list so the negative assertion is meaningful. + // Locate the `case "$path"` branch-arg dispatch block and verify + // `revert` isn't listed as a branch-arg path. + const branchBlock = extractBashBranchCase(out); expect(out).toContain('revert'); - const branchCase = out.split('Branch-arg subcommands')[1] ?? ''; - const branchPattern = branchCase.split('esac')[0] ?? ''; - expect(branchPattern).not.toMatch(/\brevert\b/); + expect(branchBlock).not.toMatch(/\brevert\b/); }); it('does not branch-complete `trunk` (subcommands take priority)', () => { const out = completion(buildFixtureProgram(), 'bash'); + const branchBlock = extractBashBranchCase(out); expect(out).toContain('trunk'); - const branchCase = out.split('Branch-arg subcommands')[1] ?? ''; - const branchPattern = branchCase.split('esac')[0] ?? ''; - expect(branchPattern).not.toMatch(/\btrunk\b/); + // Top-level `trunk` is in subcommand position and should not branch- + // complete. Nested keys like `trunk::list` are leaves with no branch + // arg either. + expect(branchBlock).not.toMatch(/(^|\|)trunk(\||\))/); + }); + + it('emits nested command paths for parents with subcommands', () => { + const out = completion(buildFixtureProgram(), 'bash'); + // The fixture's `trunk` has subcommands `list` and `add`. The path + // walker registers them so `dub trunk add ` dispatches to the + // nested case. + expect(out).toContain('trunk::list'); + expect(out).toContain('trunk::add'); + expect(out).toContain('__dub_walk_path'); }); }); @@ -165,6 +189,28 @@ describe('completion', () => { // Empty `git for-each-ref` output should short-circuit before _describe. expect(out).toMatch(/\[\[ -z \$raw \]\] && return/); }); + + it('routes --parent value completion through __dub_branches', () => { + const out = completion(buildFixtureProgram(), 'zsh'); + // The fixture's `track` has `-p, --parent `. The generated + // option spec must mark the value as branch-completed. + expect(out).toMatch(/--parent[^']*:branch:__dub_branches/); + }); + + it('completes nested subcommand options (e.g. trunk add flags)', () => { + const program = new Command(); + program.name('dub'); + const trunk = program.command('trunk').description('Manage trunks'); + trunk + .command('add') + .argument('', 'Trunk name') + .option('--default', 'Mark this trunk as default') + .description('Add a trunk'); + const out = completion(program, 'zsh'); + // The nested `add` arm should declare --default as one of its flags. + const subargs = out.split('subargs)')[1]?.split('esac')[0] ?? ''; + expect(subargs).toContain('--default'); + }); }); describe('fish', () => { @@ -216,5 +262,27 @@ describe('completion', () => { const out = completion(buildFixtureProgram(), 'fish'); expect(out).not.toMatch(/__dub_using_command revert.*\(__dub_branches\)/); }); + + it('routes --parent value through __dub_branches', () => { + const out = completion(buildFixtureProgram(), 'fish'); + // The `track` command's `--parent ` option should explicitly + // supply branch candidates via `-a '(__dub_branches)'`. + expect(out).toMatch( + /__dub_using_command track.* -l parent.*'\(__dub_branches\)'/, + ); + }); + + it('emits nested-subcommand flag completion via __dub_using_nested', () => { + const program = new Command(); + program.name('dub'); + const trunk = program.command('trunk').description('Manage trunks'); + trunk + .command('add') + .argument('', 'Trunk name') + .option('--default', 'Mark this trunk as default') + .description('Add a trunk'); + const out = completion(program, 'fish'); + expect(out).toMatch(/__dub_using_nested trunk add.* -l default/); + }); }); }); diff --git a/packages/cli/src/commands/man.test.ts b/packages/cli/src/commands/man.test.ts index b3c339b4..6788a44c 100644 --- a/packages/cli/src/commands/man.test.ts +++ b/packages/cli/src/commands/man.test.ts @@ -71,6 +71,36 @@ describe('man', () => { expect(out).toContain('trunk add'); }); + it('documents options on nested subcommands (not only the parent)', () => { + const program = new Command(); + program.name('dub').description('manage stacked diffs'); + const cfg = program.command('config').description('Manage config'); + cfg + .command('ai-provider') + .option('--clear', 'Clear the override') + .description('Set the AI provider'); + const out = man(program, { version: '1.0.0' }); + // The nested command should appear as a labelled .TP entry with its + // option in an indented .RS block underneath. Hyphens render as `\-` + // in roff after escapeRoff. + expect(out).toContain('config ai\\-provider'); + expect(out).toContain('\\-\\-clear'); + expect(out).toContain('Clear the override'); + }); + + it('renders alias suffix with a space separating it from the command name', () => { + const program = new Command(); + program.name('dub').description('manage stacked diffs'); + program.command('checkout').alias('co').description('Checkout a branch'); + const out = man(program, { version: '1.0.0' }); + // Regression: escapeRoff used to strip the leading space inside the + // suffix, producing "checkout(aliases: co)". Now the space lives + // outside the escaped fragment. + expect(out).toMatch( + /checkout\\fR \\&\(aliases: co\)|checkout\\fR \(aliases: co\)/, + ); + }); + it('escapes hyphens as \\- so man rendering preserves them', () => { const out = man(buildFixtureProgram(), { version: '1.0.0' }); // Description "Print verbose output" contains no hyphen, but diff --git a/packages/cli/src/lib/completion.ts b/packages/cli/src/lib/completion.ts index 480afc5c..1add3a46 100644 --- a/packages/cli/src/lib/completion.ts +++ b/packages/cli/src/lib/completion.ts @@ -11,12 +11,12 @@ export type CompletionShell = 'bash' | 'zsh' | 'fish'; /** * Subcommands that accept a branch name as their primary positional argument. * Used to drive shell-level branch-name completion via `git for-each-ref`. + * `up` and `down` take a numeric step count, not a branch, so they are + * intentionally absent. */ const BRANCH_ARG_COMMANDS = [ 'checkout', 'co', - 'up', - 'down', 'delete', 'untrack', 'track', @@ -31,11 +31,13 @@ const BRANCH_VALUE_FLAGS = ['--parent', '--branch', '--before', '--after']; /** * Flags whose value should be completed with file paths (default shell - * filename completion). + * filename completion). Limited to the long flag name — the same shell-level + * file completion runs for both `--by-file foo.ts bar.ts` and any future + * variadic file-valued option. */ -const FILE_VALUE_FLAGS = ['--input-file', '--profile']; +const FILE_VALUE_FLAGS = ['--by-file']; -interface OptionSpec { +export interface OptionSpec { flags: string; long: string | null; short: string | null; @@ -43,7 +45,7 @@ interface OptionSpec { description: string; } -interface CommandSpec { +export interface CommandSpec { name: string; aliases: string[]; description: string; @@ -55,7 +57,7 @@ interface CommandSpec { takesFileArg: boolean; } -interface ProgramSpec { +export interface ProgramSpec { name: string; description: string; options: OptionSpec[]; @@ -159,44 +161,91 @@ function commandFlagList(cmd: CommandSpec): string[] { return cmd.options.flatMap(flagTokens); } +interface CommandPath { + /** Joined with `::` for use as a bash case key. */ + key: string; + /** Pipe-joined name + aliases for the *terminal* name only. */ + patterns: string; + /** All `::`-joined keys that should dispatch to this entry (including aliases). */ + aliasKeys: string[]; + flags: string[]; + /** Names of any direct subcommands (including aliases). */ + childNames: string[]; + cmd: CommandSpec; +} + +/** + * Flatten the command tree into per-path entries — one for every reachable + * `dub` invocation, top-level and nested. `dub config ai-provider` + * collapses to key `config::ai-provider`, with `aliasKeys` accounting for + * the parent's aliases too so e.g. `dub co ` and `dub checkout ` + * dispatch identically. + */ +function enumerateCommandPaths(commands: CommandSpec[]): CommandPath[] { + const out: CommandPath[] = []; + const walk = (cmd: CommandSpec, parents: string[][]) => { + // parents is a list of equivalent ancestor-path arrays (each one a + // valid concrete path via aliases). For the root the list is `[[]]`. + const selfNames = [cmd.name, ...cmd.aliases]; + const ownPaths: string[][] = []; + for (const parent of parents) { + for (const name of selfNames) ownPaths.push([...parent, name]); + } + const key = ownPaths[0].join('::'); + const aliasKeys = ownPaths.map((p) => p.join('::')); + out.push({ + key, + patterns: selfNames.join('|'), + aliasKeys, + flags: commandFlagList(cmd), + childNames: cmd.subcommands.flatMap((s) => [s.name, ...s.aliases]), + cmd, + }); + for (const sub of cmd.subcommands) walk(sub, ownPaths); + }; + for (const cmd of commands) walk(cmd, [[]]); + return out; +} + export function generateBashCompletion(program: Command): string { const spec = describeProgram(program); const topLevel = collectCommandNames(spec).join(' '); const branchValueFlags = BRANCH_VALUE_FLAGS.join('|'); const fileValueFlags = FILE_VALUE_FLAGS.join('|'); - // Build a case branch per command resolving to its flag set + arg behavior. + const paths = enumerateCommandPaths(spec.commands); + + // Per-path case entries. Includes top-level and any nested subcommand + // paths so `dub config ai-provider --` lists that nested command's + // flags rather than the parent's. const cmdCases: string[] = []; - const branchArgCmds: string[] = []; - const fileArgCmds: string[] = []; - for (const cmd of spec.commands) { - const flagList = commandFlagList(cmd); - const subNames = cmd.subcommands.flatMap((s) => [s.name, ...s.aliases]); - // Single-quote the completion list. Flags from commander are kebab-case - // (`--foo-bar`, `-x`), so they cannot contain single quotes; this keeps - // any `$` or `"` in a hypothetical future flag from being expanded by - // bash inside the generated case body. - const completions = [...flagList, ...subNames].join(' '); - const patterns = [cmd.name, ...cmd.aliases].join('|'); + const branchArgKeys: string[] = []; + const fileArgKeys: string[] = []; + // Index of every known command path key, used by __dub_walk_path to know + // how deep to descend before stopping. + const knownKeys = new Set(); + for (const p of paths) { + for (const k of p.aliasKeys) knownKeys.add(k); + const completions = [...p.flags, ...p.childNames].join(' '); + // The case label must accept every alias-key variant — bash patterns + // can't be empty, so we join with `|`. + const label = p.aliasKeys.join('|'); cmdCases.push( - ` ${patterns})\n __dub_complete_words '${completions}'\n ;;`, + ` ${label})\n __dub_complete_words '${completions}'\n ;;`, ); - if (cmd.takesBranchArg) { - branchArgCmds.push(...[cmd.name, ...cmd.aliases]); - } - if (cmd.takesFileArg) { - fileArgCmds.push(...[cmd.name, ...cmd.aliases]); - } + if (p.cmd.takesBranchArg) branchArgKeys.push(...p.aliasKeys); + if (p.cmd.takesFileArg) fileArgKeys.push(...p.aliasKeys); } - const branchArgPattern = branchArgCmds.join('|'); - const fileArgPattern = fileArgCmds.join('|'); + const branchArgPattern = branchArgKeys.join('|'); + const fileArgPattern = fileArgKeys.join('|'); + const knownKeysLiteral = [...knownKeys].sort().join(' '); // Wrap each per-pattern case in a conditional so empty patterns do not // produce `case "$x" in )` — bash treats that as a syntax error. const branchArgCase = branchArgPattern - ? ` if [[ "$cur" != -* ]] && [[ "$cword" -eq 2 ]]; then - case "$subcmd" in + ? ` if [[ "$cur" != -* ]] && [[ -n "$path" ]]; then + case "$path" in ${branchArgPattern}) __dub_compreply_lines "$(__dub_branches)" return 0 @@ -206,7 +255,7 @@ export function generateBashCompletion(program: Command): string { : ''; const fileArgCase = fileArgPattern ? ` if [[ "$cur" != -* ]]; then - case "$subcmd" in + case "$path" in ${fileArgPattern}) COMPREPLY=( $(compgen -f -- "$cur") ) return 0 @@ -240,6 +289,34 @@ __dub_complete_words() { COMPREPLY=( $(compgen -W "$words" -- "$cur") ) } +# Returns the deepest known command path (e.g. "config::ai-provider") for +# the current argv, descending through nested subcommands while skipping +# flags and their values. The path is whatever portion of the argv we can +# confidently classify; flag completion is left to the per-path case below. +__dub_known_keys=" ${knownKeysLiteral} " +__dub_walk_path() { + local idx=1 next path="" + while [ "$idx" -lt "$cword" ]; do + next="\${words[$idx]}" + if [[ "$next" == -* ]]; then + idx=$((idx+1)) + continue + fi + local candidate + if [ -z "$path" ]; then + candidate="$next" + else + candidate="\${path}::$next" + fi + case "$__dub_known_keys" in + *" $candidate "*) path="$candidate" ;; + *) break ;; + esac + idx=$((idx+1)) + done + echo "$path" +} + _dub() { local cur prev words cword cur="\${COMP_WORDS[COMP_CWORD]}" @@ -265,17 +342,18 @@ _dub() { return 0 fi - local subcmd="\${words[1]}" + local path + path="$(__dub_walk_path)" - # Branch-arg subcommands: complete with local branches when the user is on - # the first positional and the current token is not a flag. + # Branch-arg commands: complete with local branches when the user is on + # a positional position and the current token is not a flag. ${branchArgCase} - # File-arg subcommands fall through to default file completion. + # File-arg commands fall through to default file completion. ${fileArgCase} # Otherwise complete that command's flags/subcommands. - case "$subcmd" in + case "$path" in ${cmdCases.join('\n')} *) COMPREPLY=( $(compgen -W "--help" -- "$cur") ) @@ -302,9 +380,58 @@ export function generateZshCompletion(program: Command): string { ) .join('\n'); + // Per-top-level case branches. When a command has subcommands we route to + // a second-level case so nested invocations like `dub config ai-provider` + // complete that subcommand's flags rather than the parent's. const subcommandCases = spec.commands .map((cmd) => { const patterns = [cmd.name, ...cmd.aliases].join('|'); + if (cmd.subcommands.length > 0) { + const nestedCases = cmd.subcommands + .map((sub) => { + const subPatterns = [sub.name, ...sub.aliases].join('|'); + const subFlags = sub.options + .map(zshOptionSpec) + .filter(Boolean) + .join(' \\\n '); + const subArg = zshArgSpec(sub); + return ` ${subPatterns}) + _arguments -s \\ + ${subFlags || ':: :->done'} \\ + ${subArg} + ;;`; + }) + .join('\n'); + const subDescribe = cmd.subcommands + .map((sub) => ` '${sub.name}:${escapeZsh(sub.description)}'`) + .join('\n'); + const parentFlags = cmd.options + .map(zshOptionSpec) + .filter(Boolean) + .join(' \\\n '); + return ` ${patterns}) + _arguments -C \\ + ${parentFlags ? `${parentFlags} \\\n ` : ''}'1: :->sub' \\ + '*:: :->subargs' + case $state in + sub) + local -a subs + subs=( +${subDescribe} + ) + _describe -t subcommands '${cmd.name} subcommand' subs + ;; + subargs) + case "\${line[1]}" in +${nestedCases} + *) + _message 'no more arguments' + ;; + esac + ;; + esac + ;;`; + } const flagSpecs = cmd.options .map(zshOptionSpec) .filter(Boolean) @@ -377,11 +504,25 @@ function zshOptionSpec(option: OptionSpec): string { const head = tokens.length > 1 ? `(${tokens.join(' ')})` : ''; const flag = tokens[0]; if (option.takesValue) { - return `'${head}${flag}[${desc}]:value:'`; + // Route option values through the right completer when the flag is on + // the branch- or file-valued allow-list. Falls back to generic `:value:` + // for plain string options. + const valueAction = optionValueAction(option); + return `'${head}${flag}[${desc}]${valueAction}'`; } return `'${head}${flag}[${desc}]'`; } +function optionValueAction(option: OptionSpec): string { + if (option.long && BRANCH_VALUE_FLAGS.includes(option.long)) { + return ':branch:__dub_branches'; + } + if (option.long && FILE_VALUE_FLAGS.includes(option.long)) { + return ':file:_files'; + } + return ':value:'; +} + function zshArgSpec(cmd: CommandSpec): string { // Subcommands take priority: `dub trunk ` must offer list/add/remove, // not branch names, even if the parent declares a [branch] positional. @@ -401,15 +542,17 @@ function zshArgSpec(cmd: CommandSpec): string { } function escapeZsh(value: string): string { - // Single-quoted zsh strings are mostly literal — the only character that - // needs special treatment is `'` itself (close-quote-then-reopen idiom). - // Escape `[`, `]`, and `:` because they are syntactically meaningful inside - // _arguments option specs and _describe candidate strings. Also escape - // backticks and dollar signs defensively: even inside single quotes they - // do not expand, but when an _arguments spec is reconstructed by zsh some - // contexts re-evaluate the description, so neutralising them keeps the - // generator output robust against future zsh quirks. - return value.replace(/'/g, "'\\''").replace(/[[\]:`$]/g, '\\$&'); + // Escape backslashes first so the subsequent escape passes can't re-double + // a backslash we added ourselves. Then handle single quotes (close-then- + // reopen idiom inside a single-quoted string), and finally the characters + // zsh treats specially inside _arguments option specs and _describe + // candidate strings: `[`, `]`, `:`. Backticks and dollar signs are + // neutralised defensively — even inside single quotes they do not expand, + // but some _arguments contexts re-evaluate the description. + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''") + .replace(/[[\]:`$]/g, '\\$&'); } export function generateFishCompletion(program: Command): string { @@ -430,6 +573,15 @@ export function generateFishCompletion(program: Command): string { out.push('function __dub_using_command'); out.push(' set -l cmd (commandline -opc)'); out.push(' test (count $cmd) -ge 2; and test $cmd[2] = $argv[1]'); + out.push(' and test (count $cmd) -lt 3'); + out.push('end'); + out.push(''); + // Predicate for nested subcommands. argv[1] = parent, argv[2] = child. + out.push('function __dub_using_nested'); + out.push(' set -l cmd (commandline -opc)'); + out.push( + ' test (count $cmd) -ge 3; and test $cmd[2] = $argv[1]; and test $cmd[3] = $argv[2]', + ); out.push('end'); out.push(''); out.push('function __dub_no_subcommand'); @@ -456,32 +608,36 @@ export function generateFishCompletion(program: Command): string { for (const cmd of spec.commands) { const names = [cmd.name, ...cmd.aliases]; for (const name of names) { - // Branch arg - if (cmd.takesBranchArg) { + const usingCondition = `__dub_using_command ${name}`; + if (cmd.subcommands.length > 0) { + // Subcommand names are valid completions at depth 1. + for (const sub of cmd.subcommands) { + const subDesc = escapeFish(sub.description); + out.push( + `complete -c dub -n '${usingCondition}' -f -a '${sub.name}' -d '${subDesc}'`, + ); + } + } else if (cmd.takesBranchArg) { out.push( - `complete -c dub -n '__dub_using_command ${name}' -f -a '(__dub_branches)'`, + `complete -c dub -n '${usingCondition}' -f -a '(__dub_branches)'`, ); } else if (cmd.takesFileArg) { - out.push(`complete -c dub -n '__dub_using_command ${name}' -F`); - } - // Flags - for (const option of cmd.options) { - const long = option.long ? option.long.replace(/^--/, '') : ''; - const short = option.short ? option.short.replace(/^-/, '') : ''; - const desc = escapeFish(option.description || ''); - const parts = [`complete -c dub -n '__dub_using_command ${name}'`]; - if (long) parts.push(`-l ${long}`); - if (short) parts.push(`-s ${short}`); - if (option.takesValue) parts.push('-r'); - parts.push(`-d '${desc}'`); - out.push(parts.join(' ')); + out.push(`complete -c dub -n '${usingCondition}' -F`); } - // Nested subcommand names + emitFishFlagCompletions(out, cmd.options, usingCondition); + + // Nested subcommands get their own predicate so e.g. + // `dub config ai-provider --` lists ai-provider's flags. for (const sub of cmd.subcommands) { - const subDesc = escapeFish(sub.description); - out.push( - `complete -c dub -n '__dub_using_command ${name}' -f -a '${sub.name}' -d '${subDesc}'`, - ); + const nestedCondition = `__dub_using_nested ${name} ${sub.name}`; + if (sub.takesBranchArg) { + out.push( + `complete -c dub -n '${nestedCondition}' -f -a '(__dub_branches)'`, + ); + } else if (sub.takesFileArg) { + out.push(`complete -c dub -n '${nestedCondition}' -F`); + } + emitFishFlagCompletions(out, sub.options, nestedCondition); } } } @@ -489,6 +645,34 @@ export function generateFishCompletion(program: Command): string { return `${out.join('\n')}\n`; } +function emitFishFlagCompletions( + out: string[], + options: OptionSpec[], + condition: string, +): void { + for (const option of options) { + const long = option.long ? option.long.replace(/^--/, '') : ''; + const short = option.short ? option.short.replace(/^-/, '') : ''; + const desc = escapeFish(option.description || ''); + const parts = [`complete -c dub -n '${condition}'`]; + if (long) parts.push(`-l ${long}`); + if (short) parts.push(`-s ${short}`); + if (option.takesValue) { + parts.push('-r'); + // Surface branch/file completers for value-taking flags that the + // allow-lists know about. Plain string flags still default to the + // shell's no-suggestion behavior, which is what fish users expect. + if (option.long && BRANCH_VALUE_FLAGS.includes(option.long)) { + parts.push("-a '(__dub_branches)'"); + } else if (option.long && FILE_VALUE_FLAGS.includes(option.long)) { + parts.push('-F'); + } + } + parts.push(`-d '${desc}'`); + out.push(parts.join(' ')); + } +} + function escapeFish(value: string): string { // Fish single-quoted strings honor `\\` and `\'` as escapes, so a literal // backslash in a description must be doubled before any single-quote diff --git a/packages/cli/src/lib/man.ts b/packages/cli/src/lib/man.ts index 42b61d5d..27df38e4 100644 --- a/packages/cli/src/lib/man.ts +++ b/packages/cli/src/lib/man.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { describeProgram } from './completion'; +import { type CommandSpec, describeProgram } from './completion'; /** * Generates roff (groff_man) markup for `dub.1`. Output goes to stdout and is @@ -48,30 +48,7 @@ export function generateManPage( lines.push('.SH COMMANDS'); for (const cmd of spec.commands) { - const aliasSuffix = - cmd.aliases.length > 0 ? ` (aliases: ${cmd.aliases.join(', ')})` : ''; - lines.push('.TP'); - lines.push(`\\fB${escapeRoff(cmd.name)}\\fR${escapeRoff(aliasSuffix)}`); - lines.push(escapeRoff(cmd.description || '')); - if (cmd.options.length > 0) { - lines.push('.RS'); - for (const option of cmd.options) { - lines.push('.TP'); - lines.push(`\\fB${escapeRoff(option.flags)}\\fR`); - lines.push(escapeRoff(option.description || '')); - } - lines.push('.RE'); - } - if (cmd.subcommands.length > 0) { - lines.push('.RS'); - lines.push('.B Subcommands:'); - for (const sub of cmd.subcommands) { - lines.push('.TP'); - lines.push(`\\fB${escapeRoff(`${cmd.name} ${sub.name}`)}\\fR`); - lines.push(escapeRoff(sub.description || '')); - } - lines.push('.RE'); - } + renderCommandEntry(lines, cmd, [cmd.name]); } lines.push('.SH FILES'); @@ -88,6 +65,42 @@ export function generateManPage( return `${lines.join('\n')}\n`; } +/** + * Recursively renders a command (and any nested subcommands) as a `.TP` + * entry plus its options. The original implementation only listed nested + * subcommand names without their options, which left useful surfaces like + * `dub config ai-provider` and `dub skills add` undocumented. + */ +function renderCommandEntry( + lines: string[], + cmd: CommandSpec, + path: string[], +): void { + const fullName = path.join(' '); + // Build the label with a literal leading space outside escapeRoff so the + // trim() inside the escaper doesn't collapse the spacing between the + // command name and the alias suffix. + const aliasSuffix = + cmd.aliases.length > 0 ? ` (aliases: ${cmd.aliases.join(', ')})` : ''; + lines.push('.TP'); + lines.push( + `\\fB${escapeRoff(fullName)}\\fR${aliasSuffix ? ` ${escapeRoff(aliasSuffix.trimStart())}` : ''}`, + ); + lines.push(escapeRoff(cmd.description || '')); + if (cmd.options.length > 0) { + lines.push('.RS'); + for (const option of cmd.options) { + lines.push('.TP'); + lines.push(`\\fB${escapeRoff(option.flags)}\\fR`); + lines.push(escapeRoff(option.description || '')); + } + lines.push('.RE'); + } + for (const sub of cmd.subcommands) { + renderCommandEntry(lines, sub, [...path, sub.name]); + } +} + /** * Roff treats lines starting with `.` or `\\'` as commands. We never want * user-supplied text (description strings, option flags) to accidentally From 23fe4a7ea975ef4059e159fc4f16ebd6f3564112 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 14:06:07 -0700 Subject: [PATCH 4/4] fix(test): strip ANSI escapes in roff render assertion CI Linux runs the man render check through groff, which emits SGR (ANSI) escape sequences for bold/underline (\e[1m, \e[7m, \e[4m, ...). The reverse- video sequence around "DUB" then "(1)" was breaking the substring assertion that the rendered output contains "DUB(1)". macOS mandoc was untouched because it uses backspace overstrike (N\bN) instead. Strip ANSI CSI sequences alongside the existing backspace stripper. The stripped output now contains "DUB(1)" on both formatters. Completes DUB-67 --- packages/cli/src/commands/man.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/man.test.ts b/packages/cli/src/commands/man.test.ts index 6788a44c..e3ec418a 100644 --- a/packages/cli/src/commands/man.test.ts +++ b/packages/cli/src/commands/man.test.ts @@ -135,13 +135,14 @@ describe('man', () => { continue; } expect(result.status, result.stderr).toBe(0); - // Strip backspace overstrike (used by formatters to bold/underline) so - // assertions match the rendered text regardless of formatter choice. + // Strip backspace overstrike (mandoc on macOS uses `N\bN` to mean + // "bold N") and ANSI escape sequences (groff on Linux emits SGR codes + // for bold/underline) so the assertions are robust across formatters. + const esc = String.fromCharCode(27); const backspace = String.fromCharCode(8); - const stripped = result.stdout.replace( - new RegExp(`.${backspace}`, 'g'), - '', - ); + const stripped = result.stdout + .replace(new RegExp(`.${backspace}`, 'g'), '') + .replace(new RegExp(`${esc}\\[[0-9;]*[A-Za-z]`, 'g'), ''); expect(stripped).toContain('DUB(1)'); expect(stripped).toContain('NAME'); expect(stripped).toContain('init');