Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
31 changes: 14 additions & 17 deletions completions/bun.bash
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,21 @@ _read_scripts_in_package_json() {
COMPREPLY+=( $(compgen -W "${package_json_compreply[*]}" -- "${cur_word}") );
}

# when a script is passed as an option, do not show other scripts as part of the completion anymore
local re_prev_script="(^| )${prev}($| )";
[[
( "${COMPREPLY[*]}" =~ ${re_prev_script} && -n "${COMP_WORDS[2]}" ) || \
( "${COMPREPLY[*]}" =~ ${re_comp_word_script} )
]] && {
local filtered_reply=();
local reply_word script_name keep;
for reply_word in "${COMPREPLY[@]}"; do
keep=1;
for script_name in "${package_json_compreply[@]}"; do
[[ "${reply_word}" == "${script_name}" ]] && { keep=""; break; };
done
[[ -n "${keep}" ]] && filtered_reply+=( "${reply_word}" );
# Do not offer package.json scripts once a script/file has already been typed. This block used
# to be gated on a regex match against an undeclared variable, which made it unconditional under
# GNU regex and an "empty (sub)expression" error under BSD regex (#24847); running it
# unconditionally preserves the long-standing GNU behaviour portably.
local filtered_reply=();
local reply_word script_name keep;
for reply_word in "${COMPREPLY[@]}"; do
keep=1;
for script_name in "${package_json_compreply[@]}"; do
[[ "${reply_word}" == "${script_name}" ]] && { keep=""; break; };
done
COMPREPLY=( "${filtered_reply[@]}" );
replaced_script="${prev}";
}
[[ -n "${keep}" ]] && filtered_reply+=( "${reply_word}" );
done
COMPREPLY=( "${filtered_reply[@]}" );
replaced_script="${prev}";
}


Expand Down
4 changes: 2 additions & 2 deletions completions/bun.fish
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ function __fish__get_bun_bun_js_files
string split ' ' (bun getcompletes j)
end

set -l bun_install_boolean_flags yarn production optional development no-save dry-run force no-cache silent verbose global
set -l bun_install_boolean_flags_descriptions "Write a yarn.lock file (yarn v1)" "Don't install devDependencies" "Add dependency to optionalDependencies" "Add dependency to devDependencies" "Don't update package.json or save a lockfile" "Don't install anything" "Always request the latest versions from the registry & reinstall all dependencies" "Ignore manifest cache entirely" "Don't output anything" "Excessively verbose logging" "Use global folder"
set -l bun_install_boolean_flags yarn production optional development no-save frozen-lockfile dry-run force no-cache silent verbose global
set -l bun_install_boolean_flags_descriptions "Write a yarn.lock file (yarn v1)" "Don't install devDependencies" "Add dependency to optionalDependencies" "Add dependency to devDependencies" "Don't update package.json or save a lockfile" "Disallow changes to lockfile" "Perform a dry run without making changes" "Always request the latest versions from the registry & reinstall all dependencies" "Ignore manifest cache entirely" "Don't output anything" "Excessively verbose logging" "Use global folder"

