From 692f2bafb7a103bdb8c20d787d010dfd2c33a2b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 8 May 2026 05:31:51 +0000 Subject: [PATCH 1/2] fix(completions): complete files after a script arg in bash & zsh `bun myscript.ts foo` did nothing in either shell: the dispatch in both completion scripts only ran file completion for positions 2+ when the first word was a known subcommand (`run`, `test`, etc.). When the first word is a script path, the case fell through with no default branch, so TAB silently produced no matches. Add a `*)` fallback to both scripts that calls `_files` / `compgen -f` for positional args after the script, mirroring `bun run`'s behaviour. Fixes #30386. --- completions/bun.bash | 8 ++ completions/bun.zsh | 7 ++ test/cli/completions.test.ts | 235 +++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 test/cli/completions.test.ts diff --git a/completions/bun.bash b/completions/bun.bash index 2188be22e344..46fccd821969 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -170,6 +170,14 @@ _bun_completions() { _read_scripts_in_package_json; _subcommand_comp_reply "${cur_word}" "${SUBCOMMANDS}"; + # `bun ` — the first word isn't a recognised + # subcommand, so treat it as a script path and complete + # positional args after the script with files. Without this + # branch, `bun myscript.ts foo` does nothing. + (( COMP_CWORD >= 2 )) && { + COMPREPLY+=( $(compgen -f -- "${cur_word}") ); + } + # determine if completion should be continued # when the current word is an empty string # the previous word is not part of the allowed completion diff --git a/completions/bun.zsh b/completions/bun.zsh index 34940fef1103..d4c4b2879cb9 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -928,6 +928,13 @@ _bun() { ;; esac + ;; + *) + # `bun ...` — the first word isn't a known subcommand, + # so bun treats it as a script path. Complete positional args + # after the script with files, mirroring what `bun run` does. + _files + ;; esac diff --git a/test/cli/completions.test.ts b/test/cli/completions.test.ts new file mode 100644 index 000000000000..00fb00636eff --- /dev/null +++ b/test/cli/completions.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, isWindows, tempDir } from "harness"; +import path from "node:path"; + +// Repo-relative paths to the completion scripts being exercised. +const BASH_COMPLETION = path.join(import.meta.dir, "..", "..", "completions", "bun.bash"); +const ZSH_COMPLETION = path.join(import.meta.dir, "..", "..", "completions", "bun.zsh"); + +// Single-quote a string for embedding inside a shell single-quoted string. +const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +// Spawn bash, source the completion script, drive `_bun_completions` with a +// simulated command line, and return the resulting COMPREPLY list. +async function bashComplete(cwd: string, words: string[], cwordIndex: number): Promise { + const compWords = words.map(sq).join(" "); + const script = [ + "set +e", + "shopt -s extglob", + `source ${sq(BASH_COMPLETION)}`, + `COMP_WORDS=(${compWords})`, + `COMP_CWORD=${cwordIndex}`, + `COMP_LINE=${sq(words.join(" "))}`, + `COMP_POINT=\${#COMP_LINE}`, + "COMPREPLY=()", + "_bun_completions", + // Emit each match on its own line prefixed with a sentinel so we can + // distinguish it from stray diagnostic output. + `for m in "\${COMPREPLY[@]}"; do printf 'COMP:%s\\n' "$m"; done`, + ].join("\n"); + await using proc = Bun.spawn({ + cmd: ["bash", "-c", script], + env: bunEnv, + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) { + throw new Error(`bash driver exited with code ${exitCode}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + return stdout + .split("\n") + .filter(l => l.startsWith("COMP:")) + .map(l => l.slice("COMP:".length)); +} + +// Is zsh available? Only relevant for the zsh-specific coverage below; +// if unavailable the zsh tests silently pass (bash covers the same logic). +async function zshAvailable(): Promise { + try { + await using proc = Bun.spawn({ + cmd: ["zsh", "-c", "exit 0"], + env: bunEnv, + stdout: "ignore", + stderr: "ignore", + }); + return (await proc.exited) === 0; + } catch { + return false; + } +} + +// Drive zsh tab completion by spawning an embedded zsh via zpty, sending a +// command line followed by a literal TAB, and returning the resulting line +// from the inner shell's terminal buffer. Assertions check that the line +// contains the expected completion (e.g. a common-prefix auto-completion). +async function zshCompleteLine(cwd: string, line: string): Promise { + // We spawn zsh -c with a script that uses zpty to start ANOTHER + // interactive zsh, feeds it setup commands (waiting for an echoed marker + // between each stage so we never race), then writes `` and + // reads the terminal buffer back. + const escapeSq = (s: string) => s.replace(/'/g, `'\\''`); + const script = [ + "emulate -L zsh", + "zmodload zsh/zpty", + // wait_for : read zpty output until appears or 5s + // elapses. This replaces sleep-based waits with deterministic + // synchronisation on echoed markers. + `wait_for() { + local needle="$1" timeout=\${2:-5} buf='' chunk + local start=$SECONDS + while (( SECONDS - start < timeout )); do + if zpty -r -t S chunk 0.05 2>/dev/null; then + buf+="$chunk" + [[ "$buf" == *$needle* ]] && return 0 + fi + done + return 1 + }`, + "zpty -b S zsh -i -f", + // Configure the inner shell, emitting a marker after each stage so + // we know when it's done. + "zpty -w S 'PS1=\"\"; autoload -Uz compinit && compinit -u; echo __INIT__'", + "wait_for __INIT__ || { echo >&2 'inner zsh setup timed out'; exit 1; }", + `zpty -w S 'cd ${escapeSq(cwd)}; source ${escapeSq(ZSH_COMPLETION)}; echo __LOADED__'`, + "wait_for __LOADED__ || { echo >&2 'completion load timed out'; exit 1; }", + "zpty -w S 'bindkey \"^I\" expand-or-complete; setopt no_always_last_prompt no_list_beep; echo __BOUND__'", + "wait_for __BOUND__ || { echo >&2 'bindkey timed out'; exit 1; }", + // Drain any remaining stdout now that we're past setup. + "while zpty -r -t S junk 0.05 2>/dev/null; do :; done", + // Literal tab at the end triggers `expand-or-complete`, which will + // either auto-complete the common prefix or stay put (ring the bell). + `zpty -n -w S '${escapeSq(line)}\t'`, + // Give zsh a moment to process the TAB and emit its response. zpty + // has no "done" signal, so we give it a fixed window to flush; the + // inner zsh is already warm from compinit at this point, so 500ms + // is plenty for a single completion lookup. + "sleep 0.5", + "local out=''", + "local chunk", + 'while zpty -r -t S chunk 0.2 2>/dev/null; do out+="$chunk"; done', + "zpty -d S", + // Strip ANSI colour escape sequences emitted by list-colors. + `printf '%s' "$out" | sed $'s/\\x1b\\\\[[0-9;]*m//g'`, + ].join("\n"); + await using proc = Bun.spawn({ + cmd: ["zsh", "-c", script], + env: bunEnv, + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) { + throw new Error(`zsh driver exited with code ${exitCode}`); + } + return stdout; +} + +describe.skipIf(isWindows)("shell completions", () => { + describe("bash (completions/bun.bash)", () => { + // Regression for #30386: `bun myscript.ts foo` did nothing. When + // the first word after `bun` isn't a recognised subcommand, the + // `case ${COMP_WORDS[1]} in ... *)` fallback in completions/bun.bash + // never called into file completion, so positions 2+ got no files. + test("completes files for `bun