Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/options_types/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,10 @@ pub struct RuntimeOptions {

#[derive(Default)]
pub struct Eval {
pub script: Box<[u8]>,
/// The `-e`/`-p` argument, or the bytes read from stdin for `bun -`.
/// `None` when neither was given; `Some` even when the script is empty,
/// since `bun -e ""` runs an empty program like `node -e ""` does.
pub script: Option<Box<[u8]>>,
pub eval_and_print: bool,
/// Under `--interactive`, `script` holds the node:repl bootstrap; this
/// holds the user's actual `-e` bytes so `process._eval` reports them
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/cli/Arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,7 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result<api::Tra
if let Some(port_str) = args.option(b"--port") {
if cmd == CommandTag::RunAsNodeCommand {
// TODO: prevent `node --port <script>` from working
ctx.runtime_options.eval.script = port_str.into();
ctx.runtime_options.eval.script = Some(port_str.into());
ctx.runtime_options.eval.eval_and_print = true;
} else {
opts.port = match strings::parse_int::<u16>(port_str, 10) {
Expand Down Expand Up @@ -1202,10 +1202,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result<api::Tra
}

if let Some(script) = args.option(b"--print") {
ctx.runtime_options.eval.script = script.into();
ctx.runtime_options.eval.script = Some(script.into());
ctx.runtime_options.eval.eval_and_print = true;
} else if let Some(script) = args.option(b"--eval") {
ctx.runtime_options.eval.script = script.into();
ctx.runtime_options.eval.script = Some(script.into());
}
ctx.runtime_options.if_present = args.flag(b"--if-present");
ctx.runtime_options.smol = args.flag(b"--smol");
Expand Down
49 changes: 14 additions & 35 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1191,17 +1191,16 @@ pub mod command {
}
}

// Fast path: `bun -v` / `bun --version` / `bun --revision`, the
// empty-eval forms `bun -e ''` / `bun -p ''` (and the `--eval=` /
// `--print=` spellings), and the dominant `bun <path>` / `bun .` run
// shape. Hoisted ABOVE `which()` and the per-tag `match` so these
// common invocations never decode the subcommand-name classifier
// (`which()` + its `RootCommandMatcher` name table / rodata) or walk
// the per-tag dispatch `match`. `bun --version` also skips
// `create_context_data` entirely (`arguments::parse` builds-and-drops
// a full `api::TransformOptions` and forces two `LazyLock`s for what
// is a no-op). Keeps `command::which`'s code/rodata and `arguments`'s
// clap tables out of the `--version` / `bun <file>` working set.
// Fast path: `bun -v` / `bun --version` / `bun --revision` and the
// dominant `bun <path>` / `bun .` run shape. Hoisted ABOVE `which()`
// and the per-tag `match` so these common invocations never decode
// the subcommand-name classifier (`which()` + its `RootCommandMatcher`
// name table / rodata) or walk the per-tag dispatch `match`.
// `bun --version` also skips `create_context_data` entirely
// (`arguments::parse` builds-and-drops a full `api::TransformOptions`
// and forces two `LazyLock`s for what is a no-op). Keeps
// `command::which`'s code/rodata and `arguments`'s clap tables out of
// the `--version` / `bun <file>` working set.
//
// Correctness guards:
// * argv0 must be a plain `bun` invocation — a `node` / `bunx` shim
Expand All @@ -1210,13 +1209,12 @@ pub mod command {
// the *predicates* are read here; `which()` performs the matching
// `PRETEND_TO_BE_NODE` / `IS_BUNX_EXE` side effects.
// * the standalone-graph probe above already ran, so a compiled
// executable's `--version` / `-e ''` is still passed through to
// user code (it returned via `boot_standalone`).
// executable's `--version` is still passed through to user code
// (it returned via `boot_standalone`).
// * the version check is exact-argv-shape (`len == 2`) so it cannot
// intercept `bun <bin> --version`, where the flag belongs to
// `<bin>` (the bug the old argv-scan shim had — see the
// NOTE below). The empty-eval check is likewise exact-shape, falling
// through to `HelpCommand.exec`.
// NOTE below).
{
let argv = bun::argv();
let argv0 = argv.get(0).map(bun_core::ZStr::as_bytes).unwrap_or(b"");
Expand All @@ -1229,25 +1227,6 @@ pub mod command {
}
}

let empty_eval = match argv.len() {
2 => matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e=" | b"-p=" | b"--eval=" | b"--print=")
),
3 => {
argv.get(2).is_some_and(|a| a.as_bytes().is_empty())
&& matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e" | b"-p" | b"--eval" | b"--print")
)
}
_ => false,
};
if empty_eval {
Output::flush();
return HelpCommand::exec();
}

// `bun <path>` / `bun .` — the dominant run shape. argv[1] is
// path-shaped (`looks_like_run_entrypoint`), which no
// subcommand keyword can be, so `which()` would unambiguously
Expand Down Expand Up @@ -1451,7 +1430,7 @@ pub mod command {
}
}

if tag == Tag::AutoCommand && !ctx.runtime_options.eval.script.is_empty() {
if tag == Tag::AutoCommand && ctx.runtime_options.eval.script.is_some() {
return run_command::RunCommand::exec_eval(ctx);
}

Expand Down
25 changes: 12 additions & 13 deletions src/runtime/cli/repl_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,14 @@ impl ReplCommand {
repl,
vm,
arena,
// ctx is the process-global ContextData; extend the borrow past the
// local reborrow lifetime via raw ptr (the runner never outlives
// ctx — global_exit() is `!`).
eval_script: {
let ptr: *const [u8] = &raw const *ctx.runtime_options.eval.script;
// SAFETY: ctx.runtime_options.eval.script lives in the process-global
// ContextData; the raw-ptr reborrow is sound because the runner never
// outlives it — hold_api_lock returns into global_exit() (`!`).
unsafe { &*ptr }
},
// The runner never returns (hold_api_lock ends in global_exit()),
// so the script can simply be leaked for the process lifetime.
eval_script: ctx
.runtime_options
.eval
.script
.take()
.map(|script| &*Box::leak(script)),
eval_and_print: ctx.runtime_options.eval.eval_and_print,
};
// vm.arena stores a *mut Arena pointing at runner.arena;
Expand Down Expand Up @@ -201,7 +199,8 @@ struct ReplRunner<'a, 'r> {
repl: &'a mut Repl<'r>,
vm: *mut VirtualMachine,
arena: Arena,
eval_script: &'a [u8],
/// `-e`/`-p` script; `None` runs the interactive loop instead.
eval_script: Option<&'a [u8]>,
eval_and_print: bool,
}

Expand All @@ -221,10 +220,10 @@ impl<'a, 'r> ReplRunner<'a, 'r> {
vm.global_exit();
}

if !this.eval_script.is_empty() || this.eval_and_print {
if let Some(eval_script) = this.eval_script {
// Non-interactive: evaluate the -e/--eval or -p/--print script,
// drain the event loop, and exit
let had_error = this.repl.eval_script(this.eval_script, this.eval_and_print);
let had_error = this.repl.eval_script(eval_script, this.eval_and_print);
Output::flush();
if had_error {
// Only overwrite on error so `process.exitCode = N` in the
Expand Down
20 changes: 8 additions & 12 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,16 +979,12 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/run<r>
let mut run_entry = entry;
vm.set_main(entry);

if !ctx.runtime_options.eval.script.is_empty() {
if let Some(eval_script) = ctx.runtime_options.eval.script.as_deref() {
// SAFETY: `ctx.runtime_options.eval.script` is process-lifetime
// (CLI argv); erase the borrow lifetime so the `Source` (stored in
// the VM for the process duration) can backref into it.
let script: &'static [u8] = unsafe {
::core::slice::from_raw_parts(
ctx.runtime_options.eval.script.as_ptr(),
ctx.runtime_options.eval.script.len(),
)
};
let script: &'static [u8] =
unsafe { ::core::slice::from_raw_parts(eval_script.as_ptr(), eval_script.len()) };
vm.module_loader.eval_source =
Some(Box::new(bun_ast::Source::init_path_string(entry, script)));
vm.module_loader.interactive_eval_script =
Expand Down Expand Up @@ -1259,7 +1255,7 @@ impl Run<'_> {
fn JSC__JSGlobalObject__addGc(global: *const JSGlobalObject);
}
let ro = &ctx.runtime_options;
if !ro.eval.script.is_empty() {
if ro.eval.script.is_some() {
// SAFETY: FFI; `vm.global` is live for the VM lifetime.
unsafe { Bun__ExposeNodeModuleGlobals(vm.global) };
}
Expand Down Expand Up @@ -2798,7 +2794,7 @@ impl RunCommand {
if bun_sys::File::stdin().read_to_end_into(&mut list).is_err() {
return Ok(false);
}
ctx.runtime_options.eval.script = list.into_boxed_slice();
ctx.runtime_options.eval.script = Some(list.into_boxed_slice());

#[cfg(windows)]
const STDIN_TRIGGER: &[u8] = b"\\[stdin]";
Expand Down Expand Up @@ -2846,8 +2842,8 @@ impl RunCommand {
// bootstrap via `[eval]`; it runs `process._eval` like Node's
// internal/main/repl.js — no source splicing.
ctx.runtime_options.eval.interactive_script =
Some(::core::mem::take(&mut ctx.runtime_options.eval.script));
ctx.runtime_options.eval.script = bootstrap.to_vec().into_boxed_slice();
Some(ctx.runtime_options.eval.script.take().unwrap_or_default());
ctx.runtime_options.eval.script = Some(bootstrap.to_vec().into_boxed_slice());
Self::exec_eval(ctx)
}

Expand Down Expand Up @@ -2900,7 +2896,7 @@ impl RunCommand {
return Self::exec_node_repl(ctx);
}

if !ctx.runtime_options.eval.script.is_empty() {
if ctx.runtime_options.eval.script.is_some() {
// synthetic `[eval]` path under cwd
let mut entry_point_buf = [0u8; MAX_PATH_BYTES + EVAL_TRIGGER.len()];
let mut cwd_buf = PathBuffer::uninit();
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/node/node_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,11 @@ mod _impl {
}
return ZigString::init(script).with_encoding().to_js(global_object);
}
if let Some(source) = vm.module_loader.eval_source.as_deref() {
// Node only defines `process._eval` for a non-empty script, so
// `-e ""` reports `undefined` just like the `--interactive` case above.
if let Some(source) = vm.module_loader.eval_source.as_deref()
&& !source.contents().is_empty()
{
return ZigString::init(source.contents())
.with_encoding()
.to_js(global_object);
Expand Down
21 changes: 21 additions & 0 deletions test/cli/run/as-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ describe("fake node cli", () => {
expect(fakeNodeRun(temp, ["-e", "console.log('pass')"]).stdout).toBe("pass");
});

// Like node, an empty -e / -p script is still a script to run. It used to be
// treated as no script at all (the "Missing script" / REPL path below), so
// stdin is pinned to a pipe here as well.
test.each([
[["-e", ""], ""],
[["-p", ""], "undefined\n"],
[["-pe", ""], "undefined\n"],
])("node %j runs an empty program", async (args, expectedStdout) => {
using temp = tempDir("fake-node", {});
await using proc = Bun.spawn({
cmd: [bunExe(), "--bun", "node", ...args],
cwd: String(temp),
env: { ...bunEnv, NODE_ENV: undefined },
stdin: Buffer.alloc(0),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: expectedStdout, stderr: "", exitCode: 0 });
});

test("process args work", () => {
using temp = tempDir("fake-node", {
"index.js": "console.log(JSON.stringify(process.argv.slice(1)))",
Expand Down
60 changes: 59 additions & 1 deletion test/cli/run/run-eval.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SyncSubprocess } from "bun";
import { describe, expect, test } from "bun:test";
import { rmSync, writeFileSync } from "fs";
import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness";
import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness";
import { tmpdir } from "os";
import { join, sep } from "path";

Expand Down Expand Up @@ -108,6 +108,64 @@ for (const flag of ["-e", "--print"]) {
});
}

// `node -e ""` and `node -p ""` run an empty program. Bun used to treat an
// empty script the same as no script at all and printed the help text instead.
describe.concurrent("empty script", () => {
async function runBun(args: string[], options: { cwd?: string; stdin?: Buffer } = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: bunEnv,
cwd: options.cwd,
stdin: options.stdin ?? "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

test.each([
[["-e", ""], ""],
[["--eval", ""], ""],
[["-e="], ""],
[["--eval="], ""],
[["-p", ""], "undefined\n"],
[["--print", ""], "undefined\n"],
[["-p="], "undefined\n"],
[["--print="], "undefined\n"],
[["-pe", ""], "undefined\n"],
])("bun %j runs an empty program instead of printing help", async (args, expectedStdout) => {
expect(await runBun(args)).toEqual({ stdout: expectedStdout, stderr: "", exitCode: 0 });
});

test.each(["-e", "-p"])(
"bun %s '' still runs preloads and keeps the remaining arguments as process.argv",
async flag => {
using dir = tempDir("empty-eval", {
"preload.js": `console.log(JSON.stringify({ _eval: typeof process._eval, argv: process.argv.slice(1) }));`,
});
// Without the fix, "abc" is looked up as a script to run instead.
const { stdout, stderr, exitCode } = await runBun(["-r", "./preload.js", flag, "", "abc", "def"], {
cwd: String(dir),
});
expect(stderr).toBe("");
const [preloadLine, ...rest] = stdout.split("\n");
// Like node, process._eval is only defined for a non-empty script.
expect(JSON.parse(preloadLine)).toEqual({ _eval: "undefined", argv: ["abc", "def"] });
expect(rest.join("\n")).toBe(flag === "-p" ? "undefined\n" : "");
expect(exitCode).toBe(0);
},
);

test.each([
["bun -", ["-"]],
["bun run -", ["run", "-"]],
])("%s with empty stdin runs an empty program", async (_, args) => {
// Without the fix this fails with "Module not found '<cwd>/[stdin]'".
expect(await runBun(args, { stdin: Buffer.alloc(0) })).toEqual({ stdout: "", stderr: "", exitCode: 0 });
});
});

describe("--print for cjs/esm", () => {
test("eval result between esm imports", async () => {
let cwd = tmpdirSync();
Expand Down
18 changes: 18 additions & 0 deletions test/js/bun/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,11 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});

test("-e with empty script exits instead of starting the interactive REPL", async () => {
const { stdout, stderr, exitCode } = await runReplWith(["-e", ""]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 });
});

test("-e supports TypeScript", async () => {
const { stdout, exitCode } = await runReplWith(["-p", "const x: number = 42; x * 2"]);
expect(stdout).toBe("84\n");
Expand Down Expand Up @@ -1429,6 +1434,19 @@ describe.concurrent("--interactive", () => {
interactiveTimeout,
);

// `node -i -e ""` still enters the REPL and leaves process._eval undefined.
test(
"-e with an empty script enters the REPL with process._eval undefined",
async () => {
const { stdout, stderr, exitCode } = await runInteractive(["-e", ""], 'console.log("EVAL=" + process._eval)\n');
expect(stdout).toContain("Welcome to Bun");
expect(stdout).toContain("EVAL=undefined");
expect(stderr).not.toContain("error");
expect(exitCode).toBe(0);
},
interactiveTimeout,
);

// The bootstrap runs -e via vm.runInThisContext (raw JS, matching
// `node -i -e`); TypeScript syntax is a SyntaxError, not transpiled.
test(
Expand Down
Loading