From baa55af274a0b787481854845a149e857c7ad378 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:59:13 +0000 Subject: [PATCH 1/3] shell: return which's PATH and cwd lookup as a struct instead of a tuple path_and_cwd returned (Vec, Vec) and both callers unpacked it as (path_env, cwd) before handing the pair to resolve(path_env, cwd, arg), where the two same-typed slices could be swapped without a compile error. Fold both helpers into a SearchEnv { path_env, cwd } struct with a resolve(&self, arg) method so the names live in the type. No behavior change. Drop the cleared tuple_wants_struct entry for which.rs from mordant-baseline.toml. --- mordant-baseline.toml | 1 - src/runtime/shell/builtin/which.rs | 39 +++++++++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/mordant-baseline.toml b/mordant-baseline.toml index 109483f7b029..ae37acb9ea5d 100644 --- a/mordant-baseline.toml +++ b/mordant-baseline.toml @@ -90,7 +90,6 @@ "same_match_twice:src/runtime/webcore/Blob.rs" = 2 "same_match_twice:src/runtime/webcore/Body.rs" = 2 "tuple_wants_struct:src/runtime/server/ServerWebSocket.rs" = 1 -"tuple_wants_struct:src/runtime/shell/builtin/which.rs" = 1 "unchecked_construction:src/runtime/api/js_bundle_completion_task.rs" = 1 "unchecked_construction:src/runtime/cli/pack_command.rs" = 2 "unchecked_construction:src/runtime/server/HTMLBundle.rs" = 2 diff --git a/src/runtime/shell/builtin/which.rs b/src/runtime/shell/builtin/which.rs index 71930eb407b6..a430f8eb7e27 100644 --- a/src/runtime/shell/builtin/which.rs +++ b/src/runtime/shell/builtin/which.rs @@ -46,11 +46,11 @@ impl Which { if Builtin::of(interp, cmd).stdout.needs_io().is_none() { // Synchronous path: resolve every arg, write straight to the // captured buffer, then finish. - let (path_env, cwd) = Self::path_and_cwd(interp, cmd); + let search = SearchEnv::load(interp, cmd); let mut had_not_found = false; for i in 0..argc { let arg = Self::arg(interp, cmd, i); - match Self::resolve(&path_env, &cwd, &arg) { + match search.resolve(&arg) { Some(resolved) => { let buf = Builtin::fmt_error_arena( interp, @@ -100,8 +100,7 @@ impl Which { } let arg = Self::arg(interp, cmd, arg_idx); - let (path_env, cwd) = Self::path_and_cwd(interp, cmd); - let resolved = Self::resolve(&path_env, &cwd, &arg); + let resolved = SearchEnv::load(interp, cmd).resolve(&arg); let child = ChildPtr::new(cmd, WriterTag::Builtin); match resolved { @@ -191,11 +190,23 @@ impl Which { // ── helpers ──────────────────────────────────────────────────────────── - /// Look up `$PATH` from the export env and the cwd from the shell env. - fn path_and_cwd(interp: &Interpreter, cmd: NodeId) -> (Vec, Vec) { + fn arg(interp: &Interpreter, cmd: NodeId, idx: usize) -> Vec { + Builtin::of(interp, cmd).arg_bytes(idx).to_vec() + } +} + +/// What each argument is resolved against: `$PATH` from the export env and +/// the cwd from the shell env. +struct SearchEnv { + path_env: Vec, + cwd: Vec, +} + +impl SearchEnv { + fn load(interp: &Interpreter, cmd: NodeId) -> Self { let shell = Builtin::shell(interp, cmd); // `EnvMap::get` refs the returned string; balance it. - let path = shell + let path_env = shell .export_env .get(EnvStr::init_slice(b"PATH")) .map(|s| { @@ -204,15 +215,15 @@ impl Which { v }) .unwrap_or_default(); - (path, shell.cwd().to_vec()) - } - - fn arg(interp: &Interpreter, cmd: NodeId, idx: usize) -> Vec { - Builtin::of(interp, cmd).arg_bytes(idx).to_vec() + Self { + path_env, + cwd: shell.cwd().to_vec(), + } } - fn resolve(path_env: &[u8], cwd: &[u8], arg: &[u8]) -> Option> { + fn resolve(&self, arg: &[u8]) -> Option> { let mut path_buf = bun_paths::path_buffer_pool::get(); - bun_which::which(&mut *path_buf, path_env, cwd, arg).map(|z| z.as_bytes().to_vec()) + bun_which::which(&mut *path_buf, &self.path_env, &self.cwd, arg) + .map(|z| z.as_bytes().to_vec()) } } From 81a12ce94227588d3194fbdf2c105c27b4f8c384 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:17:09 +0000 Subject: [PATCH 2/3] shell: test that which reads $PATH for bare names and the cwd for ./ names Two fixture directories hold an executable of the same name; one is put on the shell's PATH and the other is made its cwd. `which tool ./tool` has to print them in that order, on both the captured-stdout path and the streamed path, so feeding the two SearchEnv members to bun_which::which in the wrong order shows up as the directories swapping in the output. The existing which tests pass with the members transposed. --- test/js/bun/shell/commands/which.test.ts | 55 +++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/test/js/bun/shell/commands/which.test.ts b/test/js/bun/shell/commands/which.test.ts index 4f470b3e654a..4e04bb15f5de 100644 --- a/test/js/bun/shell/commands/which.test.ts +++ b/test/js/bun/shell/commands/which.test.ts @@ -1,6 +1,59 @@ import { $ } from "bun"; import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync } from "node:fs"; +import { join } from "node:path"; + +// Two directories each holding an executable of the same name. `which tool` +// must come from the $PATH directory and `which ./tool` from the shell's cwd, +// so the output shows which of the two inputs each lookup read. +const exe = isWindows ? "tool.exe" : "tool"; +const searchTree = { + [`path-dir/${exe}`]: "", + [`cwd-dir/${exe}`]: "", +}; + +function searchDirs(dir: string) { + const pathDir = join(dir, "path-dir"); + const cwdDir = join(dir, "cwd-dir"); + if (!isWindows) { + chmodSync(join(pathDir, exe), 0o755); + chmodSync(join(cwdDir, exe), 0o755); + } + return { pathDir, cwdDir, expected: `${join(pathDir, exe)}\n${join(cwdDir, exe)}\n` }; +} + +test.concurrent("which searches $PATH for bare names and the shell cwd for ./ names (captured stdout)", async () => { + using dir = tempDir("which-captured", searchTree); + const { pathDir, cwdDir, expected } = searchDirs(String(dir)); + + const { stdout, exitCode } = await $`which tool ./tool`.env({ PATH: pathDir }).cwd(cwdDir).quiet().nothrow(); + expect(stdout.toString()).toBe(expected); + expect(exitCode).toBe(0); +}); + +test.concurrent("which searches $PATH for bare names and the shell cwd for ./ names (streamed stdout)", async () => { + using dir = tempDir("which-streamed", searchTree); + const { pathDir, cwdDir, expected } = searchDirs(String(dir)); + + // Without .quiet() the builtin writes to the process's stdout (the pipe + // below), which is the one-argument-per-write path in the builtin. + const fixture = /* ts */ ` + import { $ } from "bun"; + const { exitCode } = await $\`which tool ./tool\`.env({ PATH: ${JSON.stringify(pathDir)} }).cwd(${JSON.stringify(cwdDir)}).nothrow(); + process.exitCode = exitCode; + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(expected); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); test.skipIf(isWindows)("which with an absolute path at the platform path length limit reports not found", async () => { const fixture = /* ts */ ` From 8acff7ec8110fde229ce810c0921f2b59b2ce2c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:20:35 +0000 Subject: [PATCH 3/3] shell: drop the SearchEnv doc comment, the field names say the same thing --- src/runtime/shell/builtin/which.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/shell/builtin/which.rs b/src/runtime/shell/builtin/which.rs index a430f8eb7e27..9d53713b2fb8 100644 --- a/src/runtime/shell/builtin/which.rs +++ b/src/runtime/shell/builtin/which.rs @@ -195,8 +195,6 @@ impl Which { } } -/// What each argument is resolved against: `$PATH` from the export env and -/// the cwd from the shell env. struct SearchEnv { path_env: Vec, cwd: Vec,