set -l bun_builtin_cmds_without_run dev create help bun upgrade discord install remove add update init pm x repl
set -l bun_builtin_cmds_accepting_flags create help bun upgrade discord run init link unlink pm x update
Expand Down
4 changes: 2 additions & 2 deletions completions/bun.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ _bun_run_completion() {
'--watch[Automatically restart bun'"'"'s JavaScript runtime on file change]' \
'--no-install[Disable auto install in bun'"'"'s JavaScript runtime]' \
'--install[Install dependencies automatically when no node_modules are present, default: "auto". "force" to ignore node_modules, fallback to install any missing]: :->install_' \
'-i[Automatically install dependencies and use global cache in bun'"'"'s runtime, equivalent to --install=fallback'] \
'-i[Automatically install dependencies and use global cache in bun'"'"'s runtime, equivalent to --install=fallback]' \
'--prefer-offline[Skip staleness checks for packages in bun'"'"'s JavaScript runtime and resolve from disk]' \
'--prefer-latest[Use the latest matching versions of packages in bun'"'"'s JavaScript runtime, always checking npm]' \
'--silent[Don'"'"'t repeat the command for bun run]' \
Expand Down Expand Up @@ -991,7 +991,7 @@ _set_remove() {
_bun_add_param_package_completion() {

IFS=$'\n' inexact=($(history -n bun | grep -E "^bun add " | cut -c 9- | uniq))
IFS=$'\n' exact=($($inexact | grep -E "^$words[$CURRENT]"))
IFS=$'\n' exact=($(print -l -- $inexact | grep -E "^$words[$CURRENT]"))
IFS=$'\n' packages=($(SHELL=zsh bun getcompletes a $words[$CURRENT]))

to_print=$inexact
Expand Down
114 changes: 114 additions & 0 deletions test/cli/shell-completion-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";

// `bun completions` writes the embedded completion script for the shell named
// by $SHELL to stdout when stdout is not a TTY. This lets us assert on the
// bytes that ship inside the binary (from completions/bun.{zsh,bash,fish}).
async function emitCompletions(shell: "zsh" | "bash" | "fish"): Promise<string> {
await using proc = Bun.spawn({
cmd: [bunExe(), "completions"],
env: { ...bunEnv, SHELL: `/bin/${shell}`, IS_BUN_AUTO_UPDATE: undefined },
stdout: "pipe",
stderr: "pipe",
});
Comment thread
robobun marked this conversation as resolved.
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("error");
expect(exitCode).toBe(0);
return stdout;
}

// `bun completions` is a no-op on Windows (PowerShell completions are not
// implemented), so these tests can only run on POSIX.
describe.skipIf(isWindows)("shell completion scripts", () => {
Comment thread
robobun marked this conversation as resolved.
test.concurrent("zsh: -i optspec has closing bracket inside the quote", async () => {
// #31665: the line was `...=fallback'] \` (bracket outside the quote),
// which zsh _arguments sees as a stray literal `]` argument.
const script = await emitCompletions("zsh");
expect(script).toContain("--install=fallback]' \\");
expect(script).not.toContain("--install=fallback'] \\");
});

test.concurrent("zsh: _bun_add_param_package_completion prints history instead of executing it", async () => {
// #34062: `$($inexact | grep ...)` runs the first history entry as a
// command. It should be `$(print -l -- $inexact | grep ...)`.
const script = await emitCompletions("zsh");
expect(script).toContain("print -l -- $inexact | grep");
expect(script).not.toContain("($($inexact | grep");
});

test.concurrent("bash: no reference to undeclared re_comp_word_script", async () => {
// #28744 / #24847: ${re_comp_word_script} was never defined; under BSD
// regex (macOS bash) `=~ <empty>` is rejected as "empty (sub)expression".
const script = await emitCompletions("bash");
expect(script).not.toContain("${re_comp_word_script}");
});

test.concurrent("bash: script passes bash -n", async () => {
const script = await emitCompletions("bash");
using dir = tempDir("bun-bash-completion", { "bun.bash": script });
await using proc = Bun.spawn({
cmd: ["bash", "-n", "bun.bash"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("");
expect(exitCode).toBe(0);
});
Comment thread
robobun marked this conversation as resolved.
Outdated

test.concurrent("bash: `bun <entrypoint> <TAB>` still offers global flags", async () => {
// Regression guard for the #24847 fix: the removed `=~ ${re_comp_word_script}`
// guard was always-true under GNU regex, so the `replaced_script` assignment
// it protected has always run. Dropping the arm outright would leave
// `bun somefile.js <TAB>` with zero completions instead of the global flag
// list, so the fix makes the block unconditional instead.
const script = await emitCompletions("bash");
using dir = tempDir("bun-bash-completion-run", {
"bun.bash": script,
"probe.sh":
"source ./bun.bash\n" +
'COMP_WORDS=(bun somefile.js "")\n' +
"COMP_CWORD=2\n" +
"COMPREPLY=()\n" +
"_bun_completions\n" +
'echo "count=${#COMPREPLY[@]}"\n' +
"printf '%s\\n' \"${COMPREPLY[@]}\"\n",
});
await using proc = Bun.spawn({
cmd: ["bash", "probe.sh"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");

Check failure on line 88 in test/cli/shell-completion-scripts.test.ts

View check run for this annotation

Claude / Claude Code Review

bash probe test requires bash 4+ (declare -A) but has no version/availability guard

The `` bash: `bun <entrypoint> <TAB>` still offers global flags `` test sources `bun.bash` and calls `_bun_completions`, which uses `declare -A` (bash 4.0+) — but macOS ships `/bin/bash` 3.2.57 and `scripts/bootstrap.sh` never `brew install`s a newer bash, so on the macOS CI lanes `declare -A` prints `declare: -A: invalid option` to stderr (failing `expect(stderr).toBe("")`) and `GLOBAL_OPTIONS[SHORT_OPTIONS]` overwrites `[LONG_OPTIONS]` at index 0 so `--help`/`--version` are never in `COMPREPLY
Comment thread
robobun marked this conversation as resolved.
Outdated
const lines = stdout.trim().split("\n");
expect(lines[0]).toMatch(/^count=\d+$/);
expect(Number(lines[0].slice("count=".length))).toBeGreaterThan(0);
expect(lines).toContain("--help");
expect(lines).toContain("--version");
expect(exitCode).toBe(0);
});

test.concurrent("fish: install boolean flags include frozen-lockfile and descriptions line up", async () => {
// #29364: frozen-lockfile was missing and dry-run's description was wrong.
const script = await emitCompletions("fish");
const flagsLine = script.split("\n").find(l => l.startsWith("set -l bun_install_boolean_flags "));
const descLine = script.split("\n").find(l => l.startsWith("set -l bun_install_boolean_flags_descriptions "));
expect(flagsLine).toBeDefined();
expect(descLine).toBeDefined();

const flags = flagsLine!.replace("set -l bun_install_boolean_flags ", "").trim().split(/\s+/);
// Descriptions are quoted with "..." and separated by a single space.
const descs = [...descLine!.matchAll(/"[^"]*"/g)].map(m => m[0]);

expect(flags).toContain("frozen-lockfile");
// The two parallel lists must stay in lockstep or every flag after the
// first mismatch gets the wrong help text.
expect(descs.length).toBe(flags.length);
});
});
Loading