Skip to content

Commit 31bcf27

Browse files
committed
feat(plugin-completion): support Bun runtimes
Resolve completion script executables for Bun source execution and likely Bun single-file binaries while keeping Deno as an unsupported runtime path. Split executable resolution into testable helpers, quote shell command parts consistently, and document the Bun compile heuristic with upstream references. Escape executable commands for the @bomb.sh/tab generated shell assignment before eval to avoid command substitution while preserving token quoting.
1 parent 9aaf8a7 commit 31bcf27

9 files changed

Lines changed: 423 additions & 38 deletions

File tree

e2e/bun.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import { mkdtemp, rm } from 'node:fs/promises'
2+
import os from 'node:os'
13
import path from 'node:path'
24
import { expect, test } from 'vitest'
35
import { runCommand } from '../scripts/utils.ts'
46

7+
const ROOT = path.resolve(import.meta.dirname, '..')
8+
9+
function shellQuote(value: string): string {
10+
return `'${value.replaceAll(`'`, `'\\''`)}'`
11+
}
12+
513
test('bun', async () => {
614
// install dependencies
715
await runCommand('bun install', {
@@ -12,3 +20,45 @@ test('bun', async () => {
1220
})
1321
expect(output).include(`Hello, Gunshi with Bun!`)
1422
})
23+
24+
test('bun source completion', async () => {
25+
const fixture = path.resolve(ROOT, 'packages/plugin-completion/examples/basic.node.ts')
26+
const command = `bun ${shellQuote(fixture)}`
27+
const script = await runCommand(`${command} complete zsh`, {
28+
cwd: ROOT
29+
})
30+
expect(script).include('bun')
31+
32+
const output = await runCommand(`${command} complete -- --config vite.config`, {
33+
cwd: ROOT
34+
})
35+
expect(output).include('vite.config.ts')
36+
})
37+
38+
test('bun single binary completion', async () => {
39+
const fixture = path.resolve(ROOT, 'packages/plugin-completion/examples/basic.node.ts')
40+
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'gunshi-completion-bun-'))
41+
const outfile = path.join(tmpDir, 'gunshi-completion-bun')
42+
43+
try {
44+
await runCommand(
45+
`bun build --compile ${shellQuote(fixture)} --outfile ${shellQuote(outfile)}`,
46+
{
47+
cwd: ROOT,
48+
timeout: 60_000
49+
}
50+
)
51+
52+
const script = await runCommand(`${shellQuote(outfile)} complete zsh`, {
53+
cwd: ROOT
54+
})
55+
expect(script).include(outfile)
56+
57+
const output = await runCommand(`${shellQuote(outfile)} complete -- --config vite.config`, {
58+
cwd: ROOT
59+
})
60+
expect(output).include('vite.config.ts')
61+
} finally {
62+
await rm(tmpDir, { force: true, recursive: true })
63+
}
64+
})

