Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions completions/bun.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
5 changes: 5 additions & 0 deletions docs/snippets/cli/run.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ bun run <file or script>
Evaluate argument as a script and print the result. Alias: <code>-p</code>
</ParamField>

<ParamField path="--check" type="boolean">
Check the syntax of the entry point without executing it. With no file (or <code>-</code>), reads from stdin.
Equivalent to <code>node --check</code>
</ParamField>

<ParamField path="--help" type="boolean">
Display this menu and exit. Alias: <code>-h</code>
</ParamField>
Expand Down
4 changes: 4 additions & 0 deletions src/options_types/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<[u8]>>,
pub experimental_http2_fetch: bool,
pub experimental_http3_fetch: bool,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/cli/Arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[
parse_param!(
"-p, --print <STR> Evaluate argument as a script and print the result"
),
parse_param!(
"--check Check the syntax of the entry point without executing it"
),
Comment thread
robobun marked this conversation as resolved.
parse_param!(
"--prefer-offline Skip staleness checks for packages in the Bun runtime and resolve from disk"
),
Expand Down Expand Up @@ -1079,6 +1082,16 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> Result<api::TransformOptions,
} else if let Some(script) = args.option(b"--eval") {
ctx.runtime_options.eval.script = script.into();
}
ctx.runtime_options.syntax_check = args.flag(b"--check");
Comment thread
robobun marked this conversation as resolved.
if ctx.runtime_options.syntax_check
&& (args.option(b"--eval").is_some() || args.option(b"--print").is_some())
{
Output::err_generic(
"either --check or --eval can be used, not both",
format_args!(""),
);
Global::exit(9);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
ctx.runtime_options.if_present = args.flag(b"--if-present");
ctx.runtime_options.smol = args.flag(b"--smol");
ctx.runtime_options.preconnect = slice_to_owned(args.options(b"--fetch-preconnect"));
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,10 @@ pub mod command {
};
ctx.args.target = Some(bun_options_types::schema::api::Target::Bun);

if ctx.runtime_options.syntax_check {
return run_command::RunCommand::exec_check(ctx);
}

if ctx.parallel || ctx.sequential {
// Result<Infallible, _>: if this returns at all, it's Err.
let Err(err) = super::multi_run::run(ctx);
Expand Down
90 changes: 90 additions & 0 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2950,6 +2950,92 @@
Ok(true)
}

/// `bun --check <file>` / `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 <file>` 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<u8>) = if let Some(entry) = entry {
let entry: Box<[u8]> = entry.clone();
match sys::File::read_from(Fd::cwd(), &entry) {
Comment thread
robobun marked this conversation as resolved.
Outdated
Ok(bytes) => (entry, bytes),
Err(err) => {
if ctx.runtime_options.if_present && err.get_errno() == sys::E::ENOENT {
Output::flush();
Global::exit(0);
}
pretty_errorln!(
"<r><red>error<r><d>:<r> Cannot find module {} ({})",
bun_core::fmt::quote(&entry),
bstr::BStr::new(err.name()),
);
Global::exit(1);
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
Outdated
} 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!(
"<r><red>error<r><d>:<r> Failed to read stdin ({})",
bstr::BStr::new(err.name()),
);
Global::exit(1);
}
}
};

let ext = paths::extension(&path);
let loader = bun_bundler::options::DEFAULT_LOADERS
.get(ext)
.copied()
.filter(|l| l.is_javascript_like())
.unwrap_or(Loader::Tsx);

Check warning on line 3012 in src/runtime/cli/run_command.rs

View check run for this annotation

Claude / Claude Code Review

exec_check ignores --loader/-l extension overrides

🟡 nit: `--loader`/`-l` is parsed alongside `--check` (Arguments.rs:851 → `ctx.args.loaders`) but `exec_check` only consults `DEFAULT_LOADERS.get(ext)` and never the user-supplied map — so e.g. `bun --check -l .js:ts script.js` parses as plain JS and rejects TS syntax, and `bun --check -l .config:ts app.config` falls back to TSX (where `<T>x` type assertions are JSX errors). Same accepted-but-ignored class as the `--if-present` case fixed in 11203f8, and unlike the deferred extension-resolution c
Comment thread
robobun marked this conversation as resolved.

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
Expand Down Expand Up @@ -2985,6 +3071,10 @@
// `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()];
Expand Down
219 changes: 219 additions & 0 deletions test/cli/run/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, 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("");
Comment thread
robobun marked this conversation as resolved.
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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("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);
});

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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("works via `bun run --check <file>`", 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);
});
});
Loading