Skip to content
Closed
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
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
141 changes: 141 additions & 0 deletions test/cli/shell-completion-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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}).
// HOME / BUN_INSTALL are cleared so the `install_bunx_symlink` step that runs
// before the piped-stdout write has nowhere outside the build tree to land.
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,
HOME: undefined,
BUN_INSTALL: 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;
}

// completions/bun.bash uses `declare -A` (associative arrays, bash >= 4.0).
// macOS ships /bin/bash 3.2, so the functional probe can only run where a
// modern bash is on PATH.
const bashMajor = (() => {
if (isWindows) return 0;
try {
const { stdout } = Bun.spawnSync({ cmd: ["bash", "-c", "echo ${BASH_VERSINFO[0]}"], env: bunEnv });
return parseInt(stdout.toString().trim(), 10) || 0;
} catch {
return 0;
}
})();

// `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.skipIf(bashMajor < 1)("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);
});

// Drives _bun_completions directly. Skipped when PATH bash is < 4 (macOS
// stock /bin/bash is 3.2 and rejects the script's `declare -A`).
test.concurrent.skipIf(bashMajor < 4)("bash: `bun <entrypoint> <TAB>` still offers global flags", async () => {
// Regression guard for the #24847 fix: the removed 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("");
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 a flat sequence of "..." literals separated by spaces.
const descs = [...descLine!.matchAll(/"([^"]*)"/g)].map(m => m[1]);

// The two parallel lists are zipped by index at runtime; length equality
// alone cannot catch an insertion at the wrong position, so spot-check
// the pairings this change touched as well.
expect(descs.length).toBe(flags.length);
expect(flags).toContain("frozen-lockfile");
expect(descs[flags.indexOf("frozen-lockfile")]).toContain("lockfile");
expect(descs[flags.indexOf("dry-run")]).toMatch(/dry run/i);
expect(descs[flags.indexOf("global")]).toMatch(/global/i);
});
});
Loading