packages/plugin-completion/README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This completion plugin is powered by [`@bomb.sh/tab`](https://github.com/bombshe
1313
<!-- eslint-disable markdown/no-missing-label-refs -->
1414

1515
> [!WARNING]
16-
> This package support Node.js runtime only. Deno and Bun support are coming soon.
16+
> This package support Node.js and Bun runtime only. Deno support is not available yet.
1717
1818
<!-- eslint-enable markdown/no-missing-label-refs -->
1919

@@ -116,20 +116,24 @@ The `complete` command accepts the following shell types:
116116
117117
<!-- eslint-enable markdown/no-missing-label-refs -->
118118

119-
## Shell Completion Setup
119+
### Runtime Support
120120

121-
This section provides detailed instructions for setting up shell completions in different shells. The setup is a one-time process that enables tab completion for your CLI.
121+
The plugin supports completion script generation for Node.js, Bun source execution, and Bun single-file executables.
122122

123-
### Prerequisites
123+
Runtime detection is intentionally conservative:
124124

125-
Shell completion requires Node.js runtime. Ensure your CLI is running with Node.js (not Deno or Bun).
125+
- **Bun source execution** replays the current Bun executable, `process.execArgv`, and the source entry. This follows Bun's documented argument vector shape, where the launcher is first and the source file is second. See [Bun's `argv` guide](https://bun.com/guides/process/argv) and [single-file executable docs](https://bun.com/docs/bundler/executables).
126+
- **Bun single-file executables** call the compiled executable itself. Bun does not currently expose a stable runtime flag that says "this process is a compiled executable", so the plugin uses an internal heuristic based on `process.execPath` and Bun virtual entries. This avoids regenerating a completion script that tries to call the original source file from inside a compiled binary. The heuristic is intentionally private; related Bun compile behavior is discussed in [oven-sh/bun#14676](https://github.com/oven-sh/bun/issues/14676) and [oven-sh/bun#13405](https://github.com/oven-sh/bun/issues/13405).
126127

127-
<!-- eslint-disable markdown/no-missing-label-refs -->
128+
Deno runtime completion script generation is not supported yet.
128129

129-
> [!WARNING]
130-
> This package support Node.js runtime only. Deno and Bun support are coming soon.
130+
## Shell Completion Setup
131131

132-
<!-- eslint-enable markdown/no-missing-label-refs -->
132+
This section provides detailed instructions for setting up shell completions in different shells. The setup is a one-time process that enables tab completion for your CLI.
133+
134+
### Prerequisites
135+
136+
Shell completion requires your CLI command to be executable from the generated completion script.
133137

134138
### Setup by Shell
135139

packages/plugin-completion/docs/functions/default.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# Function: default()
88

99
```ts
10-
function default(options): PluginWithoutExtension;
10+
function default(options?): PluginWithoutExtension;
1111
```
1212

1313
completion plugin

packages/plugin-completion/docs/interfaces/CompletionConfig.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ Completion configuration, which structure is similar `bombsh/tab`'s `CompletionC
1212

1313
| Property | Type | Description |
1414
| ------ | ------ | ------ |
15-
| <a id="args"></a> `args?` | `Record`\<`string`, \{ `handler`: [`CompletionHandler`](../type-aliases/CompletionHandler.md); \}\> | The command arguments for the completion. |
16-
| <a id="handler"></a> `handler?` | [`CompletionHandler`](../type-aliases/CompletionHandler.md) | The [`handler`](../type-aliases/CompletionHandler.md) for the completion. |
15+
| <a id="property-args"></a> `args?` | `Record`\<`string`, \{ `handler`: [`CompletionHandler`](../type-aliases/CompletionHandler.md); \}\> | The command arguments for the completion. |
16+
| <a id="property-handler"></a> `handler?` | [`CompletionHandler`](../type-aliases/CompletionHandler.md) | The [`handler`](../type-aliases/CompletionHandler.md) for the completion. |

packages/plugin-completion/docs/interfaces/CompletionOptions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ Completion plugin options.
1212

1313
| Property | Type | Description |
1414
| ------ | ------ | ------ |
15-
| <a id="config"></a> `config?` | `object` | The completion configuration |
15+
| <a id="property-config"></a> `config?` | `object` | The completion configuration |
1616
| `config.entry?` | [`CompletionConfig`](CompletionConfig.md) | The entry point [`completion configuration`](CompletionConfig.md). |
1717
| `config.subCommands?` | `Record`\<`string`, [`CompletionConfig`](CompletionConfig.md)\> | The handlers for sub-commands. |

packages/plugin-completion/docs/interfaces/CompletionParams.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ Parameters for [`the completion handler`](../type-aliases/CompletionHandler.md).
1212

1313
| Property | Type | Description |
1414
| ------ | ------ | ------ |
15-
| <a id="locale"></a> `locale?` | `Locale` | The locale to use for i18n. |
15+
| <a id="property-locale"></a> `locale?` | `Locale` | The locale to use for i18n. |

packages/plugin-completion/docs/type-aliases/CompletionHandler.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
[@gunshi/plugin-completion](../index.md) / CompletionHandler
66

7-
# Type Alias: CompletionHandler()
7+
# Type Alias: CompletionHandler
88

99
```ts
1010
type CompletionHandler = (params) => Completion[];
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @author kazuya kawaguchi (a.k.a. kazupon)
3+
* @license MIT
4+
*/
5+
6+
import { describe, expect, test } from 'vitest'
7+
import {
8+
detectRuntime,
9+
joinExecParts,
10+
resolveBunExecParts,
11+
resolveNodeExecParts,
12+
shellQuote
13+
} from './utils.ts'
14+
15+
import type { ProcessLike } from './utils.ts'
16+
17+
function createProcess(overrides: Partial<ProcessLike> = {}): ProcessLike {
18+
return {
19+
argv: ['/usr/local/bin/node', './cli.ts'],
20+
execArgv: [],
21+
execPath: '/usr/local/bin/node',
22+
release: {
23+
name: 'node'
24+
},
25+
...overrides
26+
}
27+
}
28+
29+
describe('detectRuntime', () => {
30+
test('prioritizes Bun over Deno and Node-compatible process', () => {
31+
expect(
32+
detectRuntime({
33+
Bun: {},
34+
Deno: {},
35+
process: createProcess()
36+
})
37+
).toBe('bun')
38+
})
39+
40+
test('prioritizes Deno over Node-compatible process', () => {
41+
expect(
42+
detectRuntime({
43+
Deno: {},
44+
process: createProcess()
45+
})
46+
).toBe('deno')
47+
})
48+
49+
test('detects Node from process release name', () => {
50+
expect(
51+
detectRuntime({
52+
process: createProcess()
53+
})
54+
).toBe('node')
55+
})
56+
})
57+
58+
describe('resolveNodeExecParts', () => {
59+
test('resolves Node source execution', () => {
60+
expect(resolveNodeExecParts(createProcess())).toEqual(['/usr/local/bin/node', './cli.ts'])
61+
})
62+
})
63+
64+
describe('resolveBunExecParts', () => {
65+
test('resolves Bun source execution', () => {
66+
expect(
67+
resolveBunExecParts(
68+
createProcess({
69+
argv: ['/usr/local/bin/bun', './src/cli.ts'],
70+
execPath: '/usr/local/bin/bun'
71+
})
72+
)
73+
).toEqual(['/usr/local/bin/bun', './src/cli.ts'])
74+
})
75+
76+
test('resolves Bun source execution with execArgv', () => {
77+
expect(
78+
resolveBunExecParts(
79+
createProcess({
80+
argv: ['/usr/local/bin/bun', './src/cli.ts'],
81+
execArgv: ['--smol'],
82+
execPath: '/usr/local/bin/bun'
83+
})
84+
)
85+
).toEqual(['/usr/local/bin/bun', '--smol', './src/cli.ts'])
86+
})
87+
88+
test('resolves Bun single binary on Unix', () => {
89+
expect(
90+
resolveBunExecParts(
91+
createProcess({
92+
argv: ['/tmp/my-cli'],
93+
execPath: '/tmp/my-cli'
94+
})
95+
)
96+
).toEqual(['/tmp/my-cli'])
97+
})
98+
99+
test('resolves Bun single binary on Windows', () => {
100+
expect(
101+
resolveBunExecParts(
102+
createProcess({
103+
argv: ['C:\\tools\\my-cli.exe'],
104+
execPath: 'C:\\tools\\my-cli.exe'
105+
})
106+
)
107+
).toEqual(['C:\\tools\\my-cli.exe'])
108+
})
109+
110+
test('resolves Bun Unix virtual compiled entry', () => {
111+
expect(
112+
resolveBunExecParts(
113+
createProcess({
114+
argv: ['/usr/local/bin/bun', '/$bunfs/root/main.js'],
115+
execPath: '/usr/local/bin/bun'
116+
})
117+
)
118+
).toEqual(['/usr/local/bin/bun'])
119+
})
120+
121+
test('resolves Bun Windows virtual compiled entry', () => {
122+
expect(
123+
resolveBunExecParts(
124+
createProcess({
125+
argv: ['C:\\tools\\bun.exe', 'B:\\~BUN\\root.js'],
126+
execPath: 'C:\\tools\\bun.exe'
127+
})
128+
)
129+
).toEqual(['C:\\tools\\bun.exe'])
130+
})
131+
})
132+
133+
describe('shellQuote', () => {
134+
test('escapes spaces and single quotes', () => {
135+
expect(shellQuote("/tmp/my cli's/bin")).toBe(`'/tmp/my cli'\\''s/bin'`)
136+
})
137+
138+
test('joins quoted exec parts without extra spaces', () => {
139+
expect(joinExecParts(['/tmp/my cli/bin', "--flag=it's", './cli.ts'])).toBe(
140+
String.raw`'/tmp/my cli/bin' '--flag=it'\\''s' ./cli.ts`
141+
)
142+
})
143+
144+
test('escapes metacharacters for generated double-quoted shell assignment', () => {
145+
expect(
146+
joinExecParts(['/tmp/$(touch pwn)/my cli', '--loader=`touch pwn`', './"quoted".ts'])
147+
).toBe("'/tmp/\\$(touch pwn)/my cli' '--loader=\\`touch pwn\\`' './\\\"quoted\\\".ts'")
148+
})
149+
150+
test('preserves backslashes through generated double-quoted shell assignment', () => {
151+
expect(joinExecParts(['C:\\tools\\my cli.exe'])).toBe("'C:\\\\tools\\\\my cli.exe'")
152+
})
153+
})

0 commit comments

Comments
 (0)