diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 44f33f04f506..f47c5b0d33ea 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -156,6 +156,7 @@ new!(pub NODE_DISABLE_COMPILE_CACHE: string, "NODE_DISABLE_COMPILE_CACHE", {}); // child's CLI entrypoint checks this before anything else and hands off to // C++ Bun__WebView__hostMain. Never returns — no JSC, no VM. new!(pub BUN_INTERNAL_WEBVIEW_HOST: string, "BUN_INTERNAL_WEBVIEW_HOST", {}); +new!(pub NODE_OPTIONS: string, "NODE_OPTIONS", {}); new!(pub NODE_PENDING_DEPRECATION: string, "NODE_PENDING_DEPRECATION", {}); new!(pub NODE_PRESERVE_SYMLINKS_MAIN: boolean, "NODE_PRESERVE_SYMLINKS_MAIN", { default: false }); new!(pub NODE_USE_SYSTEM_CA: boolean, "NODE_USE_SYSTEM_CA", { default: false }); diff --git a/src/clap/comptime.rs b/src/clap/comptime.rs index 69df95980af3..45ed2a87fe5b 100644 --- a/src/clap/comptime.rs +++ b/src/clap/comptime.rs @@ -65,7 +65,13 @@ pub const fn count_single(params: &[Param]) -> usize { let mut i = 0; while i < params.len() { if is_named(¶ms[i]) - && matches!(params[i].takes_value, Values::One | Values::OneOptional) + && matches!( + params[i].takes_value, + Values::One + | Values::OneOptional + | Values::OneNoDashValue + | Values::OneOptionalNoDashValue + ) { n += 1; } @@ -114,7 +120,10 @@ pub const fn convert_params_array(params: &[Param]) -> [ index = flags; flags += 1; } - Values::One | Values::OneOptional => { + Values::One + | Values::OneOptional + | Values::OneNoDashValue + | Values::OneOptionalNoDashValue => { index = single; single += 1; } @@ -361,7 +370,10 @@ impl ConvertedTable { if p.names.long.is_some() || p.names.short.is_some() { let ctr = match p.takes_value { Values::None => &mut flags, - Values::One | Values::OneOptional => &mut single, + Values::One + | Values::OneOptional + | Values::OneNoDashValue + | Values::OneOptionalNoDashValue => &mut single, Values::Many => &mut multi, }; index = *ctr; @@ -544,6 +556,7 @@ impl ComptimeClap { state: streaming::State::Normal, positional: None, short_aliases: opt.short_aliases, + reject_bad_negations: opt.reject_bad_negations, }; while let Some(arg) = stream.next()? { @@ -567,7 +580,13 @@ impl ComptimeClap { } break; } - } else if param.takes_value == Values::One || param.takes_value == Values::OneOptional { + } else if matches!( + param.takes_value, + Values::One + | Values::OneOptional + | Values::OneNoDashValue + | Values::OneOptionalNoDashValue + ) { debug_assert!(single_options.len() != 0); if single_options.len() != 0 { single_options[param.id] = Some(arg.value.unwrap_or(b"")); @@ -602,7 +621,10 @@ impl ComptimeClap { pub fn flag(&self, name: &[u8]) -> bool { let param = self.table.find(name); debug_assert!( - param.takes_value == Values::None || param.takes_value == Values::OneOptional, + matches!( + param.takes_value, + Values::None | Values::OneOptional | Values::OneOptionalNoDashValue + ), "{} is an option and not a flag.", bstr::BStr::new(name), ); @@ -634,7 +656,13 @@ impl ComptimeClap { bstr::BStr::new(name), ); debug_assert!( - !(param.takes_value == Values::One || param.takes_value == Values::OneOptional), + !matches!( + param.takes_value, + Values::One + | Values::OneOptional + | Values::OneNoDashValue + | Values::OneOptionalNoDashValue + ), "{} takes one option, not multiple.", bstr::BStr::new(name), ); diff --git a/src/clap/error.rs b/src/clap/error.rs index 42569187ccae..24a1a5570118 100644 --- a/src/clap/error.rs +++ b/src/clap/error.rs @@ -6,6 +6,10 @@ pub enum Error { MissingValue, #[error("InvalidArgument")] InvalidArgument, + /// `--no-` where `` is known but carries a value, so there is + /// nothing to negate. + #[error("InvalidNegation")] + InvalidNegation, #[error("WriteFailed")] WriteFailed, } @@ -17,6 +21,7 @@ impl Error { Self::DoesntTakeValue => "DoesntTakeValue", Self::MissingValue => "MissingValue", Self::InvalidArgument => "InvalidArgument", + Self::InvalidNegation => "InvalidNegation", Self::WriteFailed => "WriteFailed", } } @@ -40,6 +45,7 @@ impl From for Error { crate::streaming::ArgError::DoesntTakeValue => Self::DoesntTakeValue, crate::streaming::ArgError::MissingValue => Self::MissingValue, crate::streaming::ArgError::InvalidArgument => Self::InvalidArgument, + crate::streaming::ArgError::InvalidNegation => Self::InvalidNegation, } } } diff --git a/src/clap/lib.rs b/src/clap/lib.rs index 7f0beecacbfc..0bd599195936 100644 --- a/src/clap/lib.rs +++ b/src/clap/lib.rs @@ -262,6 +262,15 @@ pub enum Values { One, Many, OneOptional, + /// Like [`Values::One`], but a separate following argument that starts + /// with '-' is never consumed as the value (Node's behavior for `-e`): + /// the parse fails with a missing value instead. + OneNoDashValue, + /// Like [`Values::OneOptional`], but a separate following argument is + /// consumed as the value when it does not start with '-' (Node's behavior + /// for `-p`), and in a short cluster the attached remainder stays part of + /// the cluster (`-pe` is `-p` followed by `-e`). + OneOptionalNoDashValue, } /// Represents a parameter for the command line. @@ -417,6 +426,9 @@ pub struct ParseOptions<'a> { /// flag, never to an option's value or a `--` target. Node keeps its own /// aliases on exactly that branch (node_options-inl.h). pub short_aliases: &'static [(&'static [u8], &'static [u8])], + /// Reject `--no-` shapes Node rejects instead of ignoring them as an + /// unrecognized flag. Only the commands that stand in for `node` set this. + pub reject_bad_negations: bool, } // Help/usage/error rendering — none of this is on the cold-start hot chain @@ -511,6 +523,7 @@ pub fn parse( diagnostic: opt.diagnostic, stop_after_positional_at: opt.stop_after_positional_at, short_aliases: opt.short_aliases, + reject_bad_negations: opt.reject_bad_negations, }, )?; Ok(Args { clap, exe_arg }) @@ -532,6 +545,7 @@ pub fn parse_with_table( diagnostic: opt.diagnostic, stop_after_positional_at: opt.stop_after_positional_at, short_aliases: opt.short_aliases, + reject_bad_negations: opt.reject_bad_negations, }, )?; Ok(Args { clap, exe_arg }) @@ -657,14 +671,14 @@ where { match param.takes_value { Values::None => {} - Values::One => { + Values::One | Values::OneNoDashValue => { write!( w, " <{}>", bstr::BStr::new(value_text(context, param).map_err(Into::into)?) )?; } - Values::OneOptional => { + Values::OneOptional | Values::OneOptionalNoDashValue => { write!( w, " <{}>?", diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index cc76bf4a8bbc..46280ff56b58 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -34,6 +34,16 @@ pub(crate) enum ArgError { MissingValue, #[error("InvalidArgument")] InvalidArgument, + #[error("InvalidNegation")] + InvalidNegation, +} + +/// Whether `takes_value` opts the param into Node's value-binding rules. +fn is_node_style(takes_value: clap::Values) -> bool { + matches!( + takes_value, + clap::Values::OneNoDashValue | clap::Values::OneOptionalNoDashValue + ) } #[derive(Copy, Clone, PartialEq, Eq)] @@ -62,6 +72,7 @@ pub struct StreamingClap<'p, 'a, Id, ArgIterator> { pub positional: Option<&'p clap::Param>, pub diagnostic: Option<&'p mut clap::Diagnostic>, pub short_aliases: &'static [(&'static [u8], &'static [u8])], + pub reject_bad_negations: bool, } // ArgIterator is the @@ -129,6 +140,13 @@ where })); } + if is_node_style(param.takes_value) { + return match self.node_style_value(param.takes_value, maybe_value) { + Ok(value) => Ok(Some(Arg { param, value })), + Err(e) => Err(self.err(arg, None, Some(name), e)), + }; + } + let value = 'blk: { if let Some(v) = maybe_value { break 'blk v; @@ -153,6 +171,23 @@ where })); } + // `--no-` where `` is a known option that carries a + // value cannot mean anything, and Node rejects it + // (src/node_options-inl.h). An *unknown* `--no-` is left + // alone: Bun ignores unrecognized flags on purpose so the many + // Node options it does not implement (--no-global-search-paths, + // --no-extra-info-on-fatal-exception, …) stay harmless. + if self.reject_bad_negations { + if let Some(negated) = name.strip_prefix(b"no-") { + let negates_a_value = params.iter().any(|p| { + p.names.matches_long(negated) && p.takes_value != clap::Values::None + }); + if negates_a_value { + return Err(self.err(arg, None, Some(name), ArgError::InvalidNegation)); + } + } + } + // unrecognized command // if flag else arg if arg_info.kind == ArgKind::Long || arg_info.kind == ArgKind::Short { @@ -250,6 +285,37 @@ where return Ok(Some(Arg { param, value: None })); } + if is_node_style(param.takes_value) { + if next_is_eql { + return match self + .node_style_value(param.takes_value, Some(&arg[next_index + 1..])) + { + Ok(value) => Ok(Some(Arg { param, value })), + Err(e) => Err(self.err(arg, Some(short), None, e)), + }; + } + if arg.len() > next_index { + // Text attached without '=' stays part of the cluster for + // the optional form, so "-pe 42" is -p followed by -e 42 + // rather than -p with the value "e". + if param.takes_value == clap::Values::OneOptionalNoDashValue { + self.state = State::Chaining(Chaining { + arg, + index: next_index, + }); + return Ok(Some(Arg { param, value: None })); + } + return Ok(Some(Arg { + param, + value: Some(&arg[next_index..]), + })); + } + return match self.node_style_value(param.takes_value, None) { + Ok(value) => Ok(Some(Arg { param, value })), + Err(e) => Err(self.err(arg, Some(short), None, e)), + }; + } + if arg.len() <= next_index { let value = match self.iter.next() { Some(v) => v, @@ -342,6 +408,58 @@ where })) } + /// Bind the value of a param declared with Node's value semantics + /// ([`clap::Values::OneNoDashValue`] / [`clap::Values::OneOptionalNoDashValue`]). + /// + /// Mirrors nodejs/node v26.3.0 `src/node_options-inl.h`: + /// + /// * An `=`-attached value binds verbatim. For the required form an empty + /// one is an error rather than an empty value (`node --eval=` exits 9); + /// the optional form is a boolean upstream, so `--print=` is no value. + /// * A separate following argument that starts with '-' is never the + /// value; it is a missing value instead (`node -e -p` exits 9). This is + /// why an expression like `-42` must be passed as `--eval=-42`. + /// * A separate following argument may escape that rule with a leading + /// backslash, which is then stripped: `node -p "\-42"` prints -42. The + /// `=` form does not unescape, so `--eval=\-42` keeps the backslash. + /// * For the optional form (upstream's `--print ` alias) an *empty* + /// following argument is additionally not consumed. It stays a + /// positional, which is why `node -p "" -e 42` prints `undefined`: the + /// positional ends option parsing before `-e` is seen. + fn node_style_value( + &mut self, + takes_value: clap::Values, + attached: Option<&'a [u8]>, + ) -> Result, ArgError> { + if let Some(value) = attached { + if !value.is_empty() { + return Ok(Some(value)); + } + if takes_value == clap::Values::OneOptionalNoDashValue { + return Ok(None); + } + return Err(ArgError::MissingValue); + } + + let usable = self.iter.remain().first().is_some_and(|next| { + !next.starts_with(b"-") + && !(takes_value == clap::Values::OneOptionalNoDashValue && next.is_empty()) + }); + if !usable { + if takes_value == clap::Values::OneNoDashValue { + return Err(ArgError::MissingValue); + } + return Ok(None); + } + + // `usable` only holds when the iterator has a next argument. + let value = self.iter.next().unwrap_or_default(); + Ok(Some(match value.strip_prefix(b"\\") { + Some(rest) if rest.starts_with(b"-") => rest, + _ => value, + })) + } + fn err(&mut self, arg: &[u8], short: Option, long: Option<&[u8]>, e: ArgError) -> ArgError { if let Some(d) = self.diagnostic.as_deref_mut() { // `Diagnostic` owns @@ -369,6 +487,7 @@ mod tests { }; let mut c = StreamingClap:: { short_aliases: &[], + reject_bad_negations: false, params, iter: &mut iter, state: State::Normal, @@ -402,6 +521,7 @@ mod tests { }; let mut c = StreamingClap:: { short_aliases: &[], + reject_bad_negations: false, params, iter: &mut iter, state: State::Normal, diff --git a/src/clap_macros/lib.rs b/src/clap_macros/lib.rs index 0969a83031f5..2b5c82d504fe 100644 --- a/src/clap_macros/lib.rs +++ b/src/clap_macros/lib.rs @@ -28,6 +28,8 @@ enum Values { One, Many, OneOptional, + OneNoDashValue, + OneOptionalNoDashValue, } #[derive(Default)] @@ -226,11 +228,27 @@ fn parse_param_rest(line: &[u8]) -> Param { }; let after = &line[len + 1..]; let takes_many = after.starts_with(b"..."); + // "?" = optional value; "!" = node-style value (a separate argument + // starting with '-' is never consumed as the value); "?!" = both. let takes_one_optional = after.starts_with(b"?"); - let help_start = len + 1 + 3 * (takes_many as usize) + (takes_one_optional as usize); + let no_dash_value = after.starts_with(b"!") || after.starts_with(b"?!"); + let suffix_len = if takes_many { + 3 + } else if after.starts_with(b"?!") { + 2 + } else if takes_one_optional || no_dash_value { + 1 + } else { + 0 + }; + let help_start = len + 1 + suffix_len; return Param { takes_value: if takes_many { Values::Many + } else if takes_one_optional && no_dash_value { + Values::OneOptionalNoDashValue + } else if no_dash_value { + Values::OneNoDashValue } else if takes_one_optional { Values::OneOptional } else { @@ -383,6 +401,8 @@ fn emit_param(krate: &Path, p: &Param) -> TokenStream2 { Values::One => quote! { #krate::Values::One }, Values::Many => quote! { #krate::Values::Many }, Values::OneOptional => quote! { #krate::Values::OneOptional }, + Values::OneNoDashValue => quote! { #krate::Values::OneNoDashValue }, + Values::OneOptionalNoDashValue => quote! { #krate::Values::OneOptionalNoDashValue }, }; quote! { diff --git a/src/jsc/bindings/CheckSyntax.cpp b/src/jsc/bindings/CheckSyntax.cpp new file mode 100644 index 000000000000..161b2592a268 --- /dev/null +++ b/src/jsc/bindings/CheckSyntax.cpp @@ -0,0 +1,102 @@ +// Implements the engine half of `bun --check` / `-c` (Node.js compatibility): +// syntax-check a source without executing it, reporting errors with JSC's own +// SyntaxError messages the way `node --check` reports V8's. +#include "root.h" + +#include "ZigGlobalObject.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Bun { + +static JSC::SourceCode makeCheckSource(const WTF::String& code, const WTF::String& filename, JSC::SourceProviderSourceType sourceType) +{ + TextPosition position; + return JSC::SourceCode( + JSC::StringSourceProvider::create(code, JSC::SourceOrigin(WTF::URL::fileURLWithFileSystemPath(filename)), filename, JSC::SourceTaintedOrigin::Untainted, position, sourceType), + position.m_line.oneBasedInt(), position.m_column.oneBasedInt()); +} + +static void printSyntaxError(const WTF::String& filename, const JSC::ParserError& error) +{ + // The format follows `node --check`: the failing file (or "[stdin]") on the + // first line, then a line starting with "SyntaxError: " carrying the + // engine's parser message. + auto filenameUtf8 = filename.utf8(); + auto messageUtf8 = error.message().utf8(); + fprintf(stderr, "%s:%d\n\nSyntaxError: %s\n", filenameUtf8.data(), error.line(), messageUtf8.data()); +} + +// Called from the CLI (`Run::start`) after the VM booted and `--require` +// preloads ran. `moduleType` is 0 = detect, 1 = CommonJS, 2 = ES module +// (from `--input-type` or the file extension). Returns 0 when the source +// parses, 1 when it does not (after printing the error to stderr). +extern "C" int32_t Bun__checkSyntaxForCLI(Zig::GlobalObject* globalObject, const unsigned char* sourcePtr, size_t sourceLen, const unsigned char* namePtr, size_t nameLen, int32_t moduleType) +{ + auto& vm = JSC::getVM(globalObject); + + WTF::String source = WTF::String::fromUTF8ReplacingInvalidSequences(std::span { sourcePtr, sourceLen }); + WTF::String filename = WTF::String::fromUTF8ReplacingInvalidSequences(std::span { namePtr, nameLen }); + + // ES module check (used directly for "module", or as the fallback in + // detect mode). + auto checkAsModule = [&](JSC::ParserError& error) -> bool { + JSC::SourceCode moduleSource = makeCheckSource(source, filename, JSC::SourceProviderSourceType::Module); + return JSC::checkModuleSyntax(globalObject, moduleSource, error); + }; + + if (moduleType == 2) { + JSC::ParserError moduleError; + if (checkAsModule(moduleError)) + return 0; + printSyntaxError(filename, moduleError); + return 1; + } + + // CommonJS sources are compiled inside the module wrapper, so a top-level + // `return` is legal exactly when require() would accept it. Respect a + // wrapper overridden via `require("module").wrapper`. + WTF::String wrapperStart; + WTF::String wrapperEnd; + if (globalObject->hasOverriddenModuleWrapper) { + wrapperStart = globalObject->m_moduleWrapperStart; + wrapperEnd = globalObject->m_moduleWrapperEnd; + } else { + wrapperStart = "(function(exports,require,module,__filename,__dirname){"_s; + wrapperEnd = "\n})"_s; + } + + // The wrapper puts the source mid-program, where a shebang is a syntax + // error; the CJS loader strips it before compiling, so do the same here. + WTF::String body = source; + if (body.startsWith("#!"_s)) { + size_t newline = body.find('\n'); + body = newline == WTF::notFound ? WTF::String(""_s) : body.substring(newline); + } + + JSC::ParserError commonJSError; + JSC::SourceCode wrapped = makeCheckSource(makeString(wrapperStart, body, wrapperEnd), filename, JSC::SourceProviderSourceType::Program); + if (JSC::checkSyntax(vm, wrapped, commonJSError)) + return 0; + + // Detect mode: the package "type" is not re-derived here, so accept the + // source if it parses as an ES module instead. + if (moduleType == 0) { + JSC::ParserError moduleError; + if (checkAsModule(moduleError)) + return 0; + } + + printSyntaxError(filename, commonJSError); + return 1; +} + +} // namespace Bun diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 1f44e5d3f15e..a538713b07c9 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -553,12 +553,33 @@ pub struct RuntimeOptions { pub cron_period: Box<[u8]>, pub cpu_prof: CpuProf, pub heap_prof: HeapProf, + /// `--check` / `-c`: parse the entry point (or stdin) without executing it, + /// like Node.js. + pub check_syntax: bool, } #[derive(Default)] pub struct Eval { pub script: Box<[u8]>, pub eval_and_print: bool, + /// True when `-e`/`--eval`/`-p`/`--print` was passed at all. Node tracks + /// the same fact as `[has_eval_string]`, and it is what distinguishes an + /// empty script from an absent one: `bun -e ""` runs an empty program and + /// bare `bun -p` prints undefined, rather than falling through to help. + pub provided: bool, + /// `--input-type`: module type for string input (stdin / `--eval`), + /// "module" or "commonjs". Empty when not passed. + pub input_type: Box<[u8]>, +} + +impl Eval { + /// Whether an eval entry point should run. Either the user asked for one + /// with `-e`/`-p` (`provided`, which holds even for an empty script), or an + /// internal path staged a script to run instead of a file: piped stdin, or + /// the no-op entry `--check` uses to reach the syntax checker. + pub fn has_entry(&self) -> bool { + self.provided || !self.script.is_empty() + } } pub struct CpuProf { @@ -615,6 +636,7 @@ impl Default for RuntimeOptions { cron_period: Box::default(), cpu_prof: CpuProf::default(), heap_prof: HeapProf::default(), + check_syntax: false, } } } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index dfabd75d9b6e..9a7fc377ba97 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -89,21 +89,42 @@ macro_rules! maybe_verbose_error_trace { }; } +const BASE_HEAD_PARAMS: &[ParamType] = &[ + parse_param!( + "--env-file ... Load environment variables from the specified file(s)" + ), + parse_param!("--no-env-file Disable automatic loading of .env files"), + parse_param!( + "--cwd Absolute path to resolve files & entry points from. This just changes the process' cwd." + ), +]; + +const BASE_TAIL_PARAMS: &[ParamType] = &[parse_param!( + "-h, --help Display this menu and exit" +)]; + +/// Shared by every subcommand that keeps `-c` as the `--config` shorthand. pub(crate) const BASE_PARAMS_: &[ParamType] = concat_params!( maybe_debug_params!(), - &[ - parse_param!( - "--env-file ... Load environment variables from the specified file(s)" - ), - parse_param!("--no-env-file Disable automatic loading of .env files"), - parse_param!( - "--cwd Absolute path to resolve files & entry points from. This just changes the process' cwd." - ), - parse_param!( - "-c, --config ? Specify path to Bun config file. Default $cwd/bunfig.toml" - ), - parse_param!("-h, --help Display this menu and exit"), - ], + BASE_HEAD_PARAMS, + &[parse_param!( + "-c, --config ? Specify path to Bun config file. Default $cwd/bunfig.toml" + )], + BASE_TAIL_PARAMS, + maybe_verbose_error_trace!(), + &[parse_param!("...")], +); + +/// Same as [`BASE_PARAMS_`], but `--config` has no `-c` shorthand: the runtime +/// commands give `-c` to `--check` for Node compatibility, so advertising it +/// here too would document an alias that never resolves to `--config`. +pub(crate) const BASE_PARAMS_NO_CONFIG_SHORT_: &[ParamType] = concat_params!( + maybe_debug_params!(), + BASE_HEAD_PARAMS, + &[parse_param!( + "--config ? Specify path to Bun config file. Default $cwd/bunfig.toml" + )], + BASE_TAIL_PARAMS, maybe_verbose_error_trace!(), &[parse_param!("...")], ); @@ -190,6 +211,13 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--inspect-brk ? Activate Bun's debugger, set breakpoint on first line of code and wait" ), + parse_param!( + "--inspect-port Set the default [host:]port used when the debugger is activated with --inspect" + ), + parse_param!("--debug-port "), + parse_param!("--permission"), + parse_param!("--allow-fs-read ..."), + parse_param!("--allow-fs-write ..."), parse_param!( "--cpu-prof Start CPU profiler and write profile to disk on exit" ), @@ -226,9 +254,12 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "-i Auto-install dependencies during execution. Equivalent to --install=fallback." ), - parse_param!("-e, --eval Evaluate argument as a script"), + parse_param!("-e, --eval ! Evaluate argument as a script"), parse_param!( - "-p, --print Evaluate argument as a script and print the result" + "-p, --print ?! Evaluate argument as a script and print the result" + ), + parse_param!( + "--input-type Module type for string input from stdin or --eval: \"module\" or \"commonjs\"" ), parse_param!( "--prefer-offline Skip staleness checks for packages in the Bun runtime and resolve from disk" @@ -261,6 +292,7 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--no-deprecation Suppress all reporting of the custom deprecation." ), + parse_param!("--no-warnings Suppress all process warnings."), parse_param!( "--throw-deprecation Determine whether or not deprecation warnings result in errors." ), @@ -317,6 +349,12 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ ]; pub(crate) const AUTO_OR_RUN_PARAMS: &[ParamType] = &[ + // `-c` means `--check` for the runtime commands (Node.js compatibility). + // The AUTO/RUN tables pair this with BASE_PARAMS_NO_CONFIG_SHORT_ so + // `--config` keeps its long form and nothing else claims `-c`. + parse_param!( + "-c, --check Check the syntax of the entry point (or stdin) without executing it" + ), parse_param!( "-F, --filter ... Run a script in all workspace packages matching the pattern" ), @@ -359,7 +397,7 @@ pub(crate) const AUTO_PARAMS: &[ParamType] = concat_params!( AUTO_ONLY_PARAMS, RUNTIME_PARAMS_, TRANSPILER_PARAMS_, - BASE_PARAMS_ + BASE_PARAMS_NO_CONFIG_SHORT_ ); pub(crate) const RUN_ONLY_PARAMS: &[ParamType] = concat_params!( @@ -375,7 +413,7 @@ pub(crate) const RUN_PARAMS: &[ParamType] = concat_params!( RUN_ONLY_PARAMS, RUNTIME_PARAMS_, TRANSPILER_PARAMS_, - BASE_PARAMS_ + BASE_PARAMS_NO_CONFIG_SHORT_ ); const BAKE_DEBUG_PARAMS: &[ParamType] = &[ @@ -737,6 +775,102 @@ pub(crate) static Bun__Node__UseSystemCA: core::sync::atomic::AtomicBool = // `crate::cli::arguments::load_config*` callers are unaffected. pub use bun_bunfig::arguments::{load_config, load_config_path, load_config_with_cmd_args}; +/// The string Node prefixes its CLI errors with. Same source as +/// `process.execPath` (node_process::get_exec_path), so the prefix matches what +/// scripts observe. +fn node_error_prefix() -> &'static [u8] { + bun_core::self_exe_path() + .map(|p| p.as_bytes()) + .unwrap_or(b"bun") +} + +/// Print Node's missing-argument error for runtime CLI flags Bun borrows from +/// Node.js (`: requires an argument`) and exit with code 9, +/// Node's exit code for invalid command-line arguments. +#[cold] +#[inline(never)] +fn exit_node_requires_argument(flag: &[u8]) -> ! { + bun_core::pretty_errorln!( + "{}: {} requires an argument", + BStr::new(node_error_prefix()), + BStr::new(flag) + ); + Output::flush(); + Global::exit(9); +} + +/// Options Node refuses to accept through the NODE_OPTIONS environment +/// variable: the ones that change what the process executes. +/// https://github.com/nodejs/node/blob/v26.3.0/src/node_options.cc +const NODE_OPTIONS_DISALLOWED: &[&[u8]] = &[ + b"-v", + b"--version", + b"-h", + b"--help", + b"-e", + b"--eval", + b"-p", + b"--print", + b"-pe", + b"-c", + b"--check", + b"-i", + b"--interactive", + b"--v8-options", + b"--test", + b"--", + b"--expose-internals", +]; + +/// Reject NODE_OPTIONS values Node itself refuses, with Node's message and +/// exit code 9. Bun does not apply the remaining NODE_OPTIONS entries yet; this +/// only covers the error contract scripts can rely on. +#[cold] +#[inline(never)] +fn validate_node_options(env: &[u8]) { + let mut i = 0usize; + while i < env.len() { + while i < env.len() && env[i].is_ascii_whitespace() { + i += 1; + } + if i >= env.len() { + break; + } + // Tokenize the way Node does: whitespace-separated, double quotes + // group a span containing whitespace. + let mut token: Vec = Vec::new(); + let mut in_quotes = false; + while i < env.len() && (in_quotes || !env[i].is_ascii_whitespace()) { + if env[i] == b'"' { + in_quotes = !in_quotes; + } else { + token.push(env[i]); + } + i += 1; + } + if !token.starts_with(b"-") { + continue; + } + // Compare the option name (before any '='), treating '_' as '-' the + // way Node canonicalizes option names. The message echoes the spelling + // the user wrote. + let name = &token[..token.iter().position(|&b| b == b'=').unwrap_or(token.len())]; + let canonical: Vec = name + .iter() + .map(|&b| if b == b'_' { b'-' } else { b }) + .collect(); + if NODE_OPTIONS_DISALLOWED.contains(&canonical.as_slice()) { + bun_core::pretty_errorln!( + "{}: {} is not allowed in NODE_OPTIONS", + BStr::new(node_error_prefix()), + BStr::new(name) + ); + Output::flush(); + Global::exit(9); + } + } +} + /// node aliases `-pe` to `--print --eval` as a whole token (node_options.cc): /// it can't be a short in either runtime, being ambiguous with `-p` carrying /// the attached value `e`. Bun's `-p` takes the code, so `-pe X` is `-p X`. @@ -760,6 +894,10 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result 1, _ => 0, }, + reject_bad_negations: matches!( + cmd, + CommandTag::AutoCommand | CommandTag::RunCommand | CommandTag::RunAsNodeCommand + ), // Only the paths standing in for `node` get node's aliases. short_aliases: match cmd { CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => NODE_SHORT_ALIASES, @@ -769,6 +907,45 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result a, Err(err) => { + // For runtime flags borrowed from Node.js, report a missing value + // the way `node` does (and with its exit code 9) so scripts that + // branch on Node's CLI error contract behave the same under Bun. + if err == clap::Error::MissingValue + && matches!( + cmd, + CommandTag::AutoCommand | CommandTag::RunCommand | CommandTag::RunAsNodeCommand + ) + { + // `diag.arg` is the argument as written with its leading + // dashes stripped. Node echoes it verbatim, so the long form + // keeps a trailing '=' (`node --eval=` reports + // "--eval= requires an argument"); the short form reports the + // single flag that wanted the value, not the cluster it + // arrived in. + let node_flag: Option> = match (diag.short, diag.long.as_deref()) { + (Some(short @ (b'e' | b'p')), _) => Some(vec![b'-', short]), + (_, Some(b"eval" | b"print" | b"inspect-port" | b"debug-port")) => { + let mut flag = b"--".to_vec(); + flag.extend_from_slice(&diag.arg); + Some(flag) + } + _ => None, + }; + if let Some(flag) = node_flag { + exit_node_requires_argument(&flag); + } + } + if err == clap::Error::InvalidNegation { + // https://github.com/nodejs/node/blob/v26.3.0/src/node_options-inl.h + bun_core::pretty_errorln!( + "{}: --{} is an invalid negation because it is not a boolean option", + BStr::new(node_error_prefix()), + BStr::new(&diag.arg) + ); + Output::flush(); + Global::exit(9); + } + // Report useful error and exit let _ = diag.report(Output::error_writer(), err); command::tag_print_help(cmd, false); @@ -1099,6 +1276,7 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result` from working ctx.runtime_options.eval.script = port_str.into(); ctx.runtime_options.eval.eval_and_print = true; + ctx.runtime_options.eval.provided = true; } else { opts.port = match strings::parse_int::(port_str, 10) { Ok(v) => Some(v), @@ -1167,12 +1345,55 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result` as an alias + // for `-pe`, i.e. `--print --eval `, so -p turns on print mode and + // may also carry the script (`bun -p 42`, `bun -pe 42`, `bun -p -e 42`). + // + // Divergence: because both spellings feed one upstream `--eval` string, + // Node takes whichever came last, so `node -p 7 -e 9` prints 9. Bun's + // parser keeps the two options in separate slots with no relative + // order, so a script on -p wins and it prints 7. + let print_arg = args.option(b"--print"); + let eval_arg = args.option(b"--eval"); + if print_arg.is_some() || eval_arg.is_some() { + // `provided` (not a non-empty script) is what selects eval mode, so + // `bun -e ""` runs an empty program and bare `bun -p` prints + // undefined instead of falling through to help. + ctx.runtime_options.eval.provided = true; + ctx.runtime_options.eval.eval_and_print = print_arg.is_some(); + let script: &[u8] = match (print_arg, eval_arg) { + (Some(print_script), _) if !print_script.is_empty() => print_script, + (_, Some(eval_script)) => eval_script, + (print_script, None) => print_script.unwrap_or_default(), + }; ctx.runtime_options.eval.script = script.into(); } + if let Some(input_type) = args.option(b"--input-type") { + ctx.runtime_options.eval.input_type = input_type.into(); + } + + // Node's CLI contract only applies to the commands that stand in for + // `node`; `--check` is only declared in their tables. + if matches!( + cmd, + CommandTag::AutoCommand | CommandTag::RunCommand | CommandTag::RunAsNodeCommand + ) { + if let Some(node_options) = bun_core::env_var::NODE_OPTIONS::get() { + validate_node_options(node_options); + } + + ctx.runtime_options.check_syntax = args.flag(b"--check"); + if ctx.runtime_options.check_syntax && ctx.runtime_options.eval.provided { + // Node prints this (and exits 9) for `node -c -e foo`. + bun_core::pretty_errorln!( + "{}: either --check or --eval can be used, not both", + BStr::new(node_error_prefix()) + ); + Output::flush(); + Global::exit(9); + } + } + 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")); @@ -1230,9 +1451,60 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result = + match (args.option(b"--inspect-port"), args.option(b"--debug-port")) { + (Some(value), _) => { + if value.is_empty() { + exit_node_requires_argument(b"--inspect-port="); + } + Some(value) + } + (None, Some(value)) => { + if value.is_empty() { + exit_node_requires_argument(b"--debug-port="); + } + Some(value) + } + (None, None) => None, + }; + let default_debugger_target = || -> Box<[u8]> { + inspect_port_value + .map(Box::<[u8]>::from) + .unwrap_or_default() + }; + if let Some(inspect_flag) = args.option(b"--inspect") { ctx.runtime_options.debugger = if inspect_flag.is_empty() { - Debugger::Enable(Default::default()) + Debugger::Enable(DebuggerEnable { + path_or_port: default_debugger_target(), + ..Default::default() + }) } else { Debugger::Enable(DebuggerEnable { path_or_port: Box::<[u8]>::from(inspect_flag), @@ -1242,6 +1514,7 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result) -> crate::Result) -> crate::Result --version`, where the flag belongs to // `` (the bug the old argv-scan shim had — see the - // NOTE below). The empty-eval check is likewise exact-shape, falling - // through to `HelpCommand.exec`. + // NOTE below). { let argv = bun::argv(); let argv0 = argv.get(0).map(bun_core::ZStr::as_bytes).unwrap_or(b""); @@ -1253,25 +1252,6 @@ pub mod command { } } - let empty_eval = match argv.len() { - 2 => matches!( - argv.get(1).map(bun_core::ZStr::as_bytes), - Some(b"-e=" | b"-p=" | b"--eval=" | b"--print=") - ), - 3 => { - argv.get(2).is_some_and(|a| a.as_bytes().is_empty()) - && matches!( - argv.get(1).map(bun_core::ZStr::as_bytes), - Some(b"-e" | b"-p" | b"--eval" | b"--print") - ) - } - _ => false, - }; - if empty_eval { - Output::flush(); - return HelpCommand::exec(); - } - // `bun ` / `bun .` — the dominant run shape. argv[1] is // path-shaped (`looks_like_run_entrypoint`), which no // subcommand keyword can be, so `which()` would unambiguously @@ -1456,7 +1436,14 @@ pub mod command { Global::exit(1); } - if tag == Tag::AutoCommand && !ctx.runtime_options.eval.script.is_empty() { + if matches!(tag, Tag::AutoCommand | Tag::RunCommand) && ctx.runtime_options.check_syntax { + // `--check` / `-c`: syntax-check the entry point (or stdin) without + // executing it. `--check` together with `--eval` already errored + // during argument parsing. + return run_command::RunCommand::exec_check(ctx); + } + + if tag == Tag::AutoCommand && ctx.runtime_options.eval.provided { return run_command::RunCommand::exec_eval(ctx); } diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index ae6a3651f858..25a02133a243 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -974,7 +974,7 @@ Full documentation is available at https://bun.com/docs/cli/run let entry: &[u8] = unsafe { &*entry_ptr }; vm.set_main(entry); - if !ctx.runtime_options.eval.script.is_empty() { + if ctx.runtime_options.eval.has_entry() { // SAFETY: `ctx.runtime_options.eval.script` is process-lifetime // (CLI argv); erase the borrow lifetime so the `Source` (stored in // the VM for the process duration) can backref into it. @@ -1322,7 +1322,7 @@ impl Run { let vm = unsafe { &*self.vm }; // SAFETY: `self.ctx` is process-lifetime; see comment on `vm` above. let ro = unsafe { &(*self.ctx).runtime_options }; - if !ro.eval.script.is_empty() { + if ro.eval.has_entry() { // SAFETY: FFI; `vm.global` is live for the VM lifetime. unsafe { Bun__ExposeNodeModuleGlobals(vm.global) }; } @@ -1552,6 +1552,38 @@ impl Run { bun_standalone_graph::Graph::hint_source_pages_dont_need(); } + // `--check`: the entry that just ran was a no-op stand-in (see + // `exec_check`); syntax-check the real target now that preloads + // (which may override the CommonJS module wrapper) have run. + if let Some((source, name, module_type)) = CHECK_SYNTAX_TARGET.get() { + unsafe extern "C" { + fn Bun__checkSyntaxForCLI( + global: *const JSGlobalObject, + source_ptr: *const u8, + source_len: usize, + name_ptr: *const u8, + name_len: usize, + module_type: i32, + ) -> i32; + } + // SAFETY: FFI; `vm.global()` is live for the VM lifetime and the + // slices live in the process-lifetime `CHECK_SYNTAX_TARGET`. + let failed = unsafe { + Bun__checkSyntaxForCLI( + vm.global(), + source.as_ptr(), + source.len(), + name.as_ptr(), + name.len(), + *module_type as i32, + ) + }; + Output::flush(); + if failed != 0 { + vm.exit_handler.exit_code = 1; + } + } + // ── core run-loop ────────────────────────────────────────────────── if vm.is_watcher_enabled() { vm.report_exception_in_hot_reloaded_module_if_needed(); @@ -2968,13 +3000,79 @@ impl RunCommand { Self::boot(ctx, entry, None) } + /// `--check` / `-c` (Node.js compatibility): read the entry point (or + /// stdin), then boot the VM with a no-op eval entry so `--require` / + /// `--preload` modules still execute the way they do under `node --check`. + /// `Run::start` performs the actual syntax check against the stored source + /// instead of executing anything user-provided. + pub fn exec_check(ctx: &mut ContextData) -> crate::Result<()> { + // `ctx.args.entry_points` is the positional list with the leading + // subcommand keyword ("run") already stripped. + let target: Option> = ctx.args.entry_points.first().cloned(); + let (source, display_name): (Box<[u8]>, Box<[u8]>) = if let Some(target) = target { + let mut cwd_buf = PathBuffer::uninit(); + let cwd = bun_core::getcwd_or_exe_dir(&mut cwd_buf); + let joined = paths::resolve_path::join_abs::(cwd.as_bytes(), &target); + let abs: Box<[u8]> = joined.to_vec().into_boxed_slice(); + + let mut contents = + sys::File::openat(Fd::cwd(), &abs, sys::O::RDONLY, 0).and_then(|f| f.read_to_end()); + let mut resolved = abs; + if contents.is_err() && !resolved.ends_with(b".js") { + // Node resolves the --check target like require(): an + // extensionless path falls back to ".js". + let mut with_js = resolved.to_vec(); + with_js.extend_from_slice(b".js"); + let with_js: Box<[u8]> = with_js.into_boxed_slice(); + if let Ok(bytes) = sys::File::openat(Fd::cwd(), &with_js, sys::O::RDONLY, 0) + .and_then(|f| f.read_to_end()) + { + contents = Ok(bytes); + resolved = with_js; + } + } + match contents { + Ok(bytes) => (bytes.into_boxed_slice(), resolved), + Err(_) => { + // Same first line as Node's loader for a missing --check target. + pretty_errorln!( + "Error: Cannot find module '{}'", + ::bstr::BStr::new(&resolved) + ); + Output::flush(); + Global::exit(1); + } + } + } else { + // No file argument: check stdin, like `node --check` with piped input. + let mut bytes: Vec = Vec::new(); + let _ = sys::File::stdin().read_to_end_into(&mut bytes); + (bytes.into_boxed_slice(), Box::from(&b"[stdin]"[..])) + }; + + let module_type = CheckModuleType::of(&ctx.runtime_options.eval.input_type, &display_name); + let _ = CHECK_SYNTAX_TARGET.set((source, display_name, module_type)); + + // No-op eval entry: nothing user-visible executes, but preloads run and + // the JSC global the syntax check needs exists. + ctx.runtime_options.eval.script = Box::from(&b"\n"[..]); + ctx.runtime_options.eval.eval_and_print = false; + ctx.positionals.clear(); + ctx.args.entry_points.clear(); + Self::exec_eval(ctx) + } + /// `node` argv0 emulation. Port of `execAsIfNode`. pub fn exec_as_if_node(ctx: &mut ContextData) -> crate::Result<()> { // SAFETY: single-threaded CLI startup; `PRETEND_TO_BE_NODE` is set in // `Command::which()` before dispatch. debug_assert!(crate::cli::PRETEND_TO_BE_NODE.load(::core::sync::atomic::Ordering::Relaxed)); - if !ctx.runtime_options.eval.script.is_empty() { + if ctx.runtime_options.check_syntax { + return Self::exec_check(ctx); + } + + if ctx.runtime_options.eval.has_entry() { // synthetic `[eval]` path under cwd let mut entry_point_buf = [0u8; MAX_PATH_BYTES + EVAL_TRIGGER.len()]; let mut cwd_buf = PathBuffer::uninit(); @@ -3070,6 +3168,38 @@ const EVAL_TRIGGER: &[u8] = b"\\[eval]"; #[cfg(not(windows))] const EVAL_TRIGGER: &[u8] = b"/[eval]"; +type AutoPlatform = paths::resolve_path::platform::Auto; + +/// How `--check` should parse its target. Mirrored by the `moduleType` argument +/// of `Bun__checkSyntaxForCLI`. +#[derive(Copy, Clone)] +#[repr(i32)] +enum CheckModuleType { + /// Try CommonJS, then ES module. + Detect = 0, + CommonJS = 1, + Module = 2, +} + +impl CheckModuleType { + fn of(input_type: &[u8], display_name: &[u8]) -> Self { + match input_type { + b"module" => Self::Module, + b"commonjs" => Self::CommonJS, + _ if display_name.ends_with(b".mjs") => Self::Module, + _ if display_name.ends_with(b".cjs") => Self::CommonJS, + _ => Self::Detect, + } + } +} + +/// `--check` target: (source bytes, display name shown in errors, module type). +/// Set once during CLI startup in `exec_check` before `boot()`, read in +/// `Run::start` after the (no-op) entry evaluates. CLI-process state, not +/// per-VM state: `--check` only ever applies to the single CLI entry point. +static CHECK_SYNTAX_TARGET: std::sync::OnceLock<(Box<[u8]>, Box<[u8]>, CheckModuleType)> = + std::sync::OnceLock::new(); + /// Escape `\ " \n \r \t` for /// embedding in a double-quoted JS string literal. Used by the cron-execution /// wrapper script to inline the entry path and cron period. diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index b40c10d8e0a4..a8d391b0f348 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -79,9 +79,15 @@ pub extern "C" fn exit(global_object: &JSGlobalObject, code: u8) { // ───────────────────────────── misc exports ───────────────────────────── +/// Set by `--no-warnings`. Node's `--warnings` is the default, so it needs no +/// state of its own. +pub static NO_WARNINGS_FLAG: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + #[unsafe(no_mangle)] pub(crate) extern "C" fn Bun__NODE_NO_WARNINGS() -> bool { - env_var::NODE_NO_WARNINGS.get() == Some(b"1") + NO_WARNINGS_FLAG.load(core::sync::atomic::Ordering::Relaxed) + || env_var::NODE_NO_WARNINGS.get() == Some(b"1") } #[unsafe(no_mangle)] diff --git a/test/cli/install/bun-run-bunfig.test.ts b/test/cli/install/bun-run-bunfig.test.ts index c3a90d514c45..c03d0f89951c 100644 --- a/test/cli/install/bun-run-bunfig.test.ts +++ b/test/cli/install/bun-run-bunfig.test.ts @@ -4,7 +4,7 @@ import { bunEnv, bunExe, isWindows, tempDirWithFiles, toTOMLString } from "harne import { join as pathJoin } from "node:path"; describe.each(["bun run", "bun"])(`%s`, cmd => { - const runCmd = cmd === "bun" ? ["-c=bunfig.toml", "run"] : ["-c=bunfig.toml"]; + const runCmd = cmd === "bun" ? ["--config=bunfig.toml", "run"] : ["--config=bunfig.toml"]; const node = Bun.which("node")!; const execPath = process.execPath; diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index f9045468b6bc..fe7cc3ec5192 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -225,13 +225,9 @@ describe.concurrent("bun run", () => { }); await using proc = Bun.spawn({ - // TODO: figure out why -c is necessary here. - cmd: [ - bunExe(), - ...(withRun ? ["run"] : []), - "-c=" + join(String(dir), "bunfig.toml"), - "./index.js", - ].filter(Boolean), + // `bun run` does not pick up bunfig.toml from the cwd on its + // own; --config with no value asks for the default one. + cmd: [bunExe(), ...(withRun ? ["run"] : []), "--config", "./index.js"].filter(Boolean), cwd: String(dir), env: bunEnv, stdout: "pipe", diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 8f4bf967c094..596ec987da32 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -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"; @@ -262,6 +262,370 @@ describe("echo | bun run -", () => { group(run); }); +describe("bun --check", () => { + test.each(["-c", "--check"])("%s reports a syntax error without running the file", async flag => { + using dir = tempDir("check-bad", { + "bad.js": "var foo bar;\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "bad.js"], + 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(stdout).toBe(""); + expect(stderr).toContain("bad.js:1"); + expect(stderr).toContain("SyntaxError: Unexpected identifier"); + expect(exitCode).toBe(1); + }); + + test.each(["-c", "--check"])("%s does not execute the file", async flag => { + using dir = tempDir("check-good", { + "good.js": 'throw new Error("should not run");\n', + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "good.js"], + 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({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + test("accepts a top-level return, which is only legal inside the CommonJS wrapper", async () => { + using dir = tempDir("check-wrapper", { + "wrapped.js": "if (true) {\n return;\n}\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "wrapped.js"], + 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({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + test("checks stdin when no file is given", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check"], + env: bunEnv, + stdin: Buffer.from("var foo bar;\n"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.startsWith("[stdin]")).toBe(true); + expect(exitCode).toBe(1); + }); + + test("--input-type=module checks stdin as an ES module", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--input-type=module", "--check"], + env: bunEnv, + stdin: Buffer.from("export var p = 5;\n"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + test("reports a missing target the way Node's loader does", async () => { + using dir = tempDir("check-missing", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", "nope.js"], + 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(stdout).toBe(""); + expect(stderr).toContain("Error: Cannot find module '"); + expect(exitCode).toBe(1); + }); + + test.each([ + ["--eval", "foo"], + // An empty program still counts as --eval being present. + ["--eval", ""], + ])("--check together with %s %p exits 9, like Node", async (flag, script) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--check", flag, script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain(": either --check or --eval can be used, not both"); + expect(exitCode).toBe(9); + }); +}); + +describe("node-style CLI errors", () => { + // -p / --print are deliberately absent: upstream registers --print as a + // boolean, so bare `node -p` prints undefined instead of erroring. + test.each(["--eval", "-e", "--inspect-port", "--debug-port"])("%s without a value exits 9", async flag => { + await using proc = Bun.spawn({ + cmd: [bunExe(), flag], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.split(/\r?\n/)[0]).toEndWith(`: ${flag} requires an argument`); + expect(exitCode).toBe(9); + }); + + test.each(["--inspect-port=", "--debug-port="])("%s with an empty value exits 9", async flag => { + await using proc = Bun.spawn({ + cmd: [bunExe(), flag], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.split(/\r?\n/)[0]).toEndWith(`: ${flag} requires an argument`); + expect(exitCode).toBe(9); + }); + + test.each(["--allow-fs-read=*", "--allow-fs-write=*"])("%s without --permission is rejected", async flag => { + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "-e", "1"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("--permission is required"); + expect(exitCode).toBe(1); + }); + + test("--permission is rejected rather than silently ignored", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "-e", "1"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("--permission is not supported by Bun"); + expect(exitCode).toBe(1); + }); +}); + +describe("NODE_OPTIONS", () => { + test.each([ + "--version", + "-v", + "--help", + "-h", + "--eval", + "-e", + "--print", + "-p", + "-pe", + "--check", + "-c", + "-i", + "--interactive", + "--v8-options", + "--expose_internals", + "--expose-internals", + "--", + "--test", + ])("%s is rejected inside NODE_OPTIONS", async opt => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "1"], + env: { ...bunEnv, NODE_OPTIONS: opt }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.split(/\r?\n/)[0]).toEndWith(`: ${opt} is not allowed in NODE_OPTIONS`); + expect(exitCode).toBe(9); + }); + + test("an allowed option is not rejected", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('ok')"], + env: { ...bunEnv, NODE_OPTIONS: "--no-warnings" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("ok\n"); + expect(exitCode).toBe(0); + }); +}); + +describe("-e / -p value binding", () => { + // Node models -p as a boolean plus a `--print ` alias for `--print --eval `, + // so print mode and the script can arrive together or separately. + const shapes: [string[], string][] = [ + [["--print", "42"], "42\n"], + [["-p", "42"], "42\n"], + [["-pe", "42"], "42\n"], + [["-p", "-e", "42"], "42\n"], + [["-p", "[]"], "[]\n"], + [["--print", "--eval=-42"], "-42\n"], + [["--print", "--eval=-0"], "-0\n"], + // A separate argument may escape the leading-dash rule with a backslash. + [["-p", "\\-42"], "-42\n"], + ]; + for (const [args, expected] of shapes) { + test(`bun ${args.join(" ")}`, async () => { + await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: expected, stderr: "", exitCode: 0 }); + }); + } + + test("an empty -e program runs and produces no output", async () => { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", ""], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + test("bare -p prints undefined", async () => { + await using proc = Bun.spawn({ cmd: [bunExe(), "-p"], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toBe("undefined\n"); + expect(exitCode).toBe(0); + }); + + test("an option-looking argument is not taken as -e's value", async () => { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", "-p"], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.trim()).toEndWith(": -e requires an argument"); + expect(exitCode).toBe(9); + }); + + test("--eval= echoes the argument as written", async () => { + await using proc = Bun.spawn({ cmd: [bunExe(), "--eval="], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.split(/\r?\n/)[0]).toEndWith(": --eval= requires an argument"); + expect(exitCode).toBe(9); + }); +}); + +describe("--no- negation", () => { + // Bun ignores unrecognized flags on purpose, so the many Node options it does + // not implement stay harmless. Node errors on these; matching it would break + // every script that passes a Node-only flag to Bun. + test.each(["--no-i-dont-exist", "--no-warnings", "--no-extra-info-on-fatal-exception", "--no-global-search-paths"])( + "%s is tolerated", + async flag => { + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "-e", "console.log('ok')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }, + ); + + test("--no-warnings suppresses process warnings", async () => { + const code = "process.emitWarning('nope'); console.log('ok')"; + await using warned = Bun.spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [, warnedErr] = await Promise.all([warned.stdout.text(), warned.stderr.text(), warned.exited]); + expect(warnedErr).toContain("nope"); + + await using quiet = Bun.spawn({ + cmd: [bunExe(), "--no-warnings", "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([quiet.stdout.text(), quiet.stderr.text(), quiet.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); + + test("negating an option that takes a value is rejected", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-max-http-header-size", "-e", "1"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr.split(/\r?\n/)[0]).toEndWith( + ": --no-max-http-header-size is an invalid negation because it is not a boolean option", + ); + expect(exitCode).toBe(9); + }); + + test("a declared --no- still works", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "-e", "console.log('ok')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); + + test("other subcommands keep ignoring unknown flags", async () => { + using dir = tempDir("negation-subcommand", { "package.json": JSON.stringify({ name: "x", version: "0.0.0" }) }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "--no-i-dont-exist", "bin"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + }); +}); + test("process._eval (undefined for normal run)", async () => { const cwd = tmpdirSync(); const file = join(cwd, "test.js"); diff --git a/test/js/node/test/parallel/test-cli-bad-options.js b/test/js/node/test/parallel/test-cli-bad-options.js new file mode 100644 index 000000000000..686854132530 --- /dev/null +++ b/test/js/node/test/parallel/test-cli-bad-options.js @@ -0,0 +1,38 @@ +'use strict'; +require('../common'); + +// Tests that node exits consistently on bad option syntax. + +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +if (process.features.inspector) { + requiresArgument('--inspect-port'); + requiresArgument('--inspect-port='); + requiresArgument('--debug-port'); + requiresArgument('--debug-port='); +} +requiresArgument('--eval'); + +missingOption('--allow-fs-read=*', '--permission'); +missingOption('--allow-fs-write=*', '--permission'); + +function missingOption(option, requiredOption) { + const r = spawnSync(process.execPath, [option], { encoding: 'utf8' }); + assert.strictEqual(r.status, 1); + + const message = `${requiredOption} is required`; + assert.match(r.stderr, new RegExp(message)); +} + +function requiresArgument(option) { + const r = spawnSync(process.execPath, [option], { encoding: 'utf8' }); + + assert.strictEqual(r.status, 9); + + const msg = r.stderr.split(/\r?\n/)[0]; + assert.strictEqual( + msg, + `${process.execPath}: ${option} requires an argument` + ); +} diff --git a/test/js/node/test/parallel/test-cli-node-options-disallowed.js b/test/js/node/test/parallel/test-cli-node-options-disallowed.js new file mode 100644 index 000000000000..776237531e55 --- /dev/null +++ b/test/js/node/test/parallel/test-cli-node-options-disallowed.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common'); +if (process.config.variables.node_without_node_options) + common.skip('missing NODE_OPTIONS support'); + +// Test options specified by env variable. + +const assert = require('assert'); +const exec = require('child_process').execFile; + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +disallow('--version'); +disallow('-v'); +disallow('--help'); +disallow('-h'); +disallow('--eval'); +disallow('-e'); +disallow('--print'); +disallow('-p'); +disallow('-pe'); +disallow('--check'); +disallow('-c'); +disallow('--interactive'); +disallow('-i'); +disallow('--v8-options'); +disallow('--expose_internals'); +disallow('--expose-internals'); +disallow('--'); +disallow('--test'); + +function disallow(opt) { + const env = { ...process.env, NODE_OPTIONS: opt }; + exec(process.execPath, { cwd: tmpdir.path, env }, common.mustCall((err) => { + const message = err.message.split(/\r?\n/)[1]; + const expect = `${process.execPath}: ${opt} is not allowed in NODE_OPTIONS`; + + assert.strictEqual(err.code, 9); + assert.strictEqual(message, expect); + })); +} diff --git a/test/js/node/test/parallel/test-cli-syntax-eval.js b/test/js/node/test/parallel/test-cli-syntax-eval.js new file mode 100644 index 000000000000..ad107405b208 --- /dev/null +++ b/test/js/node/test/parallel/test-cli-syntax-eval.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { exec } = require('child_process'); + +// Should throw if -c and -e flags are both passed +['-c', '--check'].forEach(function(checkFlag) { + ['-e', '--eval'].forEach(function(evalFlag) { + exec(...common.escapePOSIXShell`"${process.execPath}" ${checkFlag} ${evalFlag} foo`, common.mustCall((err, stdout, stderr) => { + assert.strictEqual(err instanceof Error, true); + assert.strictEqual(err.code, 9); + assert( + stderr.startsWith( + `${process.execPath}: either --check or --eval can be used, not both` + ) + ); + })); + }); +}); diff --git a/test/js/node/test/parallel/test-cli-syntax-piped-bad.js b/test/js/node/test/parallel/test-cli-syntax-piped-bad.js new file mode 100644 index 000000000000..4af0aa2b6df7 --- /dev/null +++ b/test/js/node/test/parallel/test-cli-syntax-piped-bad.js @@ -0,0 +1,56 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const node = process.execPath; + +// Test both sets of arguments that check syntax +const syntaxArgs = [ + '-c', + '--check', +]; + +// Match on the name of the `Error` but not the message as it is different +// depending on the JavaScript engine. +const syntaxErrorRE = /^SyntaxError: Unexpected identifier\b/m; + +// Should throw if code piped from stdin with --check has bad syntax +// loop each possible option, `-c` or `--check` +syntaxArgs.forEach(function(arg) { + const stdin = 'var foo bar;'; + const c = spawnSync(node, [arg], { encoding: 'utf8', input: stdin }); + + // stderr should include '[stdin]' as the filename + assert(c.stderr.startsWith('[stdin]'), `${c.stderr} starts with ${stdin}`); + + // No stdout should be produced + assert.strictEqual(c.stdout, ''); + + // stderr should have a syntax error message + assert.match(c.stderr, syntaxErrorRE); + + assert.strictEqual(c.status, 1); +}); + +// Check --input-type=module +syntaxArgs.forEach(function(arg) { + const stdin = 'export var p = 5; var foo bar;'; + const c = spawnSync( + node, + ['--input-type=module', '--no-warnings', arg], + { encoding: 'utf8', input: stdin } + ); + + // stderr should include '[stdin]' as the filename + assert(c.stderr.startsWith('[stdin]'), `${c.stderr} starts with ${stdin}`); + + // No stdout should be produced + assert.strictEqual(c.stdout, ''); + + // stderr should have a syntax error message + assert.match(c.stderr, syntaxErrorRE); + + assert.strictEqual(c.status, 1); +}); diff --git a/test/js/node/test/parallel/test-cli-syntax-piped-good.js b/test/js/node/test/parallel/test-cli-syntax-piped-good.js new file mode 100644 index 000000000000..db2e0f875d2a --- /dev/null +++ b/test/js/node/test/parallel/test-cli-syntax-piped-good.js @@ -0,0 +1,42 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const node = process.execPath; + +// Test both sets of arguments that check syntax +const syntaxArgs = [ + '-c', + '--check', +]; + +// Should not execute code piped from stdin with --check. +// Loop each possible option, `-c` or `--check`. +syntaxArgs.forEach(function(arg) { + const stdin = 'throw new Error("should not get run");'; + const c = spawnSync(node, [arg], { encoding: 'utf8', input: stdin }); + + // No stdout or stderr should be produced + assert.strictEqual(c.stdout, ''); + assert.strictEqual(c.stderr, ''); + + assert.strictEqual(c.status, 0); +}); + +// Check --input-type=module +syntaxArgs.forEach(function(arg) { + const stdin = 'export var p = 5; throw new Error("should not get run");'; + const c = spawnSync( + node, + ['--no-warnings', '--input-type=module', arg], + { encoding: 'utf8', input: stdin } + ); + + // No stdout or stderr should be produced + assert.strictEqual(c.stdout, ''); + assert.strictEqual(c.stderr, ''); + + assert.strictEqual(c.status, 0); +}); diff --git a/test/js/node/test/sequential/test-cli-syntax-bad.js b/test/js/node/test/sequential/test-cli-syntax-bad.js new file mode 100644 index 000000000000..e967ff36ac28 --- /dev/null +++ b/test/js/node/test/sequential/test-cli-syntax-bad.js @@ -0,0 +1,56 @@ +'use strict'; + +const common = require('../common'); +const { exec } = require('child_process'); +const { test } = require('node:test'); +const fixtures = require('../common/fixtures'); + +// Test both sets of arguments that check syntax +const syntaxArgs = [ + '-c', + '--check', +]; + +// Match on the name of the `Error` but not the message as it is different +// depending on the JavaScript engine. +const syntaxErrorRE = /^SyntaxError: \b/m; + +// Test bad syntax with and without shebang +[ + 'syntax/bad_syntax.js', + 'syntax/bad_syntax', + 'syntax/bad_syntax_shebang.js', + 'syntax/bad_syntax_shebang', +].forEach((file) => { + const path = fixtures.path(file); + + // Loop each possible option, `-c` or `--check` + syntaxArgs.forEach((flag) => { + test(`Checking syntax for ${file} with ${flag}`, async (t) => { + try { + const { stdout, stderr } = await execNode(flag, path); + + // No stdout should be produced + t.assert.strictEqual(stdout, ''); + + // Stderr should have a syntax error message + t.assert.match(stderr, syntaxErrorRE); + + // stderr should include the filename + t.assert.ok(stderr.startsWith(path)); + } catch (err) { + t.assert.strictEqual(err.code, 1); + } + }); + }); +}); + +// Helper function to promisify exec +function execNode(flag, path) { + const { promise, resolve, reject } = Promise.withResolvers(); + exec(...common.escapePOSIXShell`"${process.execPath}" ${flag} "${path}"`, (err, stdout, stderr) => { + if (err) return reject({ ...err, stdout, stderr }); + resolve({ stdout, stderr }); + }); + return promise; +} diff --git a/test/js/node/test/sequential/test-cli-syntax-file-not-found.js b/test/js/node/test/sequential/test-cli-syntax-file-not-found.js new file mode 100644 index 000000000000..203074a0e7aa --- /dev/null +++ b/test/js/node/test/sequential/test-cli-syntax-file-not-found.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { exec } = require('child_process'); +const fixtures = require('../common/fixtures'); + +// Test both sets of arguments that check syntax +const syntaxArgs = [ + '-c', + '--check', +]; + +const notFoundRE = /^Error: Cannot find module/m; + +// test file not found +[ + 'syntax/file_not_found.js', + 'syntax/file_not_found', +].forEach(function(file) { + file = fixtures.path(file); + + // Loop each possible option, `-c` or `--check` + syntaxArgs.forEach(function(flag) { + exec(...common.escapePOSIXShell`"${process.execPath}" ${flag} "${file}"`, common.mustCall((err, stdout, stderr) => { + // No stdout should be produced + assert.strictEqual(stdout, ''); + + // `stderr` should have a module not found error message. + assert.match(stderr, notFoundRE); + + assert.strictEqual(err.code, 1, + `code ${err.code} !== 1 for error:\n\n${err}`); + })); + }); +}); diff --git a/test/js/node/test/sequential/test-cli-syntax-good.js b/test/js/node/test/sequential/test-cli-syntax-good.js new file mode 100644 index 000000000000..00d4e05e246c --- /dev/null +++ b/test/js/node/test/sequential/test-cli-syntax-good.js @@ -0,0 +1,39 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { exec } = require('child_process'); +const fixtures = require('../common/fixtures'); + +// Test both sets of arguments that check syntax +const syntaxArgs = [ + '-c', + '--check', +]; + +// Test good syntax with and without shebang +[ + 'syntax/good_syntax.js', + 'syntax/good_syntax', + 'syntax/good_syntax.mjs', + 'syntax/good_syntax_shebang.js', + 'syntax/good_syntax_shebang', + 'syntax/illegal_if_not_wrapped.js', +].forEach(function(file) { + file = fixtures.path(file); + + // Loop each possible option, `-c` or `--check` + syntaxArgs.forEach(function(flag) { + exec(...common.escapePOSIXShell`"${process.execPath}" ${flag} "${file}"`, common.mustCall((err, stdout, stderr) => { + if (err) { + console.log('-- stdout --'); + console.log(stdout); + console.log('-- stderr --'); + console.log(stderr); + } + assert.ifError(err); + assert.strictEqual(stdout, ''); + assert.strictEqual(stderr, ''); + })); + }); +}); diff --git a/test/js/node/test/sequential/test-cli-syntax-require.js b/test/js/node/test/sequential/test-cli-syntax-require.js new file mode 100644 index 000000000000..f35ad90e00a5 --- /dev/null +++ b/test/js/node/test/sequential/test-cli-syntax-require.js @@ -0,0 +1,34 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { spawnSyncAndExit } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); + +const node = process.execPath; + +// Match on the name of the `Error` but not the message as it is different +// depending on the JavaScript engine. +const syntaxErrorRE = /^SyntaxError: \b/m; + +// Should work with -r flags +['-c', '--check'].forEach(function(checkFlag) { + ['-r', '--require'].forEach(function(requireFlag) { + const preloadFile = fixtures.path('no-wrapper.js'); + const file = fixtures.path('syntax', 'illegal_if_not_wrapped.js'); + const args = [requireFlag, preloadFile, checkFlag, file]; + spawnSyncAndExit(node, args, { + status: 1, + signal: null, + trim: true, + stdout: '', + stderr(output) { + // stderr should have a syntax error message + assert.match(output, syntaxErrorRE); + + // stderr should include the filename + assert(output.startsWith(file), `${output} starts with ${file}`); + }, + }); + }); +});