diff --git a/docs/runtime/check.mdx b/docs/runtime/check.mdx index d4c6816ed632..5b92f30a9dfe 100644 --- a/docs/runtime/check.mdx +++ b/docs/runtime/check.mdx @@ -453,6 +453,18 @@ bun check # runs "biome check" bun --check # type checks the project ``` +Pass checker options before the file or script name: + +```bash terminal icon="terminal" +bun --check --threads 2 --project tsconfig.app.json --pretty false +bun run --check --threads 2 --timing src/index.ts +bun test --check --threads 2 --no-pretty +``` + +`--threads`, `--project`, `--pretty`, `--no-pretty`, `--all`, and `--timing` also work with `--check`. +`bun build --check` supports `--threads` and `--project`; it uses the build's error format. +Use the long `--project` name here: `-p` still means `--print`. With `bun run`, options after a file or script name belong to that program. + Inside the `check` script, and in any script it starts, `bun check` is the type checker. That makes `"check": "bun check"` work. diff --git a/src/clap/comptime.rs b/src/clap/comptime.rs index 4ed91da90cbf..c3c036c062a8 100644 --- a/src/clap/comptime.rs +++ b/src/clap/comptime.rs @@ -541,6 +541,8 @@ impl ComptimeClap { params: table.converted, iter, diagnostic: opt.diagnostic, + suppress_warnings: opt.suppress_warnings, + optional_booleans: opt.optional_booleans, state: streaming::State::Normal, positional: None, short_aliases: opt.short_aliases, diff --git a/src/clap/lib.rs b/src/clap/lib.rs index d130083d8535..1e5a9459d1df 100644 --- a/src/clap/lib.rs +++ b/src/clap/lib.rs @@ -368,6 +368,10 @@ impl Default for Help { #[derive(Default)] pub struct ParseOptions<'a> { pub diagnostic: Option<&'a mut Diagnostic>, + /// A trial parse must not print warnings before its caller selects a table. + pub suppress_warnings: bool, + /// Optional boolean values may also be separate `true` or `false` tokens. + pub optional_booleans: &'static [&'static [u8]], pub stop_after_positional_at: usize, /// Whole-token rewrites applied only where a token is being classified as a /// flag, never to an option's value or a `--` target. Node keeps its own @@ -457,6 +461,8 @@ pub fn parse( &mut iter, ParseOptions { diagnostic: opt.diagnostic, + suppress_warnings: opt.suppress_warnings, + optional_booleans: opt.optional_booleans, stop_after_positional_at: opt.stop_after_positional_at, short_aliases: opt.short_aliases, unknown_long_flags_are_positional: opt.unknown_long_flags_are_positional, @@ -478,6 +484,8 @@ pub fn parse_with_table( &mut iter, ParseOptions { diagnostic: opt.diagnostic, + suppress_warnings: opt.suppress_warnings, + optional_booleans: opt.optional_booleans, stop_after_positional_at: opt.stop_after_positional_at, short_aliases: opt.short_aliases, unknown_long_flags_are_positional: opt.unknown_long_flags_are_positional, diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index 56cb982f16c9..00a5bd409e69 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -62,6 +62,8 @@ pub struct StreamingClap<'p, 'a, Id, ArgIterator> { pub(crate) state: State<'a>, pub(crate) positional: Option<&'p clap::Param>, pub(crate) diagnostic: Option<&'p mut clap::Diagnostic>, + pub(crate) suppress_warnings: bool, + pub(crate) optional_booleans: &'static [&'static [u8]], pub(crate) short_aliases: &'static [(&'static [u8], &'static [u8])], pub(crate) unknown_long_flags_are_positional: bool, } @@ -127,7 +129,19 @@ where return Ok(Some(Arg { param, - value: maybe_value, + value: maybe_value.or_else(|| { + if self.optional_booleans.contains(&name) + && self + .iter + .remain() + .first() + .is_some_and(|value| matches!(*value, b"true" | b"false")) + { + self.iter.next() + } else { + None + } + }), })); } @@ -158,7 +172,8 @@ where // unrecognized command // if flag else arg if arg_info.kind == ArgKind::Long || arg_info.kind == ArgKind::Short { - if WARN_ON_UNRECOGNIZED_FLAG.load(Ordering::Relaxed) { + if !self.suppress_warnings && WARN_ON_UNRECOGNIZED_FLAG.load(Ordering::Relaxed) + { bun_core::warn!( "unrecognized flag: {}{}\n", if arg_info.kind == ArgKind::Long { @@ -175,7 +190,7 @@ where return self.next(); } - if WARN_ON_UNRECOGNIZED_FLAG.load(Ordering::Relaxed) { + if !self.suppress_warnings && WARN_ON_UNRECOGNIZED_FLAG.load(Ordering::Relaxed) { bun_core::warn!("unrecognized argument: {}\n", bstr::BStr::new(name)); Output::flush(); } @@ -382,6 +397,8 @@ mod tests { remain: args_strings, }; let mut c = StreamingClap:: { + suppress_warnings: false, + optional_booleans: &[], short_aliases: &[], unknown_long_flags_are_positional: false, params, @@ -416,6 +433,8 @@ mod tests { remain: args_strings, }; let mut c = StreamingClap:: { + suppress_warnings: false, + optional_booleans: &[], short_aliases: &[], unknown_long_flags_are_positional: false, params, @@ -889,6 +908,8 @@ mod tests { let args: &[&[u8]] = &[b"--q", b"x", b"--aa", b"--q=1", b"--", b"--aa"]; let mut iter = args::SliceIterator { remain: args }; let mut c = StreamingClap:: { + suppress_warnings: false, + optional_booleans: &[], short_aliases: &[], unknown_long_flags_are_positional: true, params: ¶ms, diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 76032b748cb2..82b79f132e2d 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -561,6 +561,7 @@ pub struct RuntimeOptions { pub smol: bool, /// `--check`: type check what is about to be run, and do not run it if there are errors. pub check: bool, + pub check_options: CheckOptions, pub debugger: Debugger, pub if_present: bool, pub redis_preconnect: bool, @@ -584,6 +585,14 @@ pub struct RuntimeOptions { pub heap_prof: HeapProf, } +#[derive(Clone, Copy, Default)] +pub struct CheckOptions { + pub threads: usize, + pub pretty: Option, + pub all: bool, + pub timing: bool, +} + #[derive(Default)] pub struct Eval { pub script: Box<[u8]>, @@ -633,6 +642,7 @@ impl Default for RuntimeOptions { Self { smol: false, check: false, + check_options: CheckOptions::default(), debugger: Debugger::Unspecified, if_present: false, redis_preconnect: false, diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index bd5582050b59..8a3d025ad867 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -704,6 +704,54 @@ pub(crate) static TEST_TABLE: &clap::ConvertedTable = clap::comptime_table!(TEST static BASE_RUNTIME_TRANSPILER_TABLE: &clap::ConvertedTable = clap::comptime_table!(BASE_RUNTIME_TRANSPILER_PARAMS, cold); +// `-p` remains `--print`. In check mode, --project shares the existing override's value slot, +// so the last spelling wins without making --project a runtime option everywhere. +const fn with_check_project(params: &[ParamType]) -> [ParamType; N] { + let mut result = [params[0]; N]; + let mut i = 0; + while i < N { + result[i] = params[i]; + if let Some(name) = result[i].names.long { + if strings::const_bytes_eq(name, b"tsconfig-override") { + result[i].names.long_aliases = &[b"project"]; + } + } + i += 1; + } + result +} +const CHECK_PARAMS: &[ParamType] = concat_params!( + cli::check_command::THREAD_PARAMS, + cli::check_command::OUTPUT_PARAMS, + cli::check_command::PRETTY +); +pub(crate) const CHECK_AUTO_PARAMS: &[ParamType] = concat_params!( + &with_check_project::<{ AUTO_PARAMS.len() }>(AUTO_PARAMS), + CHECK_PARAMS +); +static CHECK_AUTO_TABLE: &clap::ConvertedTable = clap::comptime_table!(CHECK_AUTO_PARAMS, cold); +static CHECK_RUN_TABLE: &clap::ConvertedTable = clap::comptime_table!( + concat_params!( + &with_check_project::<{ RUN_PARAMS.len() }>(RUN_PARAMS), + CHECK_PARAMS + ), + cold +); +static CHECK_TEST_TABLE: &clap::ConvertedTable = clap::comptime_table!( + concat_params!( + &with_check_project::<{ TEST_PARAMS.len() }>(TEST_PARAMS), + CHECK_PARAMS + ), + cold +); +static CHECK_BUILD_TABLE: &clap::ConvertedTable = clap::comptime_table!( + concat_params!( + &with_check_project::<{ BUILD_PARAMS.len() }>(BUILD_PARAMS), + cli::check_command::THREAD_PARAMS + ), + cold +); + /// Per-tag pre-converted clap table (rodata, built at compile time via /// `comptime_table!`). This is what `parse` consumes so the startup path never /// hits `ConvertedTable::build`'s alloc/sort/lock. @@ -719,6 +767,71 @@ fn tag_table(cmd: CommandTag) -> &'static clap::ConvertedTable { } } +fn parsing_options(cmd: CommandTag) -> clap::ParseOptions<'static> { + clap::ParseOptions { + optional_booleans: &[b"pretty"], + stop_after_positional_at: match cmd { + CommandTag::RunCommand => 2, + CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => 1, + _ => 0, + }, + short_aliases: match cmd { + CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => NODE_SHORT_ALIASES, + _ => &[], + }, + ..Default::default() + } +} + +/// Extra value-taking options must not change where an ordinary invocation finds its script. +/// Trial parses honor the same entrypoint and `--` boundaries, without printing anything. +fn check_table(cmd: CommandTag) -> Option<&'static clap::ConvertedTable> { + if !bun_core::argv().iter().any(|arg| arg == b"--check") { + return None; + } + let table = match cmd { + CommandTag::AutoCommand => CHECK_AUTO_TABLE, + CommandTag::RunCommand => CHECK_RUN_TABLE, + CommandTag::TestCommand => CHECK_TEST_TABLE, + CommandTag::BuildCommand => CHECK_BUILD_TABLE, + _ => return None, + }; + for candidate in [table, tag_table(cmd)] { + let parsed = clap::parse_with_table::( + candidate, + clap::ParseOptions { + suppress_warnings: true, + ..parsing_options(cmd) + }, + ); + if parsed.is_ok_and(|args| args.flag(b"--check")) { + return Some(table); + } + } + None +} + +/// Command dispatch must not mistake the value of `--project` for `build`, `test`, or `run`. +pub(crate) fn checked_command_name() -> Option> { + let table = check_table(CommandTag::AutoCommand)?; + let parsed = clap::parse_with_table::( + table, + clap::ParseOptions { + suppress_warnings: true, + ..parsing_options(CommandTag::AutoCommand) + }, + ); + Some(parsed.ok().and_then(|args| { + // --filter owns a workspace script, even when its name is a built-in command. + // AutoCommand routes the parsed filter to the workspace script runner instead. + if !args.options(b"--filter").is_empty() { + None + } else { + args.positionals().first().copied() + } + })) +} + // ─── exported FFI globals (written by parse(), read from C++) ──────────────── // `AtomicBool` has the same size/alignment/bit-validity as `bool`, so the // `#[no_mangle]` symbol layout is unchanged for the C++ side that reads these @@ -827,23 +940,14 @@ pub(crate) fn disallow_code_generation_from_strings_as_compiled(embedded: &[&bun /// `if matches!(cmd, …)`. pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { let mut diag = clap::Diagnostic::default(); - let table = tag_table(cmd); + let check_table = check_table(cmd); + let table = check_table.unwrap_or_else(|| tag_table(cmd)); let args = match clap::parse_with_table::( table, clap::ParseOptions { diagnostic: Some(&mut diag), - stop_after_positional_at: match cmd { - CommandTag::RunCommand => 2, - CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => 1, - _ => 0, - }, - // Only the paths standing in for `node` get node's aliases. - short_aliases: match cmd { - CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => NODE_SHORT_ALIASES, - _ => &[], - }, - ..Default::default() + ..parsing_options(cmd) }, ) { Ok(a) => a, @@ -855,6 +959,16 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result) -> crate::Result) -> ! { +pub(crate) fn usage_error(args: core::fmt::Arguments<'_>) -> ! { Output::err_generic("{}", (args,)); bun_core::note!("run 'bun check --help' for more information"); Global::exit(1); @@ -40,27 +41,35 @@ const PROJECT: &[clap::Param] = &[clap::param!( "-p, --project/--tsconfig-override Path to a tsconfig.json or its directory" )]; /// For the help only. It is read like a compiler option. -const PRETTY: &[clap::Param] = &[clap::param!( +pub(crate) const PRETTY: &[clap::Param] = &[clap::param!( "--pretty ? Show source code around each error (default in a terminal)" )]; -const OTHERS: &[clap::Param] = &[ - clap::param!( - "-b, --build Check the projects in references too, like tsc -b" - ), +pub(crate) const THREAD_PARAMS: &[clap::Param] = &[clap::param!( + "--threads Number of threads (default: one per CPU core)" +)]; +pub(crate) const OUTPUT_PARAMS: &[clap::Param] = &[ clap::param!( "--no-pretty One line per error, like tsc --pretty false (default when piped)" ), clap::param!( "--all Show every error (above 50, identical errors are grouped)" ), - clap::param!("--threads Number of threads (default: one per CPU core)"), clap::param!("--timing Print load and check times"), - clap::param!("--cwd Set the working directory"), - clap::param!("-h, --help Print this help menu"), - // Nothing is ever emitted. `--noEmit` is a compiler option like any other, which the driver sets last. - clap::param!("--no-emit"), - clap::param!("..."), ]; +const OTHERS: &[clap::Param] = clap::concat_params!( + THREAD_PARAMS, + OUTPUT_PARAMS, + &[ + clap::param!( + "-b, --build Check the projects in references too, like tsc -b" + ), + clap::param!("--cwd Set the working directory"), + clap::param!("-h, --help Print this help menu"), + // Nothing is ever emitted. `--noEmit` is a compiler option like any other, which the driver sets last. + clap::param!("--no-emit"), + clap::param!("..."), + ] +); pub(crate) const PARAMS: &[clap::Param] = clap::concat_params!(PROJECT, PRETTY, OTHERS); /// A compiler option is not in the table: see `unknown_long_flags_are_positional`. static TABLE: &clap::ConvertedTable = @@ -121,15 +130,7 @@ fn parse(args: &[&ZStr]) -> Options { timing: parsed.flag(b"--timing"), ..Default::default() }; - if let Some(threads) = parsed.option(b"--threads") { - match bun_core::fmt::parse_decimal::(threads) { - Some(n) if n > 0 => options.threads = n, - _ => usage_error(format_args!( - "--threads takes a number above zero, not \"{}\"", - BStr::new(threads) - )), - } - } + options.threads = thread_count(&parsed); // What precedes `check` is for `bun`, like what `BUN_OPTIONS` has. A flag there that is not // one of these is skipped, as every other command skips it. let positionals = parsed.positionals(); @@ -163,6 +164,48 @@ fn parse(args: &[&ZStr]) -> Options { options } +fn thread_count(parsed: &clap::Args) -> usize { + match parsed.option(b"--threads") { + None => 0, + Some(threads) => match bun_core::fmt::parse_decimal::(threads) { + Some(n) if n > 0 => n, + _ => usage_error(format_args!( + "--threads takes a number above zero, not \"{}\"", + BStr::new(threads) + )), + }, + } +} + +pub(crate) fn runtime_options(parsed: &clap::Args, for_build: bool) -> CheckOptions { + let mut options = CheckOptions { + threads: thread_count(parsed), + ..Default::default() + }; + if !for_build { + options.pretty = parsed.option(b"--pretty").map(|value| match value { + b"" | b"true" => true, + b"false" => false, + _ => usage_error(format_args!( + "--pretty does not take \"{}\"", + BStr::new(value) + )), + }); + if parsed.flag(b"--no-pretty") { + options.pretty = Some(false); + } + options.all = parsed.flag(b"--all"); + options.timing = parsed.flag(b"--timing"); + } + options +} + +fn runtime_options_of_context() -> CheckOptions { + bun_options_types::context::try_get() + .map(|ctx| ctx.runtime_options.check_options) + .unwrap_or_default() +} + fn reject(rejected: &RejectedFlag) -> ! { let flag = BStr::new(&rejected.flag); match &rejected.error { @@ -437,12 +480,16 @@ impl CheckCommand { /// `bun --check` without an entry point: `bun check` without an argument. The arguments are /// those of `bun`, which has read them. pub(crate) fn exec_without_arguments() -> ! { + let options = runtime_options_of_context(); Self::exec_with(&Options { command_line: CommandLine { project: tsconfig_override().map(<[u8]>::to_vec), ..Default::default() }, - ..Default::default() + threads: options.threads, + pretty: options.pretty, + all: options.all, + timing: options.timing, }) } @@ -501,29 +548,33 @@ fn report_and_exit(report: &Report, options: &Options, cwd: &[u8]) -> ! { }, ); if options.timing { - use std::io::Write; - let _ = writeln!( - summary, - " {} files loaded in {:.1}ms, {} checked in {:.1}ms, {} KB of stack at the most", - report.files_loaded, - report.load_time.as_secs_f64() * 1000.0, - report.files_checked, - report.check_time.as_secs_f64() * 1000.0, - report.deepest_stack / 1024, - ); - if report.files_parsed_for_all > 0 { - let _ = writeln!( - summary, - " {} files parsed for all projects", - report.files_parsed_for_all, - ); - } + write_timing(&mut summary, report); } let _ = Output::error_writer().write_all(&summary); Output::flush(); Global::exit(u32::from(!report.is_ok())); } +fn write_timing(out: &mut Vec, report: &Report) { + use std::io::Write; + let _ = writeln!( + out, + " {} files loaded in {:.1}ms, {} checked in {:.1}ms, {} KB of stack at the most", + report.files_loaded, + report.load_time.as_secs_f64() * 1000.0, + report.files_checked, + report.check_time.as_secs_f64() * 1000.0, + report.deepest_stack / 1024, + ); + if report.files_parsed_for_all > 0 { + let _ = writeln!( + out, + " {} files parsed for all projects", + report.files_parsed_for_all, + ); + } +} + /// What is run. #[derive(Clone, Copy)] pub(crate) struct EntryPoint<'a> { @@ -630,17 +681,22 @@ fn already_read(cwd: &[u8], sources: &mut dyn Iterator) - /// `BundleOptions::type_check` for `bun build --check`. pub(crate) fn check_for_build_command(checked: TypeChecked, log: &mut bun_ast::Log) -> bool { - check_for_build(checked, log, true) + check_for_build(checked, log, true, runtime_options_of_context().threads) } /// `BundleOptions::type_check` for `Bun.build({ check: true })`. It prints nothing. pub(crate) fn check_for_bun_build(checked: TypeChecked, log: &mut bun_ast::Log) -> bool { - check_for_build(checked, log, false) + check_for_build(checked, log, false, 0) } /// The files of the bundle, `sources`, are not read again. The errors are added to `log`, which the /// build reports like its own. It runs on the thread of the bundler. -fn check_for_build(checked: TypeChecked, log: &mut bun_ast::Log, shows_progress: bool) -> bool { +fn check_for_build( + checked: TypeChecked, + log: &mut bun_ast::Log, + shows_progress: bool, + threads: usize, +) -> bool { let TypeChecked { cwd, tsconfig, @@ -682,7 +738,7 @@ fn check_for_build(checked: TypeChecked, log: &mut bun_ast::Log, shows_progress: }); let then = |report| report; let report = match shows_progress { - true => run(cwd, tsconfig, &paths, &[], 0, provided, then), + true => run(cwd, tsconfig, &paths, &[], threads, provided, then), false => run_quietly(cwd, tsconfig, &paths, &[], 0, None, provided, then), }; for reported in &report.diagnostics { @@ -851,21 +907,38 @@ fn tsconfig_override() -> Option<&'static [u8]> { /// Returns whether there are no errors. fn check_and_report(paths: &Paths, provided: Provided) -> bool { let cwd = working_directory(); + let options = runtime_options_of_context(); let then = |report| report; - let report = run(&cwd, tsconfig_override(), paths, &[], 0, provided, then); - if report.diagnostics.is_empty() && report.incomplete.is_empty() { + let report = run( + &cwd, + tsconfig_override(), + paths, + &[], + options.threads, + provided, + then, + ); + if report.diagnostics.is_empty() && report.incomplete.is_empty() && !options.timing { return report.is_ok(); } let shown_from = bun_sema_driver::host::from_native(&cwd); let style = Style { is_case_sensitive: report.is_case_sensitive, - ..style_for(&shown_from, None, Destination::stderr(), false) + ..style_for( + &shown_from, + options.pretty, + Destination::stderr(), + options.all, + ) }; let mut out = Vec::new(); format::write_diagnostics(&mut out, &report, &style); if !report.is_ok() { format::write_summary(&mut out, &report, &style); } + if options.timing { + write_timing(&mut out, &report); + } let _ = Output::error_writer().write_all(&out); Output::flush(); report.is_ok() diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 01c702e7044a..8fe28ee4e7f5 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -953,16 +953,23 @@ pub(crate) 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') - { - // `--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. - match iter.next() { - Some(n) => first_arg_name = n, - None => return Tag::AutoCommand, + if let Some(checked) = arguments::checked_command_name() { + let Some(name) = checked else { + return Tag::AutoCommand; + }; + first_arg_name = name; + } else { + while !first_arg_name.is_empty() + && first_arg_name[0] == b'-' + && !(first_arg_name.len() > 1 && first_arg_name[1] == b'e') + { + // `--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. + match iter.next() { + Some(n) => first_arg_name = n, + None => return Tag::AutoCommand, + } } } diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index a6a97db54ea7..21a5b86504ef 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -304,12 +304,6 @@ mod _impl { continue; } - if !seen_run && arg == b"run" { - seen_run = true; - prev = Some(arg); - continue; - } - // A set of execArgv args consume an extra argument, so we do not want to // confuse these with script names. // Build the set lazily at runtime from the `AUTO_PARAMS` table: @@ -344,13 +338,32 @@ mod _impl { }); if let Some(p) = prev { - if MAP.contains(p) { + let check_value = bun_options_types::context::try_get() + .is_some_and(|ctx| ctx.runtime_options.check) + && p.strip_prefix(b"--").is_some_and(|name| { + crate::cli::arguments::CHECK_AUTO_PARAMS + .iter() + .any(|param| { + (param.takes_value == bun_clap::Values::One + || (param.takes_value == bun_clap::Values::OneOptional + && matches!(arg, b"true" | b"false"))) + && (param.names.long == Some(name) + || param.names.long_aliases.contains(&name)) + }) + }); + if MAP.contains(p) || check_value { args.push(BunString::clone_utf8(arg)); prev = Some(arg); continue; } } + if !seen_run && arg == b"run" { + seen_run = true; + prev = Some(arg); + continue; + } + // we hit the script name break; } diff --git a/test/cli/check/check.test.ts b/test/cli/check/check.test.ts index 42d33a60a7d2..8dc432696ab6 100644 --- a/test/cli/check/check.test.ts +++ b/test/cli/check/check.test.ts @@ -15723,6 +15723,233 @@ describe.concurrent("@types/bun", () => { }); describe.concurrent("--check", () => { + describe("checker controls", () => { + test("standalone aliases consume and forward controls while a check script keeps its name", async () => { + const selected = JSON.stringify({ ...JSON.parse(tsconfig), files: ["selected.ts"] }); + using dir = project({ + "package.json": JSON.stringify({ scripts: { check: "echo the script ran" } }), + "unselected.ts": `export const unselected: string = 1;\n`, + "selected.ts": `export const selected: number = "1";\n`, + "selected.json": selected, + "build/tsconfig.json": JSON.stringify({ ...JSON.parse(tsconfig), files: ["../selected.ts"] }), + "--check/tsconfig.json": JSON.stringify({ ...JSON.parse(tsconfig), files: ["../selected.ts"] }), + }); + const spellings = [ + ["--check", "--threads", "1", "--project", "selected.json", "--no-pretty", "--all"], + ["--threads=2", "--project=selected.json", "--check", "--no-pretty", "--all"], + ["run", "--check", "--threads=1", "--project", "selected.json", "--no-pretty", "--all"], + ["--check", "run", "--threads", "1", "--project=selected.json", "--no-pretty", "--all"], + // A project named like a subcommand is still a value. + ["--check", "--project", "build", "--threads", "1"], + ["--project", "build", "--check", "--threads=1"], + // Both spellings share one option, so the last value wins. + ["--check", "--project", "missing", "--tsconfig-override", "selected.json"], + ["--check", "--tsconfig-override", "missing", "--project", "selected.json"], + ["--check", "--project", "--check"], + ]; + const results = await Promise.all(spellings.map(args => run(String(dir), args))); + expect( + results.map(it => [ + it.stdout, + /^Found 1 error in 1 file, checked [\d,]+ files? \[time\]$/.test(it.stderr), + it.exitCode, + ]), + ).toEqual( + results.map(() => [ + `selected.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`, + true, + 1, + ]), + ); + const script = await run(String(dir), ["check", "--threads", "0"]); + expect([script.stdout, script.exitCode]).toEqual(["the script ran --threads 0", 0]); + const missing = await run(String(dir), ["--project", "--check"]); + expect([missing.stdout, missing.stderr.split("\n")[0], missing.exitCode]).toEqual([ + "", + "error: an option is missing its value before --check", + 1, + ]); + }); + + test("pretty controls preserve boolean values, paths, and no-pretty precedence", async () => { + using dir = project({ "app.ts": `export const n: number = "1";\n` }); + const forms = [ + ["--pretty", "false"], + ["--pretty=false"], + ["--pretty", "--no-pretty"], + ["--no-pretty", "--pretty", "true"], + ["--pretty=true"], + ["--pretty"], + ]; + const results = await Promise.all(forms.map(args => run(String(dir), ["--check", ...args, "app.ts"]))); + expect( + results.map(it => [ + it.stderr.includes("app.ts(1,14): error TS2322"), + it.stderr.includes("error: TS2322"), + it.exitCode, + ]), + ).toEqual([ + [true, false, 1], + [true, false, 1], + [true, false, 1], + [true, false, 1], + [false, true, 1], + [false, true, 1], + ]); + const invalid = await Promise.all( + [["--pretty=maybe"], ["--no-pretty", "--pretty=maybe"], ["--pretty=maybe", "--no-pretty"]].map(args => + run(String(dir), ["--check", ...args]), + ), + ); + expect(invalid.map(it => [it.stdout, it.stderr.split("\n")[0], it.exitCode])).toEqual( + invalid.map(() => ["", `error: --pretty does not take "maybe"`, 1]), + ); + }); + + test("invalid thread controls fail before checking or running", async () => { + using dir = project({ "app.ts": `console.log("the app ran");\n` }); + const commands = [[], ["run"], ["test"], ["build"]]; + const results = await Promise.all( + commands.flatMap(command => [ + run(String(dir), [...command, "--check", "--threads", "0"]), + run(String(dir), [...command, "--check", "--threads=many"]), + run(String(dir), [...command, "--check", "--threads"]), + ]), + ); + expect( + results.map((it, i) => [i % 3 === 2 ? it.stdout.includes("Usage:") : it.stdout === "", it.exitCode]), + ).toEqual(results.map(() => [true, 1])); + expect(results.map(it => it.stderr.split("\n")[0])).toEqual( + commands.flatMap(() => [ + `error: --threads takes a number above zero, not "0"`, + `error: --threads takes a number above zero, not "many"`, + `error: The argument '--threads' requires a value but none was supplied.`, + ]), + ); + }); + + test("runtime and test controls reach checks without becoming entrypoints", async () => { + using dir = project({ + "app.ts": `export const n: number = "1";\nconsole.log("the app ran");\n`, + "app.test.ts": `import { n } from "./app";\nvoid n;\n`, + }); + const commands = [["--check"], ["run", "--check"], ["test", "--check"]]; + const results = await Promise.all( + commands.map((command, i) => + run(String(dir), [ + ...command, + "--threads", + "1", + "--no-pretty", + "--timing", + i === 2 ? "app.test.ts" : "app.ts", + ]), + ), + ); + expect( + results.map((it, i) => [ + i === 2 ? /^bun test v[^\n]+$/.test(it.stdout) : it.stdout === "", + it.stderr.includes(`app.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`), + /\d+ files loaded in [\d.]+ms, \d+ checked in [\d.]+ms/.test(it.stderr), + it.exitCode, + ]), + ).toEqual(commands.map(() => [true, true, true, 1])); + }); + + test("successful runtime controls keep program output and execArgv", async () => { + using dir = project({ + "app.ts": `declare var process: { argv: string[]; execArgv: string[] };\nconsole.log(JSON.stringify({ args: process.argv.slice(2), exec: process.execArgv }));\n`, + "run/tsconfig.json": JSON.stringify({ ...JSON.parse(tsconfig), files: ["../app.ts", "../console.d.ts"] }), + }); + const controls = ["--check", "--threads", "1", "--project", "tsconfig.json", "--timing"]; + const namedRun = ["--check", "--project", "run", "--threads", "1", "--pretty", "false", "--timing"]; + const cases = [controls, ["run", ...controls], namedRun]; + const results = await Promise.all(cases.map(args => run(String(dir), [...args, "app.ts", "--threads", "9"]))); + expect(results.map(it => [it.stdout, /files loaded in/.test(it.stderr), it.exitCode])).toEqual( + [controls, controls, namedRun].map(exec => [ + JSON.stringify({ + args: ["--threads", "9"], + exec, + }), + true, + 0, + ]), + ); + }); + + test("ordinary runtime and script arguments retain their ownership", async () => { + const app = `console.log(JSON.stringify({ args: process.argv.slice(2), exec: process.execArgv }));\n`; + const bun = `"${bunExe().replaceAll("\\", "/")}"`; + using dir = project({ + "app.ts": app, + "package.json": JSON.stringify({ scripts: { argv: `${bun} app.ts` } }), + "preload.js": `globalThis.check = 2;\n`, + }); + const cases: [string[], string[], string[]][] = [ + [["app.ts", "--threads", "2", "--project", "x"], ["--threads", "2", "--project", "x"], []], + [["run", "app.ts", "--threads", "2"], ["--threads", "2"], []], + [["run", "argv", "--threads", "2", "--project", "x"], ["--threads", "2", "--project", "x"], []], + [["app.ts", "--check", "--threads", "2"], ["--check", "--threads", "2"], []], + [["--", "app.ts", "--check", "--threads", "2"], ["--check", "--threads", "2"], ["--"]], + [["--threads", "app.ts", "--project", "x"], ["--project", "x"], ["--threads"]], + [["--project", "app.ts", "--threads", "2"], ["--threads", "2"], ["--project"]], + [["--pretty=false", "app.ts", "--check"], ["--check"], ["--pretty=false"]], + ]; + const results = await Promise.all(cases.map(([args]) => run(String(dir), args))); + expect(results.map(it => [JSON.parse(it.stdout), it.exitCode])).toEqual( + cases.map(([, args, exec]) => [{ args, exec }, 0]), + ); + const [print, value] = await Promise.all([ + run(String(dir), ["-p", "1 + 1"]), + // A token that is the value of --print is not the --check flag. + run(String(dir), ["--preload", "./preload.js", "-p", "--check"]), + ]); + expect([print.stdout, print.stderr, print.exitCode, value.stdout, value.stderr, value.exitCode]).toEqual([ + "2", + "", + 0, + "1", + "", + 0, + ]); + }); + + test("bunx keeps checker-shaped package arguments", async () => { + using dir = project({ + "node_modules/@scope/checker-argv/package.json": JSON.stringify({ + name: "@scope/checker-argv", + version: "1.0.0", + bin: { "checker-argv": "./index.js" }, + }), + "node_modules/@scope/checker-argv/index.js": `#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n`, + "node_modules/.bin/checker-argv": `#!/usr/bin/env node\nrequire("../@scope/checker-argv/index.js");\n`, + "node_modules/.bin/checker-argv.cmd": `@echo off\r\nnode "%~dp0..\\@scope\\checker-argv\\index.js" %*\r\n`, + }); + chmodSync(join(String(dir), "node_modules/.bin/checker-argv"), 0o755); + const args = ["--check", "--threads", "2", "--project", "x"]; + const result = await run(String(dir), ["x", "--no-install", "@scope/checker-argv", ...args]); + expect([result.stdout, result.exitCode]).toEqual([JSON.stringify(args), 0]); + }); + + test.skipIf(isWindows)("the Node alias keeps script arguments", async () => { + using dir = project({ + "app.js": `console.log(JSON.stringify(process.argv.slice(2)));\n`, + }); + const node = join(String(dir), "node"); + symlinkSync(bunExe(), node); + const args = ["--check", "--threads", "2", "--project", "x"]; + await using proc = spawn({ + cmd: [node, "app.js", ...args], + cwd: String(dir), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect([stdout.trim(), stderr, exitCode]).toEqual([JSON.stringify(args), "", 0]); + }); + }); + test("a `check` script keeps `bun check`, and `bun --check` is the type checker everywhere", async () => { const files = { "a.ts": `export const a: number = "1";\n`, "src/empty.txt": "" }; using scripted = project({ @@ -16721,24 +16948,38 @@ describe.concurrent("--check", () => { workspaces: ["packages/*"], scripts: { one: "echo one ran", two: "echo two ran" }, }), - "packages/a/package.json": JSON.stringify({ name: "a", scripts: { build: "echo a ran" } }), + "packages/a/package.json": JSON.stringify({ + name: "a", + scripts: { build: "echo a ran", test: "echo a ran", run: "echo a ran" }, + }), "a.ts": `export const a: number = "1";\n`, + "a.test.ts": `import { a } from "./a";\nvoid a;\n`, }); + const filtered = ["build", "test", "run"].flatMap(script => [ + ["--check", "--filter", "*", script], + ["--filter=*", "--check", script], + ]); const results = await Promise.all([ - run(String(dir), ["--check", "--filter", "*", "build"]), + ...filtered.map(args => run(String(dir), args)), run(String(dir), ["run", "--check", "--parallel", "one", "two"]), run(String(dir), ["run", "--check", "--sequential", "one", "two"]), ]); + const direct = await Promise.all([ + run(String(dir), ["test", "--check", "a.test.ts"]), + run(String(dir), ["test", "--check", "--timeout", "1000", "a.test.ts"]), + ]); + writeFileSync(join(String(dir), "a.ts"), `export const a: number = 1;\n`); + const checked = await Promise.all(filtered.map(args => run(String(dir), args))); + expect(checked.map(it => [it.stdout.includes("a ran"), it.exitCode])).toEqual(checked.map(() => [true, 0])); const seen = results.map(it => [ it.exitCode, it.stderr.includes("TS2322"), (it.stdout + it.stderr).includes("ran"), ]); - expect(seen).toEqual([ - [1, true, false], - [1, true, false], - [1, true, false], - ]); + expect(seen).toEqual(results.map(() => [1, true, false])); + expect(direct.map(it => [/^bun test v[^\n]+$/.test(it.stdout), it.stderr.includes("TS2322"), it.exitCode])).toEqual( + direct.map(() => [true, true, 1]), + ); }); test("bun test --watch --check watches the tsconfig.json of every project", async () => { @@ -17340,7 +17581,7 @@ describe.concurrent("--check", () => { Bun.zstdDecompressSync(readFileSync(join(import.meta.dir, "typescript-go", "bundle.zst"))), ); const entries: [path: string, start: number, length: number][] = []; - for (let at = 0; at < bundle.length; ) { + for (let at = 0; at < bundle.length;) { const end = bundle.indexOf(10, at); const [, path, length] = /^=== (.*) (\d+)$/.exec(bundle.toString("latin1", at, end))!; entries.push([path, end + 1, +length]);