diff --git a/README.md b/README.md index 514ef213..c2b5e2b8 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,14 @@ On Arch Linux, install the [`seam-bin`][aur] package from the AUR with $ paru -S seam-bin ``` +The AUR and Homebrew packages install [shell completion] themselves. After an +npm or manual install, run `seam completion --install`. + [aur]: https://aur.archlinux.org/packages/seam-bin [latest GitHub release]: https://github.com/seamapi/cli/releases/latest [npm]: https://www.npmjs.com/ [Seam Wizard]: https://github.com/seamapi/wizard +[shell completion]: #shell-completion ## Usage @@ -283,7 +287,21 @@ seam devices list --help The CLI can print a completion script for bash, fish, and zsh that completes commands, flags, and flag values such as device types. -Load completions into the current shell with +Install them into the shell you are in, or one you name, with + +```bash +seam completion --install +seam completion --install zsh +``` + +If you use Zsh you must enable compinit in your `.zshrc` with + +```zsh +autoload -Uz compinit +compinit +``` + +Load completions into the current shell instead with ```bash # bash diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 232fddd8..d52ef7c3 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -14,8 +14,11 @@ import { import { assertKnownArgs, assertNoAuthOverrides } from 'lib/args/validate.js' import { getApiBlueprint } from 'lib/blueprint/index.js' import { + installCompletionForShell, printCompletion, printCompletionLoader, + readCompletionAction, + resolveCompletionShell, } from 'lib/commands/local/completion.js' import { runWizard } from 'lib/commands/local/wizard.js' import { @@ -36,10 +39,6 @@ import { parseJsonParams, readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { setAuthOverrides } from 'lib/overrides.js' import { canPrompt } from 'lib/prompt.js' -import { - completionShells, - isCompletionShell, -} from 'lib/render/completion/index.js' import { renderHelp } from 'lib/render/help.js' import seamapiCliVersion from 'lib/version.js' @@ -118,22 +117,27 @@ async function cli(args: ParsedArgs, argv: string[]) { } if (args._[0] === 'completion') { - const shell = args._[1] + const action = readCompletionAction(args) + const shellArg = args._[1] + const shell = resolveCompletionShell(shellArg, action) - if (!isCompletionShell(shell)) { - output.error(`Usage: seam completion <${completionShells.join('|')}>`) - process.exitCode = 1 + const command = findLocalCommand(['completion', shell]) + assertKnownArgs( + argParams, + shellArg == null ? ['completion'] : ['completion', shell], + { + accepted: + command == null ? new Set() : acceptedParamsOf(command.definition), + isLocal: true, + }, + ) + + if (action === 'install') { + await installCompletionForShell(shell) return } - const command = findLocalCommand(['completion', shell]) - assertKnownArgs(argParams, ['completion', shell], { - accepted: - command == null ? new Set() : acceptedParamsOf(command.definition), - isLocal: true, - }) - - if (args['loader'] === true) { + if (action === 'loader') { printCompletionLoader(shell) return } @@ -253,7 +257,8 @@ const run = async (argv: string[]) => { } const args = parseCliArgs(argv, { - booleanKeys: argv[0]?.toLowerCase() === 'completion' ? ['loader'] : [], + booleanKeys: + argv[0]?.toLowerCase() === 'completion' ? ['install', 'loader'] : [], }) const isTty = process.stdout.isTTY === true diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts index 1f0c4b0d..80d8b641 100644 --- a/src/lib/commands/local/completion.ts +++ b/src/lib/commands/local/completion.ts @@ -1,13 +1,29 @@ +import type { ParsedArgs } from 'minimist' + import type { ApiBlueprint } from 'lib/blueprint/index.js' import type { Command } from 'lib/commands/registry.js' import type { CommandSpec } from 'lib/commands/spec.js' +import { detectShell } from 'lib/completion/detect-shell.js' +import { + type CompletionTarget, + installCompletion, + type InstallOutcome, + resolveCompletionTarget, +} from 'lib/completion/install.js' +import { UsageError } from 'lib/errors.js' import { getOutput } from 'lib/output/get-output.js' import { type CompletionShell, + completionShells, + isCompletionShell, renderCompletion, renderCompletionStub, } from 'lib/render/completion/index.js' +export type CompletionAction = 'script' | 'install' | 'loader' + +const completionActionFlags = ['install', 'loader'] as const + /** * Print the completion script for a shell. * @@ -25,11 +41,92 @@ export const printCompletion = ( getOutput().text(renderCompletion(shell, spec)) } -/** Print the network-free loader installed in a shell completion directory. */ export const printCompletionLoader = (shell: CompletionShell): void => { getOutput().text(renderCompletionStub(shell)) } +export const installCompletionForShell = async ( + shell: CompletionShell, +): Promise => { + const output = getOutput() + const target = resolveCompletionTarget(shell) + const { outcome, notes, warnings } = await installCompletion(target) + + output.info(describeOutcome(outcome, target)) + for (const note of notes) output.info(note) + for (const warning of warnings) output.warn(`\n${warning}`) +} + +export const readCompletionAction = (args: ParsedArgs): CompletionAction => { + const given = completionActionFlags.filter((flag) => args[flag] === true) + + if (given.length > 1) { + throw new UsageError( + `Only one of ${completionActionFlags + .map((flag) => `--${flag}`) + .join(', ')} may be given for seam completion.`, + { hint: completionUsage }, + ) + } + + return given[0] ?? 'script' +} + +export const resolveCompletionShell = ( + shellArg: string | undefined, + action: CompletionAction, +): CompletionShell => { + if (shellArg != null) { + if (isCompletionShell(shellArg)) return shellArg + + throw new UsageError(`Unknown shell for seam completion: ${shellArg}`, { + hint: completionUsage, + }) + } + + if (action === 'script' || action === 'loader') { + throw new UsageError( + 'Missing required argument for seam completion: ', + { hint: completionUsage }, + ) + } + + const detected = detectShell() + + if (detected == null) { + throw new UsageError( + `Could not tell which shell you are in: it is none of ${listShells()}.`, + { + hint: `Name the shell instead, e.g., 'seam completion --${action} zsh'.`, + }, + ) + } + + return detected +} + +const completionUsage = [ + `Usage: seam completion <${completionShells.join('|')}>`, + ` seam completion --install [${completionShells.join('|')}]`, + ` seam completion --loader [${completionShells.join('|')}]`, +].join('\n') + +const listShells = (): string => + `${completionShells.slice(0, -1).join(', ')}, or ${completionShells.at(-1) ?? ''}` + +const describeOutcome = ( + outcome: InstallOutcome, + { shell, file }: CompletionTarget, +): string => { + if (outcome === 'present') { + return `${file} already loads ${shell} completions for seam.` + } + if (outcome === 'written') { + return `Installed ${shell} completions for seam to ${file}.` + } + return `Added ${shell} completions for seam to ${file}.` +} + type BuildSpec = (blueprint: ApiBlueprint) => CommandSpec const completionCommand = ( @@ -42,11 +139,18 @@ const completionCommand = ( title: `Print the ${shell} completion script.`, description: '', flags: [ + { + long: 'install', + short: null, + description: `Add what ${shell} needs to complete seam commands to its config, instead of printing the script.`, + values: [], + takesValue: false, + isRequired: false, + }, { long: 'loader', short: null, - description: - 'Print the dynamic, network-free loader for installation by a package manager.', + description: `Print the loader --install writes, to install it into ${shell} by hand or from a package.`, values: [], takesValue: false, isRequired: false, @@ -55,11 +159,16 @@ const completionCommand = ( }, requiresAuth: false, execute: async (invocation, ctx) => { - if (invocation.args['loader'] === true) { + const action = readCompletionAction(invocation.args) + + if (action === 'install') { + await installCompletionForShell(shell) + } else if (action === 'loader') { printCompletionLoader(shell) } else { printCompletion(shell, buildSpec(ctx.blueprint)) } + return { kind: 'done' } }, }) diff --git a/src/lib/completion/detect-shell.ts b/src/lib/completion/detect-shell.ts new file mode 100644 index 00000000..f1b31f68 --- /dev/null +++ b/src/lib/completion/detect-shell.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' + +import { + type CompletionShell, + isCompletionShell, +} from 'lib/render/completion/index.js' + +export interface DetectShellOptions { + env?: NodeJS.ProcessEnv + ancestors?: () => string[] +} + +export const detectShell = ({ + env = process.env, + ancestors = processAncestors, +}: DetectShellOptions = {}): CompletionShell | null => { + for (const command of ancestors()) { + const name = toShellName(command) + if (isCompletionShell(name)) return name + } + + const name = toShellName(env['SHELL'] ?? '') + + return isCompletionShell(name) ? name : null +} + +const toShellName = (command: string): string => + basename(command.trim()).replace(/^-/, '') + +const maxAncestors = 10 + +const processAncestors = (): string[] => { + const commands: string[] = [] + + let pid = process.ppid + for (let depth = 0; depth < maxAncestors && pid > 1; depth += 1) { + const parent = readProcess(pid) + if (parent == null) break + commands.push(parent.command) + pid = parent.ppid + } + + return commands +} + +interface ProcessInfo { + command: string + ppid: number +} + +const readProcess = (pid: number): ProcessInfo | null => + readFromProcfs(pid) ?? readFromPs(pid) + +const readFromProcfs = (pid: number): ProcessInfo | null => { + try { + const command = readFileSync(`/proc/${pid}/comm`, 'utf8').trim() + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const afterCommand = stat.slice(stat.lastIndexOf(')') + 1).trim() + const ppid = Number(afterCommand.split(/\s+/)[1]) + + return command !== '' && Number.isInteger(ppid) ? { command, ppid } : null + } catch { + return null + } +} + +const readFromPs = (pid: number): ProcessInfo | null => { + try { + const fields = execFileSync( + 'ps', + ['-p', String(pid), '-o', 'comm=,ppid='], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ) + .trim() + .split(/\s+/) + const ppid = Number(fields.pop()) + const command = fields.join(' ') + + return command !== '' && Number.isInteger(ppid) ? { command, ppid } : null + } catch { + return null + } +} diff --git a/src/lib/completion/install.ts b/src/lib/completion/install.ts new file mode 100644 index 00000000..b78964f1 --- /dev/null +++ b/src/lib/completion/install.ts @@ -0,0 +1,128 @@ +import { existsSync } from 'node:fs' +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + type CompletionShell, + renderCompletionEval, + renderCompletionStub, +} from 'lib/render/completion/index.js' + +export type CompletionFileKind = 'config' | 'completion' + +export interface CompletionTarget { + shell: CompletionShell + file: string + kind: CompletionFileKind +} + +export interface ResolveCompletionTargetOptions { + env?: NodeJS.ProcessEnv + home?: string +} + +export const resolveCompletionTarget = ( + shell: CompletionShell, + { env = process.env, home = homedir() }: ResolveCompletionTargetOptions = {}, +): CompletionTarget => { + if (shell === 'fish') { + const configHome = readPath(env['XDG_CONFIG_HOME']) ?? join(home, '.config') + return { + shell, + file: join(configHome, 'fish', 'completions', 'seam.fish'), + kind: 'completion', + } + } + + if (shell === 'zsh') { + const dotDirectory = readPath(env['ZDOTDIR']) ?? home + return { shell, file: join(dotDirectory, '.zshrc'), kind: 'config' } + } + + return { shell, file: bashConfig(home), kind: 'config' } +} + +const bashConfig = (home: string): string => { + const bashrc = join(home, '.bashrc') + const bashProfile = join(home, '.bash_profile') + + if (existsSync(bashrc)) return bashrc + if (existsSync(bashProfile)) return bashProfile + + return bashrc +} + +export type InstallOutcome = 'added' | 'present' | 'written' + +export interface InstallResult { + outcome: InstallOutcome + notes: string[] + warnings: string[] +} + +export const installCompletion = async ( + target: CompletionTarget, +): Promise => { + await mkdir(dirname(target.file), { recursive: true }) + + if (target.kind === 'completion') { + await writeFile(target.file, renderCompletionStub(target.shell), 'utf8') + return { outcome: 'written', ...adviceFor(target, null) } + } + + const line = renderCompletionEval(target.shell) + const config = await readFileOrNull(target.file) + + if (config != null && config.includes(line)) { + return { outcome: 'present', ...adviceFor(target, config) } + } + + await appendFile(target.file, `${separatorFor(config)}${line}\n`, 'utf8') + + return { outcome: 'added', ...adviceFor(target, config) } +} + +const separatorFor = (config: string | null): string => { + if (config == null || config === '') return '' + return config.endsWith('\n') ? '\n' : '\n\n' +} + +const adviceFor = ( + { shell, file }: CompletionTarget, + config: string | null, +): Pick => ({ + notes: [ + `Open a new shell to complete seam commands, or run 'exec ${shell}' now.`, + ], + warnings: + shell === 'zsh' && config?.includes('compinit') !== true + ? [ + [ + `Nothing in ${file} turns the zsh completion system on. If nothing`, + 'else does either, completions stay inert until you add this above', + 'the block just installed:', + '', + ' autoload -Uz compinit', + ' compinit', + ].join('\n'), + ] + : [], +}) + +const readFileOrNull = async (file: string): Promise => { + try { + return await readFile(file, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } +} + +const isMissingFileError = (error: unknown): boolean => + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT' + +const readPath = (value: string | undefined): string | null => { + const trimmedValue = value?.trim() + return trimmedValue == null || trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/render/completion/index.ts b/src/lib/render/completion/index.ts index f9f86788..a838a87d 100644 --- a/src/lib/render/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -41,6 +41,11 @@ export const renderCompletion = ( export const renderCompletionStub = (shell: CompletionShell): string => stubs[shell] +export const renderCompletionEval = (shell: CompletionShell): string => { + const loader = `seam completion --loader ${shell} 2> /dev/null` + return shell === 'fish' ? `${loader} | source` : `eval "$(${loader})"` +} + /** * First line of each generated completion script, which the loaders require * before evaluating one. Must match the output of {@link renderCompletion}. @@ -63,13 +68,21 @@ const stubs: Record = { # # Install to /usr/share/bash-completion/completions/seam -if command -v seam > /dev/null 2>&1; then - __seam_completion_script="$(seam completion bash 2> /dev/null)" +_seam_completion_loader() { + local script + script="$(seam completion bash 2> /dev/null)" # Evaluate only a completion script, never anything else the CLI printed. - case "$__seam_completion_script" in - '${completionScriptSentinels.bash}'*) eval "$__seam_completion_script" ;; + case "$script" in + '${completionScriptSentinels.bash}'*) eval "$script" ;; + *) return 1 ;; esac - unset __seam_completion_script + return 124 +} + +if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then + complete -F _seam_completion_loader seam +else + _seam_completion_loader || : fi `, fish: `${stubHeader('fish')} @@ -89,15 +102,23 @@ ${stubHeader('zsh')} # # Install to a directory in fpath as _seam -local __seam_completion_script -__seam_completion_script="$(seam completion zsh 2> /dev/null)" +_seam() { + local script + script="$(seam completion zsh 2> /dev/null)" + # Evaluate only a completion script, never anything else the CLI printed. + # The script ends by dispatching on funcstack, so evaluating it while this + # _seam runs both redefines _seam and completes the in-flight request. + if [[ "$script" == '${completionScriptSentinels.zsh}'* ]]; then + eval "$script" + fi +} + +if (( $+functions[compdef] )); then + compdef _seam seam +fi -# Evaluate only a completion script, never anything else the CLI printed. -# The script ends by dispatching on funcstack, so evaluating it while this -# autoloaded _seam runs both redefines _seam and completes the in-flight -# request. -if [[ "$__seam_completion_script" == '${completionScriptSentinels.zsh}'* ]]; then - eval "$__seam_completion_script" +if (( \${funcstack[(I)_seam]} )); then + _seam "$@" fi `, } diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index 0d2774c2..61955121 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -79,8 +79,8 @@ const examples = [ summary: 'Pipe request params in as JSON.', }, { - name: 'seam completion bash', - summary: 'Print a shell completion script for bash, fish, or zsh.', + name: 'seam completion {bold --install}', + summary: 'Complete seam commands in bash, fish, or zsh.', }, ] diff --git a/test/cli.test.ts b/test/cli.test.ts index 941fc89a..27e982c0 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -291,6 +291,86 @@ test('cli: prints an embedded completion loader without generating completions', expect(stdout).not.toContain('complete -F _seam_completion seam') }) +test('cli: installs completions into a shell config', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + await writeFile(join(shellHome, '.zshrc'), 'export EDITOR=vim\n', 'utf8') + + const { stderr, exitCode } = await runCli( + ['completion', '--install', 'zsh', '--no-json'], + { env: { HOME: shellHome, ZDOTDIR: shellHome } }, + ) + + expect(exitCode).toBe(0) + expect(stderr).toContain(join(shellHome, '.zshrc')) + + const config = await readFile(join(shellHome, '.zshrc'), 'utf8') + expect(config).toContain('export EDITOR=vim') + expect(config).toContain( + 'eval "$(seam completion --loader zsh 2> /dev/null)"', + ) +}) + +test('cli: installs a completion file for fish', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + + const { exitCode } = await runCli(['completion', '--install', 'fish'], { + env: { HOME: shellHome, XDG_CONFIG_HOME: shellHome }, + }) + + expect(exitCode).toBe(0) + const completions = await readFile( + join(shellHome, 'fish', 'completions', 'seam.fish'), + 'utf8', + ) + expect(completions).toContain('seam completion fish') +}) + +test('cli: prints a loader holding no completions of its own', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + + const { stdout, exitCode } = await runCli( + ['completion', '--loader', 'bash'], + { + env: { HOME: shellHome, SHELL: '/bin/bash' }, + }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toContain('complete -F _seam_completion_loader seam') + expect(stdout).not.toContain('_seam_subcommands') +}) + +test('cli: reports a shell it cannot install completions for', async () => { + const { stderr, exitCode } = await runCli([ + 'completion', + '--install', + 'nushell', + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Unknown shell for seam completion: nushell') +}) + +test('cli: refuses to both install and print completions', async () => { + const { stderr, exitCode } = await runCli([ + 'completion', + '--install', + '--loader', + 'zsh', + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Only one of --install, --loader') +}) + +test('cli: names the shell to print a completion script for', async () => { + const { stderr, exitCode } = await runCli(['completion']) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Missing required argument for seam completion') + expect(stderr).toContain('seam completion --loader [bash|fish|zsh]') +}) + test('cli: names every unknown argument at once', async () => { requests = [] const { stderr, exitCode } = await runCli([ diff --git a/test/completion/detect-shell.test.ts b/test/completion/detect-shell.test.ts new file mode 100644 index 00000000..fed6f33d --- /dev/null +++ b/test/completion/detect-shell.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vitest' + +import { detectShell } from 'lib/completion/detect-shell.js' + +const noAncestors = () => [] + +test('detectShell: reads the shell that ran the CLI', () => { + expect(detectShell({ ancestors: () => ['/usr/bin/fish'], env: {} })).toBe( + 'fish', + ) + expect(detectShell({ ancestors: () => ['zsh'], env: {} })).toBe('zsh') + expect(detectShell({ ancestors: () => ['-bash'], env: {} })).toBe('bash') +}) + +test('detectShell: looks past whatever the shell ran to reach the CLI', () => { + expect( + detectShell({ + ancestors: () => ['node', 'sh', 'npm', '/usr/bin/fish'], + env: {}, + }), + ).toBe('fish') +}) + +test('detectShell: prefers the shell running it to the login shell', () => { + expect( + detectShell({ + ancestors: () => ['/usr/bin/fish'], + env: { SHELL: '/bin/bash' }, + }), + ).toBe('fish') +}) + +test('detectShell: falls back to SHELL when no ancestor is a shell', () => { + expect( + detectShell({ ancestors: noAncestors, env: { SHELL: '/bin/zsh' } }), + ).toBe('zsh') + expect( + detectShell({ ancestors: () => ['node', 'tmux'], env: { SHELL: '-zsh' } }), + ).toBe('zsh') +}) + +test('detectShell: gives up on a shell completions cannot be installed into', () => { + expect( + detectShell({ ancestors: noAncestors, env: { SHELL: '/bin/sh' } }), + ).toBe(null) + expect(detectShell({ ancestors: noAncestors, env: { SHELL: '' } })).toBe(null) + expect(detectShell({ ancestors: noAncestors, env: {} })).toBe(null) +}) diff --git a/test/completion/install.test.ts b/test/completion/install.test.ts new file mode 100644 index 00000000..2238ea38 --- /dev/null +++ b/test/completion/install.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, expect, test } from 'vitest' + +import { + installCompletion, + resolveCompletionTarget, +} from 'lib/completion/install.js' +import { + renderCompletionEval, + renderCompletionStub, +} from 'lib/render/completion/index.js' + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'seam-cli-completion-')) +}) + +test('resolveCompletionTarget: zsh reads its config from ZDOTDIR', () => { + expect(resolveCompletionTarget('zsh', { env: {}, home })).toEqual({ + shell: 'zsh', + file: join(home, '.zshrc'), + kind: 'config', + }) + expect( + resolveCompletionTarget('zsh', { env: { ZDOTDIR: '/dotfiles' }, home }), + ).toEqual({ + shell: 'zsh', + file: join('/dotfiles', '.zshrc'), + kind: 'config', + }) +}) + +test('resolveCompletionTarget: fish owns a completion file of its own', () => { + expect(resolveCompletionTarget('fish', { env: {}, home })).toEqual({ + shell: 'fish', + file: join(home, '.config', 'fish', 'completions', 'seam.fish'), + kind: 'completion', + }) + expect( + resolveCompletionTarget('fish', { + env: { XDG_CONFIG_HOME: '/xdg' }, + home, + }), + ).toEqual({ + shell: 'fish', + file: join('/xdg', 'fish', 'completions', 'seam.fish'), + kind: 'completion', + }) +}) + +test('resolveCompletionTarget: bash takes the config it finds', async () => { + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bashrc'), + ) + + await writeFile(join(home, '.bash_profile'), '', 'utf8') + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bash_profile'), + ) + + await writeFile(join(home, '.bashrc'), '', 'utf8') + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bashrc'), + ) +}) + +test('installCompletion: keeps what the config already holds', async () => { + const file = join(home, '.zshrc') + await writeFile(file, 'export EDITOR=vim', 'utf8') + + const result = await installCompletion({ shell: 'zsh', file, kind: 'config' }) + + expect(result.outcome).toBe('added') + const config = await readFile(file, 'utf8') + expect(config.startsWith('export EDITOR=vim\n\n')).toBe(true) + expect(config).toContain(renderCompletionEval('zsh')) +}) + +test('installCompletion: writes a config that does not exist yet', async () => { + const file = join(home, 'nested', '.bashrc') + + const result = await installCompletion({ + shell: 'bash', + file, + kind: 'config', + }) + + expect(result.outcome).toBe('added') + expect(await readFile(file, 'utf8')).toContain(renderCompletionEval('bash')) +}) + +test('installCompletion: installs the same snippet only once', async () => { + const file = join(home, '.zshrc') + const target = { shell: 'zsh', file, kind: 'config' } as const + + await installCompletion(target) + const installed = await readFile(file, 'utf8') + + const result = await installCompletion(target) + + expect(result.outcome).toBe('present') + expect(await readFile(file, 'utf8')).toBe(installed) +}) + +test('installCompletion: writes a completion file whole', async () => { + const file = join(home, '.config', 'fish', 'completions', 'seam.fish') + + const result = await installCompletion({ + shell: 'fish', + file, + kind: 'completion', + }) + + expect(result.outcome).toBe('written') + expect(await readFile(file, 'utf8')).toBe(renderCompletionStub('fish')) + + await installCompletion({ shell: 'fish', file, kind: 'completion' }) + expect(await readFile(file, 'utf8')).toBe(renderCompletionStub('fish')) +}) + +test('installCompletion: warns when nothing turns the zsh completion system on', async () => { + const file = join(home, '.zshrc') + + const missing = await installCompletion({ + shell: 'zsh', + file, + kind: 'config', + }) + expect(missing.outcome).toBe('added') + expect(missing.warnings.join('\n')).toContain('autoload -Uz compinit') + + await mkdir(join(home, 'other'), { recursive: true }) + const initialized = join(home, 'other', '.zshrc') + await writeFile(initialized, 'autoload -Uz compinit\ncompinit\n', 'utf8') + + const found = await installCompletion({ + shell: 'zsh', + file: initialized, + kind: 'config', + }) + expect(found.warnings).toEqual([]) +}) + +test('installCompletion: says nothing about compinit to another shell', async () => { + const result = await installCompletion({ + shell: 'bash', + file: join(home, '.bashrc'), + kind: 'config', + }) + + expect(result.warnings).toEqual([]) + expect(result.notes.join('\n')).toContain('exec bash') +}) diff --git a/test/render/completion.test.ts b/test/render/completion.test.ts index 3b494fb6..ed56b7e5 100644 --- a/test/render/completion.test.ts +++ b/test/render/completion.test.ts @@ -7,6 +7,7 @@ import { completionShells, isCompletionShell, renderCompletion, + renderCompletionEval, renderCompletionStub, } from 'lib/render/completion/index.js' import { testBlueprint } from 'test/fixtures/blueprint.js' @@ -103,3 +104,34 @@ test('zsh completion: completes the in-flight request when evaluated by the stub 'if (( ${funcstack[(I)_seam]} )); then', ) }) + +test.each(completionShells)( + '%s completion loader: survives being evaluated by a shell config', + (shell) => { + expect(renderCompletionStub(shell)).not.toMatch(/^local /m) + }, +) + +test.each(completionShells)('%s completion eval: loads the loader', (shell) => { + expect(renderCompletionEval(shell)).toContain( + `seam completion --loader ${shell}`, + ) +}) + +test('bash completion loader: completes again with the script it loaded', () => { + const stub = renderCompletionStub('bash') + expect(stub).toContain('complete -F _seam_completion_loader seam') + expect(stub).toContain('return 124') +}) + +test('zsh completion loader: registers itself when sourced', () => { + const stub = renderCompletionStub('zsh') + expect(stub).toContain('if (( $+functions[compdef] )); then') + expect(stub).toContain('compdef _seam seam') + // eslint-disable-next-line no-template-curly-in-string + expect(stub).toContain('if (( ${funcstack[(I)_seam]} )); then') +}) + +test('zsh completion loader: leaves the completion system to the shell', () => { + expect(renderCompletionStub('zsh')).not.toContain('compinit') +})