diff --git a/README.md b/README.md index 9859a2ee..648e707f 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,30 @@ 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, 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` 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..43e17d3e 100644 --- a/apps/docs/content/docs/guides/shell-integration.mdx +++ b/apps/docs/content/docs/guides/shell-integration.mdx @@ -115,6 +115,65 @@ 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 — 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 `). +- 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`. + +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..1b6b8b86 100644 --- a/homebrew/dubstack.rb +++ b/homebrew/dubstack.rb @@ -12,9 +12,18 @@ 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. `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", err: :merge) + 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..587c6c7e --- /dev/null +++ b/packages/cli/src/commands/completion.test.ts @@ -0,0 +1,288 @@ +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'; + +/** + * 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'); + 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'); + // `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'); + 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); + }); + + it('does not branch-complete `revert` (positional is a PR/SHA, not a branch)', () => { + const out = completion(buildFixtureProgram(), 'bash'); + // 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'); + 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'); + // 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'); + }); + }); + + 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); + }); + + 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/); + }); + + 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', () => { + 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); + }); + + 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\)/); + }); + + 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/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..e3ec418a --- /dev/null +++ b/packages/cli/src/commands/man.test.ts @@ -0,0 +1,154 @@ +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('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 + // 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 (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'), '') + .replace(new RegExp(`${esc}\\[[0-9;]*[A-Za-z]`, '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..25494343 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') @@ -3626,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 new file mode 100644 index 00000000..1add3a46 --- /dev/null +++ b/packages/cli/src/lib/completion.ts @@ -0,0 +1,686 @@ +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`. + * `up` and `down` take a numeric step count, not a branch, so they are + * intentionally absent. + */ +const BRANCH_ARG_COMMANDS = [ + 'checkout', + 'co', + '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). 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 = ['--by-file']; + +export interface OptionSpec { + flags: string; + long: string | null; + short: string | null; + takesValue: boolean; + description: string; +} + +export 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; +} + +export 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 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(' '); + // 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], + ) || + aliases.some((alias) => + BRANCH_ARG_COMMANDS.includes( + alias as (typeof BRANCH_ARG_COMMANDS)[number], + ), + ); + return { + name, + 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); +} + +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('|'); + + 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 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( + ` ${label})\n __dub_complete_words '${completions}'\n ;;`, + ); + if (p.cmd.takesBranchArg) branchArgKeys.push(...p.aliasKeys); + if (p.cmd.takesFileArg) fileArgKeys.push(...p.aliasKeys); + } + + 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" != -* ]] && [[ -n "$path" ]]; then + case "$path" in + ${branchArgPattern}) + __dub_compreply_lines "$(__dub_branches)" + return 0 + ;; + esac + fi` + : ''; + const fileArgCase = fileArgPattern + ? ` if [[ "$cur" != -* ]]; then + case "$path" 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") ) +} + +# 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]}" + 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 path + path="$(__dub_walk_path)" + + # 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 commands fall through to default file completion. +${fileArgCase} + + # Otherwise complete that command's flags/subcommands. + case "$path" 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'); + + // 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) + .join(' \\\n '); + const argSpec = zshArgSpec(cmd); + return ` ${patterns}) + _arguments -s \\ + ${flagSpecs || ':: :->done'} \\ + ${argSpec} + ;;`; + }) + .join('\n'); + + return `#compdef dub +# dub zsh completion +# +# Usage: +# 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 + 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 +} + +_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) { + // 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. + 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 { + // 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 { + 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(' 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'); + 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) { + 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 '${usingCondition}' -f -a '(__dub_branches)'`, + ); + } else if (cmd.takesFileArg) { + out.push(`complete -c dub -n '${usingCondition}' -F`); + } + 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 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); + } + } + } + + 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 + // 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, ' '); +} diff --git a/packages/cli/src/lib/man.ts b/packages/cli/src/lib/man.ts new file mode 100644 index 00000000..27df38e4 --- /dev/null +++ b/packages/cli/src/lib/man.ts @@ -0,0 +1,122 @@ +import type { Command } from 'commander'; +import { type CommandSpec, 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) { + renderCommandEntry(lines, cmd, [cmd.name]); + } + + 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`; +} + +/** + * 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 + * 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; +}