diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index d6a3e49500c5..e7b9ba5c47e4 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -244,7 +244,7 @@ impl<'a> Parser<'a> { } fn load_preload(&mut self, expr: &Expr) -> crate::Result<()> { - match &expr.data { + let mut preloads: Vec> = match &expr.data { ExprData::EArray(array) => { let items = array.items.slice(); let mut preloads: Vec> = Vec::with_capacity(items.len()); @@ -256,18 +256,22 @@ impl<'a> Parser<'a> { } } } - self.ctx.preloads = preloads; + preloads } ExprData::EString(s) => { - if s.len() > 0 { - self.ctx.preloads = vec![estring_to_owned(s, self.bump)]; + if s.len() == 0 { + return Ok(()); } + vec![estring_to_owned(s, self.bump)] } - ExprData::ENull(_) => {} - _ => { - self.add_error(expr.loc, b"Expected preload to be an array")?; - } - } + ExprData::ENull(_) => return Ok(()), + _ => return self.add_error(expr.loc, b"Expected preload to be an array"), + }; + // `ctx.preloads` already holds the `--preload`s when bunfig.toml is + // loaded after argument parsing (`bun run`); as in the other load + // order, the config's preloads run first and argv's follow. + preloads.append(&mut self.ctx.preloads); + self.ctx.preloads = preloads; Ok(()) } @@ -359,8 +363,15 @@ impl<'a> Parser<'a> { self.load_log_level(&expr)?; } + // Keys with a command-line counterpart are still validated but only + // applied when that flag was not given: see `CliOverrides`. + let cli = self.ctx.cli_overrides; + if let Some(expr) = json.get(b"define") { - self.ctx.args.define = Some(self.parse_define_map(&expr)?); + let define = self.parse_define_map(&expr)?; + if !cli.define { + self.ctx.args.define = Some(define); + } } if let Some(expr) = json.get(b"origin") { @@ -723,9 +734,9 @@ impl<'a> Parser<'a> { } if let Some(auto_install_expr) = install_obj.get(b"auto") { - if let ExprData::EString(_) = &auto_install_expr.data { + let auto_install = if let ExprData::EString(_) = &auto_install_expr.data { let key = auto_install_expr.as_string(self.bump).unwrap_or(b""); - self.ctx.debug.global_cache = match GlobalCache::MAP.get(key) { + match GlobalCache::MAP.get(key) { Some(v) => *v, None => { self.add_error( @@ -734,19 +745,22 @@ impl<'a> Parser<'a> { )?; return Ok(()); } - }; + } } else if let ExprData::EBoolean(b) = auto_install_expr.data { - self.ctx.debug.global_cache = if b.value { + if b.value { GlobalCache::allow_install } else { GlobalCache::disable - }; + } } else { self.add_error( auto_install_expr.loc, b"Invalid auto install setting, must be one of true, false, or \"force\" \"fallback\" \"disable\"", )?; return Ok(()); + }; + if !cli.auto_install { + self.ctx.debug.global_cache = auto_install; } } @@ -827,13 +841,15 @@ impl<'a> Parser<'a> { if let Some(console_expr) = json.get(b"console") { if let Some(depth) = console_expr.get(b"depth") { if let Some(n) = depth.as_number() { - let depth_value = n as u16; - // Treat depth=0 as maxInt(u16) for infinite depth - self.ctx.runtime_options.console_depth = Some(if depth_value == 0 { - u16::MAX - } else { - depth_value - }); + if !cli.console_depth { + let depth_value = n as u16; + // Treat depth=0 as maxInt(u16) for infinite depth + self.ctx.runtime_options.console_depth = Some(if depth_value == 0 { + u16::MAX + } else { + depth_value + }); + } } else { self.add_error(depth.loc, b"Expected number")?; } @@ -987,16 +1003,18 @@ impl<'a> Parser<'a> { } { if let Some(jsx) = self.ctx.args.jsx.as_mut() { - if !jsx_factory.is_empty() { + if !jsx_factory.is_empty() && !cli.jsx_factory { jsx.factory = jsx_factory; } - if !jsx_fragment.is_empty() { + if !jsx_fragment.is_empty() && !cli.jsx_fragment { jsx.fragment = jsx_fragment; } - if !jsx_import_source.is_empty() { + if !jsx_import_source.is_empty() && !cli.jsx_import_source { jsx.import_source = jsx_import_source; } - jsx.runtime = jsx_runtime; + if !cli.jsx_runtime { + jsx.runtime = jsx_runtime; + } jsx.development = jsx_dev; } else { self.ctx.args.jsx = Some(api::Jsx { @@ -1020,12 +1038,14 @@ impl<'a> Parser<'a> { if let Some(expr) = json.get(b"macros") { if let ExprData::EBoolean(b) = expr.data { - if !b.value { + if !b.value && !cli.macros { self.ctx.debug.macros = MacroOptions::Disable; } } else { - self.ctx.debug.macros = - MacroOptions::Map(parse_macros_json(&expr, self.log, self.source, self.bump)); + let remaps = parse_macros_json(&expr, self.log, self.source, self.bump); + if !cli.macros { + self.ctx.debug.macros = MacroOptions::Map(remaps); + } } bun_analytics::features::macros.fetch_add(1, Ordering::Relaxed); } @@ -1084,10 +1104,12 @@ impl<'a> Parser<'a> { loader_names.push(key.into()); loader_values.push(loader.to_api()); } - self.ctx.args.loaders = Some(api::LoaderMap { - extensions: loader_names, - loaders: loader_values, - }); + if !cli.loaders { + self.ctx.args.loaders = Some(api::LoaderMap { + extensions: loader_names, + loaders: loader_values, + }); + } } Ok(()) diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 4190731b8ed8..ff0e8b338c61 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -44,6 +44,36 @@ pub struct ContextData { pub preloads: Vec>, pub has_loaded_global_config: bool, + pub cli_overrides: CliOverrides, +} + +/// Settings that were given on the command line. +/// +/// bunfig.toml is usually parsed before argv is applied, so argv simply +/// overwrites it. `bun run `, the `node` shim, `bun repl` and +/// standalone executables only load it afterwards (the `loaded_bunfig` +/// checks in run_command.rs and repl_command.rs); the bunfig parser skips the +/// keys recorded here so the command line wins in that order too. +#[derive(Clone, Copy, Default)] +pub struct CliOverrides { + /// `--define` + pub define: bool, + /// `--loader` + pub loaders: bool, + /// `--jsx-runtime` + pub jsx_runtime: bool, + /// `--jsx-factory` + pub jsx_factory: bool, + /// `--jsx-fragment` + pub jsx_fragment: bool, + /// `--jsx-import-source` + pub jsx_import_source: bool, + /// `--console-depth` + pub console_depth: bool, + /// `--install`, `-i` or `--no-install` + pub auto_install: bool, + /// `--no-macros` + pub macros: bool, } impl Default for ContextData { @@ -84,6 +114,7 @@ impl Default for ContextData { no_exit_on_error: false, preloads: Vec::new(), has_loaded_global_config: false, + cli_overrides: CliOverrides::default(), } } } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 486f92a04d5b..b2312853c974 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -926,6 +926,7 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result) -> crate::Result) -> crate::Result) -> crate::Result) -> crate::Result) -> crate::Result) -> crate::Result::from); diff --git a/test/config/bunfig/cli-flags-override-bunfig.test.ts b/test/config/bunfig/cli-flags-override-bunfig.test.ts new file mode 100644 index 000000000000..33b613c88b86 --- /dev/null +++ b/test/config/bunfig/cli-flags-override-bunfig.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; + +// `bun ` parses bunfig.toml before it applies argv; `bun run ` (and +// everything else that boots through RunCommand) parses it after argv. A key +// with a command-line counterpart has to lose to the flag in both orders and +// still apply when the flag is absent. + +async function run(cwd: string, args: string[], env: Record = bunEnv) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +type Expected = { stdout: unknown; stderr: unknown; exitCode: number }; +const ok = (stdout: string): Expected => ({ stdout, stderr: "", exitCode: 0 }); + +// An import source for the automatic runtime, so nothing is fetched from a +// registry. The dev and prod entry points report the same thing so the tests do +// not depend on how `development` is derived. +function jsxImportSource(name: string, output: string) { + return { + [`node_modules/${name}/package.json`]: JSON.stringify({ name }), + [`node_modules/${name}/jsx-runtime/index.js`]: `exports.jsx = exports.jsxs = () => ${JSON.stringify(output)};`, + [`node_modules/${name}/jsx-dev-runtime/index.js`]: `exports.jsxDEV = () => ${JSON.stringify(output)};`, + }; +} + +const settings: { + name: string; + files: Record; + entry: string; + flags: string[]; + withFlag: Expected; + withoutFlag: Expected; +}[] = [ + { + name: "[define] / --define", + files: { + "bunfig.toml": `[define]\nTAG = '"bunfig"'\n`, + "index.js": `console.log(TAG);\n`, + }, + entry: "index.js", + flags: ["--define", 'TAG="cli"'], + withFlag: ok("cli\n"), + withoutFlag: ok("bunfig\n"), + }, + { + name: "[loader] / --loader", + files: { + "bunfig.toml": `[loader]\n".data" = "text"\n`, + "payload.data": `{"loaded": true}`, + "index.js": `import payload from "./payload.data";\nconsole.log(typeof payload);\n`, + }, + entry: "index.js", + flags: ["--loader", ".data:json"], + withFlag: ok("object\n"), + withoutFlag: ok("string\n"), + }, + { + name: "[console] depth / --console-depth", + files: { + "bunfig.toml": `[console]\ndepth = 1\n`, + "index.js": `console.log({ a: { b: { c: 1 } } });\n`, + }, + entry: "index.js", + flags: ["--console-depth", "5"], + withFlag: ok("{\n a: {\n b: {\n c: 1,\n },\n },\n}\n"), + withoutFlag: ok("{\n a: {\n b: [Object ...],\n },\n}\n"), + }, + { + name: "jsx / --jsx-runtime", + files: { + ...jsxImportSource("react", "runtime:bunfig"), + "bunfig.toml": `jsx = "react-jsx"\n`, + "index.jsx": `globalThis.React = { createElement: () => "runtime:cli" };\nconsole.log(
);\n`, + }, + entry: "index.jsx", + flags: ["--jsx-runtime", "classic"], + withFlag: ok("runtime:cli\n"), + withoutFlag: ok("runtime:bunfig\n"), + }, + { + // The runtime still comes from bunfig.toml here; only the factory is + // taken from the command line. + name: "jsxFactory / --jsx-factory", + files: { + "bunfig.toml": `jsx = "react"\njsxFactory = "bunfigFactory"\n`, + "index.jsx": [ + `globalThis.bunfigFactory = () => "factory:bunfig";`, + `globalThis.cliFactory = () => "factory:cli";`, + `console.log(
);`, + ``, + ].join("\n"), + }, + entry: "index.jsx", + flags: ["--jsx-factory", "cliFactory"], + withFlag: ok("factory:cli\n"), + withoutFlag: ok("factory:bunfig\n"), + }, + { + name: "jsxFragment / --jsx-fragment", + files: { + "bunfig.toml": `jsx = "react"\njsxFragment = "BunfigFragment"\n`, + "index.jsx": [ + `globalThis.React = { createElement: fragment => "fragment:" + fragment };`, + `globalThis.BunfigFragment = "bunfig";`, + `globalThis.CliFragment = "cli";`, + `console.log(<>);`, + ``, + ].join("\n"), + }, + entry: "index.jsx", + flags: ["--jsx-fragment", "CliFragment"], + withFlag: ok("fragment:cli\n"), + withoutFlag: ok("fragment:bunfig\n"), + }, + { + name: "jsxImportSource / --jsx-import-source", + files: { + ...jsxImportSource("bunfig-source", "source:bunfig"), + ...jsxImportSource("cli-source", "source:cli"), + "bunfig.toml": `jsx = "react-jsx"\njsxImportSource = "bunfig-source"\n`, + "index.jsx": `console.log(
);\n`, + }, + entry: "index.jsx", + flags: ["--jsx-import-source", "cli-source"], + withFlag: ok("source:cli\n"), + withoutFlag: ok("source:bunfig\n"), + }, + { + // Any `[macros]` remap table turns macros back on. + name: "[macros] / --no-macros", + files: { + "bunfig.toml": `[macros]\n"some-package" = { "value" = "./macro.ts" }\n`, + "macro.ts": `export function value() {\n return "ran";\n}\n`, + "index.ts": `import { value } from "./macro.ts" with { type: "macro" };\nconsole.log("macro:" + value());\n`, + }, + entry: "index.ts", + flags: ["--no-macros"], + withFlag: { stdout: "", stderr: expect.stringContaining("error: Macros are disabled"), exitCode: 1 }, + // Debug builds log "[macro] call value" to stdout before the script runs. + withoutFlag: { stdout: expect.stringMatching(/(^|\n)macro:ran\n$/), stderr: "", exitCode: 0 }, + }, +]; + +describe.each(settings)("$name", ({ files, entry, flags, withFlag, withoutFlag }) => { + test.concurrent("bun : the flag wins", async () => { + using dir = tempDir("bunfig-cli-early", files); + expect(await run(String(dir), [...flags, entry])).toEqual(withFlag); + }); + + test.concurrent("bun run : the flag still wins when bunfig.toml is parsed after argv", async () => { + using dir = tempDir("bunfig-cli-late", files); + expect(await run(String(dir), ["run", ...flags, entry])).toEqual(withFlag); + }); + + test.concurrent("bun run : bunfig.toml applies when the flag is absent", async () => { + using dir = tempDir("bunfig-only", files); + expect(await run(String(dir), ["run", entry])).toEqual(withoutFlag); + }); +}); + +describe("[install] auto / --install", () => { + // Auto-install is observable as a manifest request, so point bunfig.toml at + // a registry stub that only records what was asked for. + function registryStub() { + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(req) { + requests.push(new URL(req.url).pathname); + return new Response("not found", { status: 404 }); + }, + }); + return { requests, server, [Symbol.dispose]: () => server.stop(true) }; + } + + function project(auto: string, registryPort: number) { + return tempDir("bunfig-install-auto", { + "bunfig.toml": `[install]\nauto = ${auto}\nregistry = "http://localhost:${registryPort}/"\n`, + "index.js": `import "package-that-is-not-installed";\n`, + }); + } + + async function runProject(dir: string, args: string[]) { + const result = await run(dir, args, { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") }); + expect(result.stderr).toContain("Cannot find package 'package-that-is-not-installed'"); + expect(result.exitCode).toBe(1); + } + + test.concurrent.each(["--no-install index.js", "run --no-install index.js"])( + 'bun %s: --no-install wins over auto = "fallback"', + async args => { + using registry = registryStub(); + using dir = project(`"fallback"`, registry.server.port); + await runProject(String(dir), args.split(" ")); + expect(registry.requests).toEqual([]); + }, + ); + + test.concurrent('bun run : auto = "fallback" applies when no flag is given', async () => { + using registry = registryStub(); + using dir = project(`"fallback"`, registry.server.port); + await runProject(String(dir), ["run", "index.js"]); + expect(registry.requests).toEqual(["/package-that-is-not-installed"]); + }); + + test.concurrent("bun run -i : -i wins over auto = false", async () => { + using registry = registryStub(); + using dir = project("false", registry.server.port); + await runProject(String(dir), ["run", "-i", "index.js"]); + expect(registry.requests).toEqual(["/package-that-is-not-installed"]); + }); +}); diff --git a/test/config/bunfig/preload.test.ts b/test/config/bunfig/preload.test.ts index 44daf6c91deb..06ec196c55e9 100644 --- a/test/config/bunfig/preload.test.ts +++ b/test/config/bunfig/preload.test.ts @@ -1,5 +1,5 @@ import type { SpawnOptions } from "bun"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, fakeNodeRun } from "harness"; import { join, resolve } from "path"; const fixturePath = (...segs: string[]) => resolve(import.meta.dirname, "fixtures", "preload", ...segs); @@ -91,17 +91,25 @@ describe("Given a `bunfig.toml` with a list of preloads", () => { // "--preload ./preload3.ts", "--preload=./preload3.ts", - // FIXME: Tests are failing due to active bugs + // `bun run` loads bunfig.toml after argv has been applied. + "--preload=./preload3.ts run", + "run --preload ./preload3.ts", + "run --preload=./preload3.ts", + // FIXME: `bun --preload ./preload3.ts run cli-merge.ts` takes `./preload3.ts` for the subcommand + // name, so it runs as `bun run` with no target and only prints the usage text. // "--preload ./preload3.ts run", - // "--preload=./preload3.ts run", - // "run --preload ./preload3.ts", - // "run --preload=./preload3.ts", ])("When `bun %s cli-merge.ts` is run, `--preload` adds the target file to the list of preloads", async args => { const [out, err, code] = await run("cli-merge.ts", { args: args.split(" "), cwd: dir }); expect(err).toBeEmpty(); expect(out).toBeEmpty(); expect(code).toBe(0); }); + + // The `node` shim loads bunfig.toml the same way `bun run` does. + it("When run as `node -r ./preload3.ts cli-merge.ts`, the preload is added after the ones from bunfig.toml", () => { + // fakeNodeRun throws, with the assertion message from cli-merge.ts, when the list is wrong. + expect(fakeNodeRun(dir, ["-r", "./preload3.ts", "cli-merge.ts"])).toEqual({ stdout: "", stderr: "" }); + }); }); // describe("Given a `bunfig.toml` with a plugin preload", () => {