Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 18 additions & 7 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,18 @@ pub mod command {
strings::contains_char(basename, b'.')
}

/// `-e` / `--eval` / `-p` / `--print` in any spelling `arguments::parse`
/// accepts for [`Tag::AutoCommand`]: the value as the next token, attached
/// (`-p1+1`, `-p=1+1`, `--print=1+1`), or node's `-pe` (a
/// `NODE_SHORT_ALIASES` entry). `-e` and `-p` take a value, so anything
/// trailing them in the same token is that value, never another flag.
#[inline]
fn is_eval_flag(arg: &[u8]) -> bool {
matches!(arg, [b'-', b'e' | b'p', ..] | b"--eval" | b"--print")
|| arg.starts_with(b"--eval=")
|| arg.starts_with(b"--print=")
}

/// `#[inline(never)]`: argv→`Tag` classification, called once from
/// `Cli::start` on every `bun` invocation. Kept a concrete symbol so
/// `src/startup.order` can place it next to `Cli::start` /
Expand Down Expand Up @@ -937,10 +949,12 @@ pub mod command {
let Some(mut first_arg_name) = iter.next() else {
return Tag::AutoCommand;
};
while !first_arg_name.is_empty()
&& first_arg_name[0] == b'-'
&& !(first_arg_name.len() > 1 && first_arg_name[1] == b'e')
{
while !first_arg_name.is_empty() && first_arg_name[0] == b'-' {
// What follows an eval flag is the script and its argv, so `bun -p test`
// evaluates `test` rather than running `bun test`.
if is_eval_flag(first_arg_name) {
return Tag::AutoCommand;
}
// `--interactive` stays on AutoCommand: Arguments.rs parses it and the no-target check
// routes to RunCommand::exec_node_repl. An early ReplCommand return here would bypass
// that and boot the legacy `bun repl` implementation instead.
Expand Down Expand Up @@ -1068,9 +1082,6 @@ pub mod command {
}
return Tag::AutoCommand;
}
if x == RootCommandMatcher::case(b"-e") {
return Tag::AutoCommand;
}
Tag::AutoCommand
}

Expand Down
70 changes: 69 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,74 @@ for (const flag of ["-e", "--print"]) {
});
}

// The subcommand lookup skips the leading flags and matches the next argument
// against the subcommand names. It used to stop only at `-e`; after any other
// eval flag the code (or, with an attached value, the script's first argument)
// was matched instead, so `bun -p test` ran the test runner and `bun -pe help`
// printed bun's help. Every child runs in an empty directory so a misdispatched
// subcommand has nothing to act on.
describe("eval flags take precedence over subcommand names", () => {
describe("the code is a subcommand name", () => {
const cases: string[][] = [
["-p", "test"],
["--print", "test"],
["--eval", "test"],
["-pe", "help"],
["-e", "test"],
// The eval flag comes after another flag.
["--smol", "-p", "test"],
];

for (const args of cases) {
const name = args[args.length - 1];
test.concurrent(`bun ${args.join(" ")} evaluates \`${name}\``, async () => {
using dir = tempDir("eval-subcommand-name", {});
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
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).toContain(`ReferenceError: ${name} is not defined`);
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});
}
});

describe("a script argument is a subcommand name", () => {
const argv = "JSON.stringify(process.argv.slice(1))";
const cases: string[][] = [
[`-p${argv}`, "test"],
[`-p=${argv}`, "test"],
[`--print=${argv}`, "test"],
[`--eval=console.log(${argv})`, "help"],
[`-e=console.log(${argv})`, "test"],
["-p", argv, "test"],
];

for (const args of cases) {
const name = args[args.length - 1];
test.concurrent(`bun ${args.join(" ")} passes \`${name}\` to the script`, async () => {
using dir = tempDir("eval-subcommand-arg", {});
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
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(JSON.stringify([name]) + "\n");
expect(exitCode).toBe(0);
});
}
});
});

describe("--print for cjs/esm", () => {
test("eval result between esm imports", async () => {
let cwd = tmpdirSync();
Expand Down