diff --git a/docs/bundler/macros.mdx b/docs/bundler/macros.mdx index c4becbccb82a..7186833559c1 100644 --- a/docs/bundler/macros.mdx +++ b/docs/bundler/macros.mdx @@ -210,7 +210,7 @@ export function howLong() { } ``` -This outputs: +With the minify syntax option enabled, this outputs: ```js function howLong() { @@ -219,6 +219,8 @@ function howLong() { export { howLong }; ``` +Bun accepts a `const` as a macro argument when you declare the `const` above the call and its initializer is a number, a string, a boolean, `null`, `undefined`, or a macro call that returns one of these. Inside the argument, Bun also joins these values in a template literal or with `+`. Write the expression inside the argument and not in the initializer of a `const`. Bun does not yet join a piece that contains a non-ASCII character. To pass an object or an array that another macro returns, call that macro inside the argument. + ## Examples ### Embed latest git commit hash diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 47fb760609ac..eb7051f78ced 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -707,7 +707,7 @@ pub struct VisitArgsOpts<'a> { #[derive(Clone, Copy)] pub struct VisitDeclOpts { pub(crate) was_anonymous_named_expr: bool, - pub(crate) could_be_const_value: bool, + pub(crate) was_const: bool, pub(crate) could_be_macro: bool, } @@ -1762,12 +1762,43 @@ pub type ImportItemForNamespaceMap = StringArrayHashMap; pub struct MacroState<'a> { pub(crate) refs: MacroRefs<'a>, + /// The const table of macro arguments. `visit_macro_arguments` swaps it with `const_values`. + pub(crate) consts: bun_ast::ast_result::ConstValuesMap, + /// Inside that swap, where `features.inlining` is forced on. + pub(crate) in_args: bool, + /// `features.inlining` before the swap. + pub(crate) inlining_outside_args: bool, } impl<'a> MacroState<'a> { pub(crate) fn init() -> MacroState<'a> { MacroState { refs: MacroRefs::default(), + consts: Default::default(), + in_args: false, + inlining_outside_args: false, + } + } + + /// Inside the swap the two tables trade places. Out of line: one copy for every `P`. + #[inline(never)] + pub(crate) fn put_const( + &mut self, + const_values: &mut bun_ast::ast_result::ConstValuesMap, + r#ref: Ref, + for_inliner: Option, + for_macro_args: Option, + ) { + let (inliner, macro_args) = if self.in_args { + (&mut self.consts, const_values) + } else { + (const_values, &mut self.consts) + }; + if let Some(value) = for_inliner { + inliner.put(r#ref, value).expect("oom"); + } + if let Some(value) = for_macro_args { + macro_args.put(r#ref, value).expect("oom"); } } } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index f98cdc96e5dd..0ec84b2758fa 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -27,6 +27,7 @@ use bun_ast::{ Stmt, StmtData, Symbol, }; use bun_collections::VecExt; +use bun_core::strings; // `parser::SideEffects` is a stub enum without the assoc fns; the real // `should_keep_stmt_in_dead_control_flow` lives on `ast::side_effects::SideEffects`. use crate::scan::scan_side_effects::SideEffects; @@ -588,12 +589,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> } } - let is_after = self.vis_scope().is_after_const_local_prefix; self.visit_decl( decl, VisitDeclOpts { was_anonymous_named_expr, - could_be_const_value: was_const && !is_after, + was_const, could_be_macro: if Self::ALLOW_MACROS { prev_macro_call_count != self.macro_call_count } else { @@ -616,12 +616,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // which is not mutated during the visit pass. let replacer = _ptr.get(); if !self.replace_decl_and_possibly_remove(decl, replacer) { - let is_after = self.vis_scope().is_after_const_local_prefix; self.visit_decl( decl, VisitDeclOpts { was_anonymous_named_expr: false, - could_be_const_value: was_const && !is_after, + was_const, could_be_macro: false, }, ); @@ -649,7 +648,119 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> j } - pub(crate) fn visit_binding_and_expr_for_macro(&mut self, binding: Binding, expr: Expr) { + #[inline] + fn imports_macro(&self) -> bool { + Self::ALLOW_MACROS && self.macro_.refs.count() > 0 + } + + fn inlining_outside_macro_args(&self) -> bool { + if self.macro_.in_args { + self.macro_.inlining_outside_args + } else { + self.options.features.inlining + } + } + + /// What a macro argument gets for a `const`: a primitive, or a string as one piece. + fn macro_argument_value(&mut self, value: Expr) -> Option { + match value.data { + ExprData::ENumber(_) + | ExprData::EBoolean(_) + | ExprData::EBranchBoolean(_) + | ExprData::ENull(_) + | ExprData::EUndefined(_) + | ExprData::EInlinedEnum(_) => Some(value), + ExprData::EString(string) => match string.flattened(self.arena) { + E::Flattened::Borrowed(_) => Some(value), + E::Flattened::Owned(flat) => Some(self.new_expr(flat, value.loc)), + }, + _ => None, + } + } + + /// A macro's string result is UTF-16, which no fold joins. An ASCII one gets an 8-bit copy. + pub(crate) fn macro_string_for_join(&mut self, expr: Expr) -> Expr { + if let ExprData::EString(string) = expr.data { + if string.is_utf16 && strings::first_non_ascii16(string.slice16()).is_none() { + let units = string.slice16(); + let ascii = self.arena.alloc_slice_fill_default::(units.len()); + strings::copy_u16_into_u8(ascii, units); + return self.new_expr(E::EString::init(ascii), expr.loc); + } + } + expr + } + + /// `inlinable`: the value also goes to the inliner's table, by the rule of `const_values`. + fn put_const_value_for_macros( + &mut self, + r#ref: Ref, + value: Expr, + inlinable: bool, + is_const: bool, + ) { + let mirror = inlinable && self.inlining_outside_macro_args(); + let for_macro_args = if mirror { + // The arguments of a macro call see what the inliner puts in ordinary code. + Some(value) + } else if is_const { + self.macro_argument_value(value) + } else { + None + }; + self.macro_.put_const( + &mut self.const_values, + r#ref, + inlinable.then_some(value), + for_macro_args, + ); + } + + /// `visit_decl` for an identifier binding in a file that imports a macro. + #[cold] + fn record_const_value_for_macros( + &mut self, + r#ref: Ref, + value: Option, + was_const: bool, + could_be_macro: bool, + ) { + let Some(value) = value else { return }; + // The inliner takes a `const` of the leading declaration run, and any macro result. + let inlinable = (could_be_macro || !self.vis_scope().is_after_const_local_prefix) + && value.can_be_const_value(); + self.put_const_value_for_macros(r#ref, value, inlinable, was_const); + } + + /// A destructured binding of a macro result. The inliner takes it only under inlining. + fn record_macro_result( + &mut self, + r#ref: Ref, + mut value: Expr, + is_const: bool, + default: Option, + ) { + // An `undefined` gives the binding its default. Only a `const` keeps that value. + if let (Some(default), ExprData::EUndefined(_)) = (default, value.data) { + if !is_const { + return; + } + let Some(default) = self.macro_argument_value(default) else { + return; + }; + value = default; + } + let inlinable = self.inlining_outside_macro_args(); + self.put_const_value_for_macros(r#ref, value, inlinable, is_const); + } + + pub(crate) fn visit_binding_and_expr_for_macro( + &mut self, + binding: Binding, + expr: Expr, + is_const: bool, + default: Option, + ) { match binding.data { BData::BObject(bound_object) => { let bound_object = bound_object.get(); @@ -669,16 +780,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> self.visit_binding_and_expr_for_macro( property.value, query.expr, + is_const, + property.default_value, ); } _ => { - if self.options.features.inlining { - if let BData::BIdentifier(id) = property.value.data - { - self.const_values - .put(id.r#ref, query.expr) - .expect("oom"); - } + if let BData::BIdentifier(id) = property.value.data { + self.record_macro_result( + id.r#ref, + query.expr, + is_const, + property.default_value, + ); } } } @@ -710,16 +823,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> *child_expr = self.new_expr(E::Missing {}, expr.loc); continue; } - self.visit_binding_and_expr_for_macro(item.binding, *child_expr); + self.visit_binding_and_expr_for_macro( + item.binding, + *child_expr, + is_const, + item.default_value, + ); } } } } - BData::BIdentifier(id) => { - if self.options.features.inlining { - self.const_values.put(id.r#ref, expr).expect("oom"); - } - } + BData::BIdentifier(id) => self.record_macro_result(id.r#ref, expr, is_const, default), BData::BMissing(_) => {} } } @@ -727,17 +841,27 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> pub(crate) fn visit_decl(&mut self, decl: &mut G::Decl, opts: VisitDeclOpts) { let VisitDeclOpts { was_anonymous_named_expr, - could_be_const_value, + was_const, could_be_macro, } = opts; // Optionally preserve the name match decl.binding.data { BData::BIdentifier(id) => { let id_ref = id.r#ref; - if could_be_const_value || (Self::ALLOW_MACROS && could_be_macro) { - if let Some(val) = decl.value { - if val.can_be_const_value() { - self.const_values.put(id_ref, val).expect("oom"); + if was_const || (Self::ALLOW_MACROS && could_be_macro) { + if self.imports_macro() { + self.record_const_value_for_macros( + id_ref, + decl.value, + was_const, + could_be_macro, + ); + } else if !self.vis_scope().is_after_const_local_prefix { + // Past the leading declaration run, a use can run before the initializer. + if let Some(val) = decl.value { + if val.can_be_const_value() { + self.const_values.put(id_ref, val).expect("oom"); + } } } } else { @@ -756,7 +880,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> BData::BObject(_) | BData::BArray(_) => { if Self::ALLOW_MACROS { if could_be_macro && let Some(value) = decl.value { - self.visit_binding_and_expr_for_macro(decl.binding, value); + self.visit_binding_and_expr_for_macro(decl.binding, value, was_const, None); } } } diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index edaeed0c257d..82cb56f35b60 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -451,6 +451,10 @@ impl BinaryExpressionVisitor { } Op::Code::BinAdd => { if p.should_fold_typescript_constant_expressions { + if p.macro_.in_args { + e_.left = p.macro_string_for_join(e_.left); + e_.right = p.macro_string_for_join(e_.right); + } if let Some(vals) = Expr::extract_numeric_values(&e_.left.data, &e_.right.data) { return p.new_expr(E::Number::new(vals[0] + vals[1]), v.loc); diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 59748411dabf..311af080d1f5 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -769,6 +769,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // it may no longer be a template literal after this point (it may turn into // a plain string literal instead). if p.should_fold_typescript_constant_expressions || p.options.features.inlining { + if p.macro_.in_args { + for part in e_.parts_mut().iter_mut() { + part.value = p.macro_string_for_join(part.value); + } + } *e = e_.fold(p.arena, expr.loc); return; } @@ -1871,6 +1876,38 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> p.should_fold_typescript_constant_expressions = prev_should_fold_typescript_constant_expressions; } + /// Visits `args` with `macro_.consts` as the inliner's table. False: nothing was visited. + #[cold] + #[inline(never)] + fn visit_macro_arguments(p: &mut Self, args: &mut [Expr]) -> bool { + // The macro runtime keeps the call as written. A nested macro call is inside the outer swap. + if p.options.features.is_macro_runtime || p.macro_.in_args { + return false; + } + // A `with` object can shadow the `const`. + let mut scope = Some(p.current_scope); + while let Some(s) = scope { + if s.kind == js_ast::scope::Kind::With { + return false; + } + scope = s.parent; + } + + p.macro_.inlining_outside_args = p.options.features.inlining; + core::mem::swap(&mut p.const_values, &mut p.macro_.consts); + p.macro_.in_args = true; + p.options.features.inlining = true; + + for arg in args { + p.visit_expr(arg); + } + + p.options.features.inlining = p.macro_.inlining_outside_args; + p.macro_.in_args = false; + core::mem::swap(&mut p.const_values, &mut p.macro_.consts); + true + } + fn e_call(p: &mut Self, e: &mut Expr, in_: ExprIn) { let expr = *e; let mut e_ = expr.data.e_call().expect("infallible: variant checked"); @@ -2097,21 +2134,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> p.should_fold_typescript_constant_expressions; let old_is_control_flow_dead = p.is_control_flow_dead; - // We want to forcefully fold constants inside of - // certain calls even when minification is disabled, so - // that if we have an import based on a string template, - // it does not cause a bundle error. This is relevant for - // macros, as they require constant known values, but also - // for `require` and `require.resolve`, as they go through - // the module resolver. - if is_macro_ref - || matches!(e_.target.data, Data::ERequireCallTarget) - || matches!(e_.target.data, Data::ERequireResolveCallTarget) - { - p.options.ignore_dce_annotations = true; - p.should_fold_typescript_constant_expressions = true; - } - // When a value is targeted by `--drop`, it will be removed. // The HMR APIs in `import.meta.hot` are implicitly dropped when HMR is disabled. let mut method_call_should_be_replaced_with_undefined = @@ -2133,8 +2155,26 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> } } - for arg in e_.args.slice_mut() { - p.visit_expr(arg); + // We want to forcefully fold constants inside of + // certain calls even when minification is disabled, so + // that if we have an import based on a string template, + // it does not cause a bundle error. This is relevant for + // macros, as they require constant known values, but also + // for `require` and `require.resolve`, as they go through + // the module resolver. + if is_macro_ref + || matches!(e_.target.data, Data::ERequireCallTarget) + || matches!(e_.target.data, Data::ERequireResolveCallTarget) + { + p.options.ignore_dce_annotations = true; + p.should_fold_typescript_constant_expressions = true; + } + + // A call that runs a macro visits its arguments with the macro's const table. + if !(is_macro_ref && Self::visit_macro_arguments(p, e_.args.slice_mut())) { + for arg in e_.args.slice_mut() { + p.visit_expr(arg); + } } // Restore saved state. @@ -2532,9 +2572,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> } // Check if the feature flag is enabled - // Use the underlying string data directly without allocation. // Feature flag names should be ASCII identifiers, so UTF-16 is unexpected. - let flag_string = arg.data.e_string().expect("infallible: variant checked"); + let mut flag_string = arg.data.e_string().expect("infallible: variant checked"); + flag_string.resolve_rope_if_needed(p.arena); if flag_string.is_utf16 { p.log().add_error( Some(p.source), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ff170d4c8c38..ac5013679cef 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -64,7 +64,8 @@ bun_core::declare_scope!(cache, visible); /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. -const EXPECTED_VERSION: u32 = 34; +/// Version 35: `feature()` reads all of a flag name that a fold joined from pieces. +const EXPECTED_VERSION: u32 = 35; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/bundler_feature_flag.test.ts b/test/bundler/bundler_feature_flag.test.ts index 327763902a63..d5e1fb1c34d1 100644 --- a/test/bundler/bundler_feature_flag.test.ts +++ b/test/bundler/bundler_feature_flag.test.ts @@ -88,6 +88,28 @@ if (feature("DISABLED_FEATURE")) { }, }); + // minifySyntax folds "ENABLED_" + "FEATURE" before feature() sees it; the + // lookup must use the whole folded name, not its first segment. + itBundled(`feature_flag/${backend}/FoldedFlagName`, { + backend, + files: { + "/a.js": ` +import { feature } from "bun:bundle"; +if (feature("ENABLED_" + "FEATURE")) { + console.log("this should be kept"); +} else { + console.log("this should be removed"); +} +`, + }, + features: ["ENABLED_FEATURE"], + minifySyntax: true, + onAfterBundle(api) { + api.expectFile("out.js").toInclude("this should be kept"); + api.expectFile("out.js").not.toInclude("this should be removed"); + }, + }); + itBundled(`feature_flag/${backend}/ImportRemoved`, { backend, files: { @@ -477,6 +499,37 @@ if (feature("RUNTIME_FLAG")) { expect(exitCode2).toBe(0); }); + // The runtime transpiler folds "ENABLED_" + "FEATURE" too, so it must read the whole name. + test("reads a folded flag name whole at runtime with bun run", async () => { + using dir = tempDir("bundler-feature-flag", { + "index.ts": ` +import { feature } from "bun:bundle"; + +if (feature("ENABLED_" + "FEATURE")) { + console.log("folded flag enabled"); +} else { + console.log("folded flag disabled"); +} +`, + }); + + async function run(flag: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", `--feature=${flag}`, "./index.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + const [whole, firstSegment] = await Promise.all([run("ENABLED_FEATURE"), run("ENABLED_")]); + expect(whole).toMatchObject({ stdout: "folded flag enabled", exitCode: 0 }); + expect(firstSegment).toMatchObject({ stdout: "folded flag disabled", exitCode: 0 }); + }); + test("works correctly in bun test", async () => { using dir = tempDir("bundler-feature-flag", { "test.test.ts": ` diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 4b5254346695..6a41b18e041f 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -130,13 +130,13 @@ test("namespace import", () => { expect(macros.escape()).toBe("\\\f\n\r\t\v\0'\"`$\x00\x0B\x0C"); }); -// test("template string ascii", () => { -// expect(identity(`A${""}`)).toBe("A"); -// }); +test("template string ascii", () => { + expect(identity(`A${""}`)).toBe("A"); +}); -// test("template string latin1", () => { -// expect(identity(`©${""}`)).toBe("©"); -// }); +// A macro runs when this file is transpiled, so a call that fails cannot stand in a test body. +// identity(`©${""}`) needs the join of a non-ASCII piece: #42019. +test.todo("template string latin1"); test("ireturnapromise", async () => { expect(await ireturnapromise()).toEqual("aaa"); @@ -625,3 +625,473 @@ describe("--no-macros", () => { expect(existsSync(path.join(String(dir), "MACRO_RAN"))).toBe(true); }); }); + +// docs/bundler/macros.mdx, "Arguments": a macro argument may be a constant or the result of +// another macro. The runtime transpiler and `--minify-syntax` inline constants, plain +// `bun build` does not, so each mode takes a different path to the same answer. +describe("constant arguments", () => { + const macroFile = ` + export function id(x) { return x; } + export function getFoo() { return "foo"; } + export function getObj() { return { a: 1, b: "two" }; } + export function getUndefined() { return { a: undefined, list: [undefined, 2] }; } + `; + const header = `import { id, getFoo, getObj, getUndefined } from "./m.ts" with { type: "macro" };\n`; + const identifierError = '"Cannot convert identifier to JS. Try a statically-known value" error in macro'; + + async function bun(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // The three ways a file reaches the parser. Most cases load the file in this process, + // because a debug build needs about a second to start. They run one at a time: together + // they share the bundler threads, and each case then takes as long as the whole group. + const modes = [ + { mode: "the runtime transpiler", minifySyntax: null }, + { mode: "Bun.build", minifySyntax: false }, + { mode: "Bun.build with minify.syntax", minifySyntax: true }, + ]; + + // What the entry exports as `result`, or the errors of the build. + async function resultOf( + entry: string, + minifySyntax: boolean | null, + files: Record = {}, + entryFile = "entry.ts", + features: string[] = [], + ) { + using dir = tempDir("macro-constant-arguments", { "m.ts": macroFile, [entryFile]: entry, ...files }); + let file = path.join(String(dir), entryFile); + if (minifySyntax !== null) { + const built = await Bun.build({ + entrypoints: [file], + target: "bun", + minify: { syntax: minifySyntax }, + outdir: path.join(String(dir), "out"), + features, + throw: false, + }); + if (!built.success) return { errors: built.logs.map(log => log.message) }; + file = built.outputs[0].path; + } + return { result: (await import(file)).result }; + } + + // The same three ways through the command line. A build prints the module, a run prints its output. + const builtLine = 'console.log(JSON.stringify([40, "https://example.com/foo"]));'; + const cliModes = [ + { mode: "bun run", args: ["run"], line: '[40,"https://example.com/foo"]' }, + { mode: "bun build", args: ["build", "--target=bun"], line: builtLine }, + { mode: "bun build --minify-syntax", args: ["build", "--target=bun", "--minify-syntax"], line: builtLine }, + ]; + + const accepted = ` + import { fromOther } from "./other.ts"; + import * as ns from "./m.ts" with { type: "macro" }; + const out = []; + const lead = 5; + out.push(id(lead)); + const fromMacro = getFoo(); + out.push(id(fromMacro), id({ test: { animationName: fromMacro } })); + const { a } = getObj(); + out.push(id(a)); + console.log("a statement"); + const N = 5; + out.push(id(N), id(N + 1), id({ a: N, b: [N] })); + out.push(id(id(N)), id([id(N), id({ N })])); + const foo = getFoo(); + out.push(id(foo), id(\`https://example.com/\${foo}\`), id("a/" + foo)); + out.push(id("a/" + getFoo()), id(\`x\${getFoo()}y\`), id("a/" + getObj().b)); + out.push(id(foo + "/x"), id(getFoo() + "/y")); + const n = 42; + out.push(id(\`n=\${n}\`)); + // Each kind of value, and a call through a namespace import. + enum E { A = 1, B = "bee" } + const NEG = -5, YES = true, NIL = null, UNDEF = undefined, EA = E.A, EB = E.B; + out.push(id(NEG), id(YES), id(NIL), id(UNDEF) === undefined, id(EA), id(\`e/\${EB}\`)); + out.push(id(\`\${YES}/\${NIL}/\${UNDEF}\`), ns.id(N), ns.id(\`u/\${ns.getFoo()}\`)); + { + const M = 7; + out.push(id(N + M)); + } + function inFunction() { + console.log("a statement"); + const data = 40; + return id(data); + } + out.push(inFunction()); + // A const that a macro argument and ordinary code both read. + function mixed() { + const K = 5; + const q = id(K); + console.log("a statement"); + const L = 6; + return [q, K, id(L), L, L]; + } + out.push(mixed(), fromOther); + // A declaration in an argument that folds away. + out.push(id(typeof (() => { console.log("a statement"); const y = 5; return id(y); }))); + export const result = JSON.stringify(out); + `; + const acceptedFiles = { + "other.ts": ` + console.log("a statement"); + const K = 9; + import { id } from "./m.ts" with { type: "macro" }; + export const fromOther = [id(K), K]; + `, + }; + const acceptedOutput = JSON.stringify([ + 5, + "foo", + { test: { animationName: "foo" } }, + 1, + 5, + 6, + { a: 5, b: [5] }, + 5, + [5, { N: 5 }], + "foo", + "https://example.com/foo", + "a/foo", + "a/foo", + "xfooy", + "a/two", + "foo/x", + "foo/y", + "n=42", + -5, + true, + null, + true, + 1, + "e/bee", + "true/null/undefined", + 5, + "u/foo", + 12, + 40, + [5, 5, 6, 6, 6], + [9, 9], + "function", + ]); + + test.each(modes)("$mode accepts a const in any statement position", async ({ minifySyntax }) => { + expect(await resultOf(header + accepted, minifySyntax, acceptedFiles)).toEqual({ result: acceptedOutput }); + }); + + test.concurrent.each(cliModes)("$mode accepts a const below a statement", async ({ args, line }) => { + const entry = ` + console.log(id(40)); + const data = 40; + const foo = getFoo(); + console.log(JSON.stringify([id(data), id(\`https://example.com/\${foo}\`)])); + `; + using dir = tempDir("macro-constant-arguments-cli", { "m.ts": macroFile, "entry.ts": header + entry }); + const { stdout, stderr, exitCode } = await bun(String(dir), ...args, "entry.ts"); + // Debug builds print "[macro] call id" to stdout, so only the last line is compared. + expect({ line: stdout.trimEnd().split("\n").at(-1), stderr, exitCode }).toEqual({ line, stderr: "", exitCode: 0 }); + }); + + // Bun.Transpiler has no file, so the macro import is an absolute path here. + test("Bun.Transpiler accepts a const in any statement position", async () => { + using dir = tempDir("macro-constant-arguments-api", { "m.ts": macroFile }); + const source = (header + accepted) + .replace(`import { fromOther } from "./other.ts";`, "const fromOther = [9, 9];") + .replaceAll(`"./m.ts"`, JSON.stringify(path.join(String(dir), "m.ts"))); + const options = { default: {}, inline: { inline: true }, minify: { minify: { syntax: true } } }; + const results: unknown[] = []; + for (const [name, option] of Object.entries(options)) { + const file = path.join(String(dir), "transpiled-" + name + ".js"); + await Bun.write(file, new Bun.Transpiler({ loader: "ts", ...option }).transformSync(source)); + results.push((await import(file)).result); + } + expect(results).toEqual([acceptedOutput, acceptedOutput, acceptedOutput]); + }); + + test("the example of docs/bundler/macros.mdx builds", async () => { + using dir = tempDir("macro-constant-arguments-docs", { + "getText.ts": `export function getText(url) { return "<" + url + ">"; }`, + "getFoo.ts": `export function getFoo() { return "foo"; }`, + "index.ts": ` + import { getText } from "./getText.ts" with { type: "macro" }; + import { getFoo } from "./getFoo.ts" with { type: "macro" }; + + export function howLong() { + // this works because getFoo() is statically known + const foo = getFoo(); + const text = getText(\`https://example.com/\${foo}\`); + console.log("The page is", text.length, "characters long"); + } + `, + }); + const build = (syntax: boolean) => + Bun.build({ entrypoints: [path.join(String(dir), "index.ts")], minify: { syntax }, throw: false }); + const [plain, minified] = await Promise.all([build(false), build(true)]); + expect([plain, minified].map(built => built.logs.map(log => log.message))).toEqual([[], []]); + // The macro got the whole URL. + expect(await plain.outputs[0].text()).toContain('const text = "";'); + // The output block of the docs page. + expect(await minified.outputs[0].text()).toContain('console.log("The page is", 25, "characters long");'); + }); + + // A macro call is replaced when the file is built. It does not read the binding when the + // program runs, so the TDZ of the const does not apply to it: plain JS throws here. + test.each(modes)("$mode: an argument is a value at build time", async ({ minifySyntax }) => { + const entry = ` + const early = below(); + console.log("a statement"); + const N = 5; + function below() { return id(N); } + export const result = JSON.stringify([early]); + `; + expect(await resultOf(header + entry, minifySyntax)).toEqual({ result: "[5]" }); + }); + + // The macro runtime does not run a macro that a macro module imports. It keeps the call. + test("a call that the macro runtime keeps reads the const by name", async () => { + const files = { + "inner.ts": `export function answer(x) { return x; }`, + "outer.ts": ` + import { answer } from "./inner.ts" with { type: "macro" }; + export function show() { + console.log("a statement"); + const N = 5; + const kept = () => answer(N); + return kept.toString(); + } + `, + }; + const entry = ` + import { show } from "./outer.ts" with { type: "macro" }; + export const result = show(); + `; + expect(await resultOf(entry, null, files)).toEqual({ result: "() => answer(N)" }); + }); + + // The macro gives undefined, so the binding holds its default when the program runs. + test.each(modes)("$mode: a binding that takes its default holds the default", async ({ minifySyntax }) => { + const entry = ` + const { a = 5 } = getUndefined(); + const { list: [x = 8, y = 9] } = getUndefined(); + const { a: random = Math.random() } = getUndefined(); + const { a: object = getObj() } = getUndefined(); + const { a: joined = "x" + "y" } = getUndefined(); + let { a: changed = 5 } = getUndefined(); + changed = 9; + const own = [typeof random, random === random, object.a, object === object, changed, joined + "1", joined]; + export const result = JSON.stringify([id(a), a, id(x), x, id(y), y, ...own]); + `; + expect(await resultOf(header + entry, minifySyntax)).toEqual({ + result: '[5,5,8,8,2,2,"number",true,1,true,9,"xy1","xy"]', + }); + }); + + // A module is strict, so a var that a direct eval declares stays inside the eval. + test.each(modes)("$mode: a direct eval does not hide the const", async ({ minifySyntax }) => { + const entry = ` + function helper(s) { return eval(s); } + console.log("a statement"); + const N = 5; + const out = [id(N), helper("1 + 1")]; + { eval("0"); out.push(id(N)); } + function sameFunction() { eval("var N = 7"); return id(N); } + out.push(sameFunction()); + export const result = JSON.stringify(out); + `; + expect(await resultOf(header + entry, minifySyntax, {}, "entry.js")).toEqual({ result: "[5,2,5,5]" }); + }); + + test("a joined flag name in an argument is read whole", async () => { + const entry = ` + import { feature } from "bun:bundle"; + console.log("a statement"); + const F = "SUPER"; + export const result = id(feature(F + "_SECRET") ? 1 : 2); + `; + const [whole, firstPiece] = await Promise.all([ + resultOf(header + entry, false, {}, "entry.ts", ["SUPER_SECRET"]), + resultOf(header + entry, false, {}, "entry.ts", ["SUPER"]), + ]); + expect([whole, firstPiece]).toEqual([{ result: 1 }, { result: 2 }]); + }); + + const rejected = [ + { what: "a let binding", entry: `let N = 5; console.log(id(N));`, error: identifierError }, + { what: "a var binding", entry: `var N = 5; console.log(id(N));`, error: identifierError }, + { + what: "a let binding that holds a macro result", + entry: `let x = getFoo(); x = "bar"; console.log(id(x));`, + error: identifierError, + }, + { + what: "a const with a value that is not known", + entry: `const foo = Math.random() ? "foo" : "bar"; console.log(id(foo));`, + error: identifierError, + }, + { what: "a loop const", entry: `for (const i of [1, 2]) console.log(id(i));`, error: identifierError }, + { + what: "a const of a for initializer", + entry: `for (const i = 0; ; ) { console.log(id(i)); break; }`, + error: identifierError, + }, + { + what: "an array const with an item that is not known", + entry: `const c = [getFoo(), Math.random()]; console.log(id(c));`, + error: identifierError, + }, + // The position rule: the table holds a const only after the visit reached its declaration. + { + what: "a const that stands below the call", + entry: `const g = () => id(N); const N = 5; console.log(g());`, + error: identifierError, + }, + // The initializer is ordinary code. The table takes it only when the visit leaves a literal. + { + what: "a const that copies a const below a statement", + entry: `console.log("a statement"); const A = 5; const B = A; console.log(id(B));`, + error: identifierError, + }, + { + what: "a const that joins a const below a statement", + entry: `console.log("a statement"); const base = "https://example.com"; const url = base + "/api"; console.log(id(url));`, + error: identifierError, + }, + { + what: "a const that holds a template with a macro result", + entry: "const foo = getFoo(); const url = `https://example.com/${foo}`; console.log(id(url));", + error: identifierError, + }, + { + what: "a let binding that holds a macro result, in an argument that folds away", + entry: `console.log(id(typeof (() => { let x = getFoo(); x = "bar"; return id(x); })));`, + error: identifierError, + }, + // The program can change the object after the macro returned it. + { + what: "an object that a macro returned, by name", + entry: `const o = getObj(); o.a = 2; console.log(id(o));`, + error: identifierError, + }, + // The binding takes its default, and the build does not know that value. + { + what: "a binding that takes a default that is not known", + entry: `const { a = Math.random() } = getUndefined(); console.log(id(a));`, + error: identifierError, + }, + // The program reads a property that the object does not have from its prototype. + { + what: "a binding of a property that the macro result does not have", + entry: `const { toString = 5 } = getObj(); console.log(id(toString));`, + error: identifierError, + }, + { + what: "a const that a with object can shadow", + entry: `console.log("a statement"); const N = 5; with ({ N: 6 }) { console.log(id(N)); }`, + error: identifierError, + entryFile: "entry.js", + }, + ]; + + // With no minify option: one error, and it is the error of the row. + test.each(rejected)("Bun.build rejects $what", async ({ entry, error, entryFile }) => { + expect(await resultOf(header + entry, false, {}, entryFile)).toEqual({ errors: [error] }); + }); + + // The table that macro arguments read must not change what happens to other code in the + // same file: a const below a statement is not inlined there, so its TDZ error stays. + test("code outside the arguments keeps the rules of the inliner", async () => { + const tdz = ` + let thrown; + try { read(); thrown = "no throw"; } catch (e) { thrown = e.name; } + const N = 5; + function read() { return N; } + export const result = thrown + " " + id(N); + `; + const assignment = ` + console.log("a statement"); + const N = 5; + N = 6; + console.log(id(N)); + `; + const specifiers = ` + const x = "foo"; + console.log(id(x)); + export const p = () => import(\`./a/\${x}.js\`); + export const q = () => require(\`./b/\${x}.js\`); + export const r = () => require("./c/" + getFoo() + ".js"); + `; + using dir = tempDir("macro-constant-arguments-outside", { + "m.ts": macroFile, + "assignment.ts": header + assignment, + "specifiers.ts": header + specifiers, + }); + const [tdzResult, assignmentResult, specifiersResult] = await Promise.all([ + resultOf(header + tdz, null), + bun(String(dir), "run", "assignment.ts"), + Bun.build({ entrypoints: [path.join(String(dir), "specifiers.ts")], target: "bun", throw: false }), + ]); + expect(tdzResult).toEqual({ result: "ReferenceError 5" }); + expect(assignmentResult).toMatchObject({ + stderr: expect.stringContaining('This assignment will throw because "N" is a constant'), + exitCode: 1, + }); + expect({ success: specifiersResult.success, text: await specifiersResult.outputs[0]?.text() }).toEqual({ + success: true, + text: expect.stringMatching( + /import\(`\.\/a\/\$\{x\}\.js`\)[^]*require\(`\.\/b\/\$\{x\}\.js`\)[^]*require\("\.\/c\/" \+ "foo" \+ "\.js"\)/, + ), + }); + }); + + // Under inlining a const can hold a joined string or what a macro returned, and a function + // that an argument holds can fold away. + test.each([modes[0], modes[2]])("$mode keeps what inlining accepts", async ({ minifySyntax }) => { + const entry = ` + const base = "https://example.com"; + const url = base + "/api"; + const o = getObj(); + const { list } = getUndefined(); + console.log("a statement"); + const lateObj = getObj(); + export const result = JSON.stringify([ + id(url), id(url + "/x"), id(url + "/y"), url, + id(o), id(list), id(lateObj), + id(typeof (() => { const y = getFoo(); return id(y); })), + id(typeof function () { const K = 5; return id(K); }), + id((() => { const y = getFoo(); return id(y); }, 5)), + id(typeof (() => { console.log("a statement"); const K = 5; return id(K); })), + ]); + `; + const api = "https://example.com/api"; + const obj = { a: 1, b: "two" }; + expect(await resultOf(header + entry, minifySyntax)).toEqual({ + result: JSON.stringify([ + api, + api + "/x", + api + "/y", + api, + obj, + [null, 2], + obj, + "function", + "function", + 5, + "function", + ]), + }); + }); + + // The folds do not join a UTF-16 piece, so today each mode stops with + // "Cannot convert argument type to JS". That join belongs to the string folds: #42019. + test.todo("a template or a + with a non-ASCII piece", async () => { + const entry = ` + const s = "αβγ"; + export const result = JSON.stringify([id(\`©\${""}\`), id(\`p/\${s}\`), id("é" + s)]); + `; + const results = await Promise.all(modes.map(({ minifySyntax }) => resultOf(header + entry, minifySyntax))); + expect(results).toEqual(modes.map(() => ({ result: '["©","p/αβγ","éαβγ"]' }))); + }); +});