Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-rabbits-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
incur: patch
---

Exported shell completion environment variables in bash and zsh hooks.
99 changes: 97 additions & 2 deletions src/Completions.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { execFile, spawnSync } from 'node:child_process'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Cli, Completions, z } from 'incur'

const originalIsTTY = process.stdout.isTTY
Expand All @@ -15,6 +19,45 @@ vi.mock('./SyncSkills.js', async (importOriginal) => {
return { ...actual, readHash: () => undefined }
})

function hasShell(shell: string): boolean {
return spawnSync(shell, ['-c', ':'], { stdio: 'ignore' }).status === 0
}

const bash = hasShell('bash')
const zsh = hasShell('zsh')
const fish = hasShell('fish')

function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { env, timeout: 30_000 }, (error, stdout, stderr) => {
if (error) reject(new Error(stderr?.trim() || stdout?.trim() || error.message))
else resolve(stdout)
})
})
}

async function withFakeCli(run: (dir: string) => Promise<void>) {
const dir = await mkdtemp(join(tmpdir(), 'incur-completions-'))
const bin = join(dir, 'fake-cli')

try {
await writeFile(
bin,
`#!/bin/sh
if [ -n "$COMPLETE" ]; then
printf '%s' "$COMPLETE:\${_COMPLETE_INDEX:-missing}"
else
printf 'missing'
fi
`,
)
await chmod(bin, 0o755)
await run(dir)
} finally {
await rm(dir, { recursive: true, force: true })
}
}

async function serve(
cli: { serve: Cli.Cli['serve'] },
argv: string[],
Expand Down Expand Up @@ -231,27 +274,79 @@ describe('register', () => {
test('bash: generates complete -F script with nospace support', () => {
const script = Completions.register('bash', 'mycli')
expect(script).toContain('_incur_complete_mycli()')
expect(script).toContain('COMPLETE="bash"')
expect(script).toContain('export COMPLETE="bash"')
expect(script).toContain('complete -o default -o bashdefault -o nosort -F')
expect(script).toContain('"mycli" -- "${COMP_WORDS[@]}"')
expect(script).toContain('compopt -o nospace')
})

test.skipIf(!bash)('bash: exports completion env vars to the CLI subprocess', async () => {
await withFakeCli(async (dir) => {
const output = await exec(
'bash',
[
'-lc',
`${Completions.register('bash', 'fake-cli')}
COMP_WORDS=('fake-cli' 'build' '')
COMP_CWORD=2
_incur_complete_fake_cli
printf '%s' "\${COMPREPLY[*]}"`,
],
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
)

expect(output).toBe('bash:2')
})
})

test('zsh: generates compdef script', () => {
const script = Completions.register('zsh', 'mycli')
expect(script).toContain('#compdef mycli')
expect(script).toContain('COMPLETE="zsh"')
expect(script).toContain('export COMPLETE="zsh"')
expect(script).toContain('compdef _incur_complete_mycli mycli')
expect(script).toContain('_describe')
})

test.skipIf(!zsh)('zsh: exports completion env vars to the CLI subprocess', async () => {
await withFakeCli(async (dir) => {
const output = await exec(
'zsh',
[
'-lc',
`compdef() { : }
_describe() { print -r -- "\${(j:|:)\${(@P)2}}" }
${Completions.register('zsh', 'fake-cli')}
words=('fake-cli' 'build' '')
CURRENT=3
_incur_complete_fake_cli`,
],
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
)

expect(output.trim()).toBe('zsh:2')
})
})

test('fish: generates complete command', () => {
const script = Completions.register('fish', 'mycli')
expect(script).toContain('complete --keep-order --exclusive --command mycli')
expect(script).toContain('COMPLETE=fish')
expect(script).toContain('commandline --current-token')
})

test.skipIf(!fish)('fish: passes completion env vars to the CLI subprocess', async () => {
await withFakeCli(async (dir) => {
const output = await exec(
'fish',
['-c', `${Completions.register('fish', 'fake-cli')}
complete --do-complete 'fake-cli '`],
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
)

expect(output.trim()).toBe('fish:missing')
})
})

test('nushell: generates external completer closure', () => {
const script = Completions.register('nushell', 'mycli')
expect(script).toContain('COMPLETE=nushell')
Expand Down
8 changes: 4 additions & 4 deletions src/Completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ function bashRegister(name: string): string {
local _COMPLETE_INDEX=\${COMP_CWORD}
local _completions
_completions=( $(
COMPLETE="bash"
_COMPLETE_INDEX="$_COMPLETE_INDEX"
export COMPLETE="bash"
export _COMPLETE_INDEX="$_COMPLETE_INDEX"
"${name}" -- "\${COMP_WORDS[@]}"
) )
if [[ $? != 0 ]]; then
Expand Down Expand Up @@ -262,8 +262,8 @@ function zshRegister(name: string): string {
return `#compdef ${name}
_incur_complete_${id}() {
local completions=("\${(@f)$(
_COMPLETE_INDEX=$(( CURRENT - 1 ))
COMPLETE="zsh"
export _COMPLETE_INDEX=$(( CURRENT - 1 ))
export COMPLETE="zsh"
"${name}" -- "\${words[@]}" 2>/dev/null
)}")
if [[ -n $completions ]]; then
Expand Down
Loading