diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index aba13b40f541..3b101215ed1d 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -784,6 +784,29 @@ Full documentation is available at https://bun.com/docs/cli/run .copied() } + /// Same lookup order as the VM's `loader_for_path`, so both give the entry point one loader. + fn entry_point_loader(ctx: &ContextData, path: &[u8]) -> Loader { + let ext = paths::fs::PathName::init(path).ext; + if let Some(map) = ctx.args.loaders.as_ref() { + if let Some(i) = map.extensions.iter().rposition(|e| **e == *ext) { + return ::from_api(map.loaders[i]); + } + } + bun_bundler::options::DEFAULT_LOADERS + .get(ext) + .copied() + .or_else(|| Loader::from_string(ext)) + .unwrap_or(Loader::Tsx) + } + + /// `boot` runs any `.sh` path in the Bun shell. `Html` serves the file and `Md` renders it. + fn can_run_entry_point(path: &[u8], loader: Loader) -> bool { + strings::has_suffix_comptime(path, b".sh") + || loader.can_be_run_by_bun() + || loader == Loader::Html + || loader == Loader::Md + } + /// Shared ctx→transpiler/resolver option projection used by [`boot`] and /// [`boot_standalone`]. fn wire_transpiler_from_ctx(b: &mut Transpiler<'_>, ctx: &mut ContextData) { @@ -1693,11 +1716,8 @@ fn print_unhandled_version_note(vm: &mut VirtualMachine) { impl RunCommand { /// Duplicate `path` to a process-lifetime buffer, boot the VM, and on /// failure print the formatted error + `exit(1)`. - fn boot_and_handle_error(ctx: &mut ContextData, path: &[u8], loader: Option) -> bool { - if matches!( - loader.or_else(|| Self::default_loader_for(path)), - Some(Loader::Md) - ) { + fn boot_and_handle_error(ctx: &mut ContextData, path: &[u8], loader: Loader) -> bool { + if loader == Loader::Md { Self::render_markdown_file_and_exit(path); } @@ -1710,7 +1730,7 @@ impl RunCommand { // owned copy by value. let owned: Box<[u8]> = path.to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, owned, loader) { + if let Err(err) = Self::boot(ctx, owned, Some(loader)) { Self::boot_failed_exit(ctx, paths::basename(path), &err); } true @@ -2338,7 +2358,7 @@ impl RunCommand { } // ── try fast run (file exists & not a dir → boot VM) ──────────────── - if try_fast_run && Self::maybe_open_with_bun_js(ctx, target_name) { + if try_fast_run && Self::maybe_open_with_bun_js(ctx, target_name, log_errors) { return Ok(true); } @@ -2548,20 +2568,13 @@ impl RunCommand { match resolution { Ok(mut resolved) => { let path = resolved.path().expect("resolved primary path"); - let ext = path.name().ext; - let loader: Loader = this_transpiler - .options - .loaders - .get(ext) - .copied() - .or_else(|| bun_bundler::options::DEFAULT_LOADERS.get(ext).copied()) - .unwrap_or(Loader::Tsx); - if loader.can_be_run_by_bun() || loader == Loader::Html || loader == Loader::Md { + let loader = Self::entry_point_loader(ctx, path.text); + if Self::can_run_entry_point(path.text, loader) { bun_core::scoped_log!(RUN_LOG, "Resolved to: `{}`", bstr::BStr::new(path.text)); // borrowck — `boot_and_handle_error` takes // `&mut ctx`; copy `path.text` out of the resolver borrow. let text: Box<[u8]> = path.text.to_vec().into_boxed_slice(); - return Ok(Self::boot_and_handle_error(ctx, &text, Some(loader))); + return Ok(Self::boot_and_handle_error(ctx, &text, loader)); } else { bun_core::scoped_log!( RUN_LOG, @@ -2578,11 +2591,7 @@ impl RunCommand { if strings::has_suffix_comptime(target_name, b".html") && strings::contains_char(target_name, b'*') { - return Ok(Self::boot_and_handle_error( - ctx, - target_name, - Some(Loader::Html), - )); + return Ok(Self::boot_and_handle_error(ctx, target_name, Loader::Html)); } } } @@ -2648,31 +2657,64 @@ impl RunCommand { which(&mut path_buf, path_for_which, top_level_dir, target_name) { let out = destination.as_bytes(); - let stored = fs.dirname_store.append_slice(out)?; - let passthrough: Vec> = ctx.passthrough.clone(); - Self::run_binary_without_bunx_path( - ctx, - stored, - destination, - top_level_dir, - env_loader, - &passthrough, - Some(target_name), - )?; + // With a directory in the target, `which` skips `$PATH` and looks at that path. + const SEPARATORS: &[u8] = if cfg!(windows) { b"/\\" } else { b"/" }; + let loader = Self::entry_point_loader(ctx, out); + if strings::contains_any(target_name, SEPARATORS) + && !Self::can_run_entry_point(out, loader) + { + resolved_to_unrunnable_file + .get_or_insert_with(|| (out.to_vec().into_boxed_slice(), loader)); + } else { + let stored = fs.dirname_store.append_slice(out)?; + let passthrough: Vec> = ctx.passthrough.clone(); + Self::run_binary_without_bunx_path( + ctx, + stored, + destination, + top_level_dir, + env_loader, + &passthrough, + Some(target_name), + )?; + } } } } // ── failure ───────────────────────────────────────────────────────── + Ok(Self::nothing_ran( + ctx, + log_errors, + target_name, + resolved_to_unrunnable_file + .as_ref() + .map(|(path, loader)| (&**path, *loader)), + )) + } + + /// Failure tail of [`RunCommand::exec_with_cfg`]. `true` means the target counts as handled. + #[cold] + #[inline(never)] + #[cfg_attr( + any(target_os = "linux", target_os = "android"), + unsafe(link_section = ".text.unlikely") + )] + fn nothing_ran( + ctx: &ContextData, + log_errors: bool, + target_name: &[u8], + unrunnable: Option<(&[u8], Loader)>, + ) -> bool { if ctx.runtime_options.if_present { - return Ok(true); + return true; } if log_errors { - if let Some((path, loader)) = resolved_to_unrunnable_file { + if let Some((path, loader)) = unrunnable { bun_core::pretty_error!( "error: Cannot run \"{}\"\n", - bstr::BStr::new(&path), + bstr::BStr::new(path), ); bun_core::pretty_error!( "note: Bun cannot run {} files directly\n", @@ -2707,7 +2749,7 @@ impl RunCommand { Global::exit(1); } - Ok(false) + false } /// Fast-path file probe: if `target` resolves to an existing regular file, @@ -2717,7 +2759,7 @@ impl RunCommand { /// /// `Arguments::parse` does not populate `entry_points` yet, so we /// take the target slice explicitly. - fn maybe_open_with_bun_js(ctx: &mut ContextData, target: &[u8]) -> bool { + fn maybe_open_with_bun_js(ctx: &mut ContextData, target: &[u8], log_errors: bool) -> bool { if target.is_empty() { return false; } @@ -2823,7 +2865,18 @@ impl RunCommand { }; let _ = bun_sys::close(fd); - Self::boot_and_handle_error(ctx, &absolute_script_path, None) + let loader = Self::entry_point_loader(ctx, &absolute_script_path); + // A plugin that a preload registers can turn a data file into code. + if ctx.preloads.is_empty() && !Self::can_run_entry_point(&absolute_script_path, loader) { + return Self::nothing_ran( + ctx, + log_errors, + target, + Some((&absolute_script_path, loader)), + ); + } + + Self::boot_and_handle_error(ctx, &absolute_script_path, loader) } /// `bun run -` — read script from stdin into `ctx.runtime_options.eval` diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 25675aca221d..4c684834713f 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; import { describe, expect, it } from "bun:test"; -import { chmodSync } from "fs"; +import { chmodSync, symlinkSync } from "fs"; import { bunEnv as bunEnv_, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; import { basename, join } from "path"; @@ -809,6 +809,18 @@ describe.concurrent("bun run", () => { stderr: /error: Cannot run ".*no_run_json\.json"|EACCES/, exitCode: 1, }, + { + command: ["./no_run_json.json"], + stdout: "", + stderr: /error: Cannot run ".*no_run_json\.json"/, + exitCode: 1, + }, + { + command: [dir + "/no_run_json.json"], + stdout: "", + stderr: /error: Cannot run ".*no_run_json\.json"/, + exitCode: 1, + }, { command: ["/absolute"], @@ -883,6 +895,197 @@ describe.concurrent("bun run", () => { } }); + describe("a path to an existing file that Bun cannot run", () => { + async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // `file` is the basename of the path in the message. Its only regular expression syntax is ".". + const cannotRun = (file: string, loader: string) => ({ + stdout: "", + stderr: expect.stringMatching( + new RegExp( + `^error: Cannot run "[^"]*[/\\\\]${file.replaceAll(".", "\\.")}"\nnote: Bun cannot run ${loader} files directly\n$`, + ), + ), + exitCode: 1, + }); + + const dir = tempDirWithFiles("bun-run-unrunnable", { + "style.css": "body { color: red }", + "notes.txt": "hello", + "config.toml": "a = 1", + "UPPER.CSS": "body { color: red }", + "data.sqlite": "", + "sub": { "keep.txt": "" }, + }); + + const spellings: { args: string[]; cwd?: string }[] = [ + { args: ["style.css"] }, + { args: ["./style.css"] }, + { args: [join(dir, "style.css")] }, + { args: ["../style.css"], cwd: join(dir, "sub") }, + ...(isWindows ? [{ args: [".\\style.css"] }] : []), + ]; + for (const { args, cwd = dir } of spellings) { + for (const prefix of [[], ["run"]]) { + it(`bun ${[...prefix, ...args].join(" ")}`, async () => { + expect(await run(cwd, ...prefix, ...args)).toEqual(cannotRun("style.css", "css")); + }); + } + } + + it.each([ + ["./notes.txt", "text"], + ["./config.toml", "toml"], + // The VM also reads an extension in upper case, and a loader name as an extension. + ["./UPPER.CSS", "css"], + ["UPPER.CSS", "css"], + ["./data.sqlite", "sqlite"], + ["data.sqlite", "sqlite"], + ])("bun %s names the %s loader", async (path, loader) => { + expect(await run(dir, path)).toEqual(cannotRun(basename(path), loader)); + }); + + it("--if-present exits 0 without an error", async () => { + const silent = { stdout: "", stderr: "", exitCode: 0 }; + expect( + await Promise.all([ + run(dir, "--if-present", "./style.css"), + run(dir, "run", "--if-present", "./style.css"), + run(dir, "--if-present", "sub/keep.txt"), + ]), + ).toEqual([silent, silent, silent]); + }); + + it("follows a [loader] entry in bunfig.toml", async () => { + using cwd = tempDir("bun-run-unrunnable-bunfig", { + "bunfig.toml": `[loader]\n".txt" = "ts"\n".ts" = "text"\n`, + "code.txt": "console.log('ran code.txt' as string);", + "words.ts": "these are words", + }); + expect(await run(String(cwd), "./code.txt")).toEqual({ stdout: "ran code.txt\n", stderr: "", exitCode: 0 }); + expect(await run(String(cwd), "./words.ts")).toEqual(cannotRun("words.ts", "text")); + expect(await run(String(cwd), "words.ts")).toEqual(cannotRun("words.ts", "text")); + }); + + it("follows --loader, and the last flag for an extension wins", async () => { + using cwd = tempDir("bun-run-unrunnable-loader-flag", { + "code.txt": "console.log('ran code.txt' as string);", + "words.js": "these are words", + }); + const ranCode = { stdout: "ran code.txt\n", stderr: "", exitCode: 0 }; + expect(await run(String(cwd), "--loader", ".txt:ts", "./code.txt")).toEqual(ranCode); + expect(await run(String(cwd), "--loader", ".txt:text", "--loader", ".txt:ts", "./code.txt")).toEqual(ranCode); + expect(await run(String(cwd), "--loader", ".js:text", "./words.js")).toEqual(cannotRun("words.js", "text")); + }); + + // `--loader` and bunfig store the `sh` loader as the `file` loader. + it("runs a .sh file whose extension is mapped to the sh loader", async () => { + using cwd = tempDir("bun-run-sh-loader", { + "bunfig.toml": `[loader]\n".sh" = "sh"\n`, + "script.sh": "echo ran script.sh", + }); + const ran = { stdout: "ran script.sh\n", stderr: "", exitCode: 0 }; + expect( + await Promise.all([ + run(String(cwd), "./script.sh"), + run(String(cwd), "run", "script.sh"), + run(String(cwd), "--loader", ".sh:sh", "./script.sh"), + ]), + ).toEqual([ran, ran, ran]); + }); + + it("leaves a data file to a plugin that a preload registers", async () => { + using cwd = tempDir("bun-run-plugin-entry", { + "plugin.ts": ` + import { plugin } from "bun"; + plugin({ + name: "yaml-entry", + setup(build) { + build.onLoad({ filter: /\\.yaml$/ }, () => ({ contents: "console.log('ran entry.yaml');", loader: "js" })); + }, + }); + `, + "entry.yaml": "a: 1", + "preloaded": { "bunfig.toml": `preload = ["../plugin.ts"]`, "entry.yaml": "a: 1" }, + }); + const ran = { stdout: "ran entry.yaml\n", stderr: "", exitCode: 0 }; + expect( + await Promise.all([ + run(String(cwd), "--preload", "./plugin.ts", "./entry.yaml"), + run(String(cwd), "run", "--preload", "./plugin.ts", join(String(cwd), "entry.yaml")), + run(join(String(cwd), "preloaded"), "./entry.yaml"), + ]), + ).toEqual([ran, ran, ran]); + }); + + it("renders or runs a file by its configured loader, for every spelling", async () => { + using cwd = tempDir("bun-run-md-loader", { + "bunfig.toml": `[loader]\n".txt" = "md"\n".md" = "ts"\n`, + "notes.txt": "# Title\n", + "code.md": "console.log('ran code.md' as string);", + }); + const rendered = { stdout: "Title\n=====\n", stderr: "", exitCode: 0 }; + const ranCode = { stdout: "ran code.md\n", stderr: "", exitCode: 0 }; + expect( + await Promise.all([ + run(String(cwd), "./notes.txt"), + run(String(cwd), "notes.txt"), + run(String(cwd), "./code.md"), + run(String(cwd), "code.md"), + run(String(cwd), "run", "code.md"), + ]), + ).toEqual([rendered, rendered, ranCode, ranCode, ranCode]); + }); + + // On Windows, a file symlink needs a privilege that the test user can lack. + it.skipIf(isWindows)("uses the extension of the file that a symlink points to", async () => { + using cwd = tempDir("bun-run-unrunnable-symlink", { + "style.css": "body { color: red }", + "script.js": "console.log('ran script.js');", + }); + symlinkSync("style.css", join(String(cwd), "style-link.js")); + symlinkSync("script.js", join(String(cwd), "script-link.css")); + expect(await run(String(cwd), "./style-link.js")).toEqual(cannotRun("style.css", "css")); + expect(await run(String(cwd), "./script-link.css")).toEqual({ + stdout: "ran script.js\n", + stderr: "", + exitCode: 0, + }); + }); + + it("does not execute the file as a binary", async () => { + using cwd = tempDir("bun-run-unrunnable-executable", { + "tool.css": "#!/bin/sh\necho executed tool.css\n", + "sub": { "tool.css": "#!/bin/sh\necho executed sub/tool.css\n" }, + }); + chmodSync(join(String(cwd), "tool.css"), 0o755); + chmodSync(join(String(cwd), "sub", "tool.css"), 0o755); + const targets = ["./tool.css", "sub/tool.css", ...(isWindows ? ["sub\\tool.css"] : [])]; + expect( + await Promise.all(targets.flatMap(target => [run(String(cwd), target), run(String(cwd), "run", target)])), + ).toEqual(Array(targets.length * 2).fill(cannotRun("tool.css", "css"))); + }); + + // Only Windows looks for `tool.cmd` when the target is `sub/tool`. + it.skipIf(!isWindows)("runs the .cmd file next to a data file of the same name", async () => { + using cwd = tempDir("bun-run-cmd-sibling", { + "sub": { "tool.json": "{}", "tool.cmd": "@echo ran tool.cmd" }, + }); + const { stdout, stderr, exitCode } = await run(String(cwd), "run", "sub/tool"); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "ran tool.cmd", stderr: "", exitCode: 0 }); + }); + }); + it("should run from stdin", async () => { const res = await $`echo "console.log('hello')" | bun run -`.text(); expect(res).toBe(`hello\n`); diff --git a/test/cli/run/preload-test.test.js b/test/cli/run/preload-test.test.js index 72f9ddd9538a..bc449431b94d 100644 --- a/test/cli/run/preload-test.test.js +++ b/test/cli/run/preload-test.test.js @@ -118,7 +118,7 @@ plugin({ }); `; - test.todo("works from CLI", async () => { + test("works from CLI", async () => { const preloadDir = join(realpathSync(tmpdir()), "bun-preload-test6"); mkdirSync(preloadDir, { recursive: true }); const preloadPath = join(preloadDir, "preload.js");