From 2670e508bc2002176ae5b65f69583e136e31d62b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:40:01 +0000 Subject: [PATCH 1/2] cli: add --check flag to syntax-check a file without executing bun --check file.js parses the entry point with Bun's JS/TS parser and exits 0 (no output) on success or 1 with the syntax error, without booting the JS VM. Matches node --check semantics: reads stdin when no file is given, rejects combining with -e/-p, and honors --if-present. Before this, --check was an unknown flag and was silently skipped, so the file ran its top level. -c remains Bun's --config alias. --- completions/bun.zsh | 1 + docs/snippets/cli/run.mdx | 5 + src/options_types/context.rs | 3 + src/runtime/cli/Arguments.rs | 10 ++ src/runtime/cli/mod.rs | 4 + src/runtime/cli/run_command.rs | 102 +++++++++++++ test/cli/run/check.test.ts | 266 +++++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 test/cli/run/check.test.ts diff --git a/completions/bun.zsh b/completions/bun.zsh index 3a02d58b200c..22b4f2895e56 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -472,6 +472,7 @@ _bun_run_completion() { '--cwd[Absolute path to resolve files & entry points from. This just changes the process cwd]:cwd' \ '--config[Config file to load bun from (e.g. -c bunfig.toml]: :->config' \ '-c[Config file to load bun from (e.g. -c bunfig.toml]: :->config' \ + '--check[Check the syntax of the entry point without executing it]' \ '--env-file[Load environment variables from the specified file(s)]:env-file' \ '--extension-order[Defaults to: .tsx,.ts,.jsx,.js,.json]:extension-order' \ '--jsx-factory[Changes the function called when compiling JSX elements using the classic JSX runtime]:jsx-factory' \ diff --git a/docs/snippets/cli/run.mdx b/docs/snippets/cli/run.mdx index dc59946698f5..2af08e12a126 100644 --- a/docs/snippets/cli/run.mdx +++ b/docs/snippets/cli/run.mdx @@ -22,6 +22,11 @@ bun run Evaluate argument as a script and print the result. Alias: -p + + Check the syntax of the entry point without executing it. With no file (or -), reads from stdin. + Equivalent to node --check + + Display this menu and exit. Alias: -h diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 4190731b8ed8..838434523698 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -544,6 +544,8 @@ pub struct RuntimeOptions { pub redis_preconnect: bool, pub sql_preconnect: bool, pub eval: Eval, + /// `--check`: parse the entry point and exit without executing. + pub syntax_check: bool, pub preconnect: Vec>, pub experimental_http2_fetch: bool, pub experimental_http3_fetch: bool, @@ -615,6 +617,7 @@ impl Default for RuntimeOptions { redis_preconnect: false, sql_preconnect: false, eval: Eval::default(), + syntax_check: false, preconnect: Vec::new(), experimental_http2_fetch: false, experimental_http3_fetch: false, diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index ab0fc39e9b37..439d2fad84cd 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -228,6 +228,9 @@ const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "-p, --print Evaluate argument as a script and print the result" ), + parse_param!( + "--check Check the syntax of the entry point without executing it" + ), parse_param!( "--prefer-offline Skip staleness checks for packages in the Bun runtime and resolve from disk" ), @@ -1205,6 +1208,13 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result: if this returns at all, it's Err. let Err(err) = super::multi_run::run(ctx); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index b0a92bc2ac43..ad6ff4945540 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -2857,6 +2857,104 @@ impl RunCommand { Self::exec_eval(ctx) } + /// `bun --check ` / `node --check` — parse the entry point and exit + /// 0 on success, or print syntax errors and exit 1. Never boots the JS VM. + #[cold] + #[inline(never)] + pub(crate) fn exec_check(ctx: &mut ContextData) -> crate::Result<()> { + bun_ast::initialize_store(); + let arena = runner_arena(); + + let mut positionals: &[Box<[u8]>] = &ctx.positionals[..]; + // `bun run --check ` leaves "run" in positionals[0]; strip it + // (but not under node emulation, where positionals[0] is the script). + if !crate::cli::PRETEND_TO_BE_NODE.load(::core::sync::atomic::Ordering::Relaxed) + && !positionals.is_empty() + && positionals[0].as_ref() == b"run" + { + positionals = &positionals[1..]; + } + + let entry = positionals.first().filter(|e| e.as_ref() != b"-"); + let (path, contents): (Box<[u8]>, Vec) = if let Some(entry) = entry { + let entry: Box<[u8]> = entry.clone(); + // Cursor-read (not pread-based `read_from`) so pipes passed as + // paths (process substitution, FIFOs) work like `node --check`. + match sys::File::openat(Fd::cwd(), &entry, sys::O::RDONLY, 0).and_then(|f| { + let mut bytes = Vec::new(); + f.read_to_end_into(&mut bytes).map(|_| bytes) + }) { + Ok(bytes) => (entry, bytes), + Err(err) => { + if ctx.runtime_options.if_present && err.get_errno() == sys::E::ENOENT { + Output::flush(); + Global::exit(0); + } + let verb: &[u8] = if err.get_errno() == sys::E::ENOENT { + b"find module" + } else { + b"read" + }; + pretty_errorln!( + "error: Cannot {} {} ({})", + bstr::BStr::new(verb), + bun_core::fmt::quote(&entry), + bstr::BStr::new(err.name()), + ); + Global::exit(1); + } + } + } else { + let mut bytes = Vec::new(); + match sys::File::stdin().read_to_end_into(&mut bytes) { + Ok(_) => (Box::from(&b"[stdin]"[..]), bytes), + Err(err) => { + pretty_errorln!( + "error: Failed to read stdin ({})", + bstr::BStr::new(err.name()), + ); + Global::exit(1); + } + } + }; + + let ext = paths::extension(&path); + let user_loader = ctx.args.loaders.as_ref().and_then(|m| { + m.extensions + .iter() + .position(|e| strings::eql(e, ext)) + .map(|i| ::from_api(m.loaders[i])) + }); + let loader = user_loader + .or_else(|| bun_bundler::options::DEFAULT_LOADERS.get(ext).copied()) + .filter(|l| l.is_javascript_like()) + .unwrap_or(Loader::Tsx); + + let source = bun_ast::Source::init_path_string(&*path, &*contents); + let define = bun_js_parser::Define::default(); + let mut opts = + bun_js_parser::ParserOptions::init(bun_options_types::jsx::Pragma::default(), loader); + opts.features.top_level_await = true; + + // SAFETY: `ctx.log` is the process-lifetime CLI log set in + // `Command::start`; no other borrow is live here. + let log: &mut bun_ast::Log = unsafe { ctx.log() }; + let had_error = match bun_js_parser::Parser::init(opts, log, &source, &define, arena) { + Ok(parser) => parser.parse().is_err(), + Err(_) => true, + }; + + // SAFETY: see above; the parser's `&mut Log` borrow has ended. + let log: &mut bun_ast::Log = unsafe { ctx.log() }; + if had_error || log.has_errors() { + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + Output::flush(); + Global::exit(1); + } + Output::flush(); + Global::exit(0); + } + /// Synthetic `cwd/[eval]` /// entry point + boot. `Arguments::parse` has already stashed the script /// in `ctx.runtime_options.eval.script`. Public so `Command::start` can @@ -2897,6 +2995,10 @@ impl RunCommand { // values. Explicit `--env-file` is still honored. #6338 ctx.args.disable_default_env_files = true; + if ctx.runtime_options.syntax_check { + return Self::exec_check(ctx); + } + // `node --interactive [-e code]`: same gate as AutoCommand — a script // positional wins, and `-p` currently bypasses the REPL (see mod.rs). if ctx.runtime_options.interactive diff --git a/test/cli/run/check.test.ts b/test/cli/run/check.test.ts new file mode 100644 index 000000000000..fbdb8a3de68a --- /dev/null +++ b/test/cli/run/check.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; + +describe.concurrent("bun --check", () => { + test("exits 0 and produces no output for a syntactically valid file", async () => { + using dir = tempDir("check-valid", { + "ok.js": `const x = 1;\nconsole.log("SHOULD NOT RUN");\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "ok.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The script must not execute. + expect(stdout).not.toContain("SHOULD NOT RUN"); + expect(stdout).toBe(""); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("exits 1 and reports a syntax error for an invalid file", async () => { + using dir = tempDir("check-invalid", { + "bad.js": `const x = ;\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "bad.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stderr).toContain("bad.js"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); + + test("does not execute the file", async () => { + using dir = tempDir("check-noexec", { + "side-effect.js": ` + import fs from "node:fs"; + fs.writeFileSync("ran.txt", "1"); + process.exit(42); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "side-effect.js"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(await Bun.file(`${dir}/ran.txt`).exists()).toBe(false); + expect(exitCode).toBe(0); + }); + + test("accepts TypeScript syntax in .ts files", async () => { + using dir = tempDir("check-ts", { + "ok.ts": `interface Foo { x: number }\nconst y: Foo = { x: 1 };\nexport { y };\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "ok.ts"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(exitCode).toBe(0); + }); + + test("honors --loader overrides", async () => { + // TS syntax in a .js file: fails by default, passes with -l .js:ts. + using dir = tempDir("check-loader", { + "script.js": `interface Foo { x: number }\n`, + }); + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "script.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + } + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "-l", ".js:ts", "script.js"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(exitCode).toBe(0); + } + }); + + test("accepts ESM import/export syntax", async () => { + using dir = tempDir("check-esm", { + "esm.mjs": `import foo from "bar";\nexport const x = 1;\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "esm.mjs"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(exitCode).toBe(0); + }); + + test("accepts top-level await", async () => { + using dir = tempDir("check-tla", { + "tla.js": `const x = await Promise.resolve(1);\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "tla.js"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(exitCode).toBe(0); + }); + + test("reads from stdin when no file is given", async () => { + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check"], + env: bunEnv, + stdin: new Blob(["let x = 1;\n"]), + 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(""); + expect(exitCode).toBe(0); + } + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check"], + env: bunEnv, + stdin: new Blob(["const x = ;\n"]), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stderr).toContain("[stdin]"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + } + }); + + test("reads from stdin when the positional is `-`", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "-"], + env: bunEnv, + stdin: new Blob(["let x = 1;\n"]), + 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(""); + expect(exitCode).toBe(0); + }); + + // Process substitution hands bun a pipe via a /dev/fd path; POSIX shells only. + test.skipIf(isWindows)("reads a pipe passed as a path (process substitution)", async () => { + await using proc = Bun.spawn({ + cmd: ["bash", "-c", `${JSON.stringify(bunExe())} --check <(echo "let x = 1;")`], + 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(""); + expect(exitCode).toBe(0); + }); + + test("exits 1 when the file does not exist", async () => { + using dir = tempDir("check-missing", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "nope.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stderr).toContain("nope.js"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); + + test("exits 0 when the file does not exist and --if-present is set", async () => { + using dir = tempDir("check-if-present", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "--if-present", "nope.js"], + env: bunEnv, + cwd: String(dir), + 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(""); + expect(exitCode).toBe(0); + }); + + test.each(["-e", "--eval", "-p", "--print"])("rejects combining --check with %s", async flag => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", flag, "1 + 1"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr.toLowerCase()).toContain("either --check or --eval"); + expect(stdout).toBe(""); + expect(exitCode).toBe(9); + }); + + test("works via `bun run --check `", async () => { + using dir = tempDir("check-run", { + "bad.js": `function f( {\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "--check", "bad.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); +}); From 205f030e86c8d089e2623609a7928d25322642f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:04:27 +0000 Subject: [PATCH 2/2] test: cover --check under node argv0 emulation Exercises the exec_as_if_node dispatch and the node-mode guard on the 'run' positional strip (a file literally named run must be checked, not stripped). --- test/cli/run/check.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/cli/run/check.test.ts b/test/cli/run/check.test.ts index fbdb8a3de68a..29c96be99a1f 100644 --- a/test/cli/run/check.test.ts +++ b/test/cli/run/check.test.ts @@ -263,4 +263,44 @@ describe.concurrent("bun --check", () => { expect(stdout).toBe(""); expect(exitCode).toBe(1); }); + + test("works as `node --check ` (argv0 emulation) without executing", async () => { + using dir = tempDir("check-as-node", { + "script.js": `require("fs").writeFileSync("ran.txt", "1");\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "script.js"], + argv0: "node", + env: bunEnv, + cwd: String(dir), + 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(""); + expect(await Bun.file(`${dir}/ran.txt`).exists()).toBe(false); + expect(exitCode).toBe(0); + }); + + test("`node --check run` checks a file literally named `run`", async () => { + // Under node emulation positionals[0] is the script, so the `run` + // subcommand strip must not apply. + using dir = tempDir("check-as-node-run", { + run: `const x = ;\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "run"], + argv0: "node", + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error"); + expect(stderr).toContain("run"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); });