diff --git a/completions/bun.zsh b/completions/bun.zsh index 34940fef110..fb30768be87 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -443,6 +443,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 ba4f375f256..f6e10c380fc 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 4d39c7e861b..58ff614915e 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -534,6 +534,9 @@ pub struct RuntimeOptions { pub redis_preconnect: bool, pub sql_preconnect: bool, pub eval: Eval, + /// `--check` — parse the entry point for syntax errors and exit without + /// executing it (`node --check` / `-c`). + pub syntax_check: bool, pub preconnect: Vec>, pub experimental_http2_fetch: bool, pub experimental_http3_fetch: bool, @@ -598,6 +601,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 accab9fb352..0df3e06d25e 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -244,6 +244,9 @@ pub(crate) 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" ), @@ -1079,6 +1082,16 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> 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 a98d7bce7f2..667e14a9091 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -2950,6 +2950,113 @@ impl RunCommand { Ok(true) } + /// `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 fn exec_check(ctx: &mut ContextData) -> Result<(), bun_core::Error> { + bun_ast::initialize_store(); + let arena = runner_arena(); + + let mut positionals: &[Box<[u8]>] = &ctx.positionals[..]; + // `bun run --check ` puts "run" in positionals[0]; strip it. + // Not applicable under `node`-argv0 emulation, where positionals[0] + // is always the script name (a file literally named "run" must not + // be stripped). + 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 instead of `read_from` (pread-based) so pipes + // reached via a path — process substitution (`/dev/fd/N`), FIFOs — + // work like they do in `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); + } + // "Cannot find" is only true for ENOENT; EISDIR/EACCES/etc. + // found the path but could not read it. + 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 { + // `read_to_end()` pread()s from offset 0, which fails on a pipe + // (ESPIPE). Stream-read stdin into a growable buffer instead. + 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); + // `--loader .ext:loader` overrides take precedence over the defaults, + // matching the run 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 @@ -2985,6 +3092,10 @@ impl RunCommand { // `Command::which()` before dispatch. debug_assert!(crate::cli::PRETEND_TO_BE_NODE.load(::core::sync::atomic::Ordering::Relaxed)); + if ctx.runtime_options.syntax_check { + return Self::exec_check(ctx); + } + if !ctx.runtime_options.eval.script.is_empty() { // synthetic `[eval]` path under cwd let mut entry_point_buf = [0u8; MAX_PATH_BYTES + EVAL_TRIGGER.len()]; diff --git a/test/cli/run/check.test.ts b/test/cli/run/check.test.ts new file mode 100644 index 00000000000..fbdb8a3de68 --- /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); + }); +});