diff --git a/Cargo.lock b/Cargo.lock index 5cebe113107..b1b36a6808d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,7 @@ dependencies = [ name = "bun_bin" version = "0.0.0" dependencies = [ + "bstr", "bun_alloc", "bun_core", "bun_crash_handler", diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 7bdec3f8e7a..44d7197cbd3 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -707,9 +707,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let expr = *e; let _ = in_; let mut e_ = expr.data.e_template().expect("infallible: variant checked"); - if let Some(tag) = e_.tag { + if e_.tag.is_some() { p.visit_expr(e_.tag.as_mut().unwrap()); + } + + // Visit the interpolation values before the macro dispatch below: its + // early-return paths (dead code, macros disabled, node_modules, macro + // failure) replace the whole expression without visiting the parts, + // which would leave the scopes recorded during the parse pass for any + // arrows/functions inside them unconsumed and trip "Scope mismatch + // while visiting" on the next scope push. Mirrors e_call, which visits + // its arguments before macro handling. + // `Template.parts` is arena-owned (Zig: `[]E.TemplatePart`). + for part in e_.parts_mut().iter_mut() { + p.visit_expr(&mut part.value); + } + if let Some(tag) = e_.tag { if Self::ALLOW_MACROS { let ref_ = match &e_.tag.unwrap().data { Data::EImportIdentifier(ident) => Some(ident.ref_), @@ -797,11 +811,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // `Template.parts` is arena-owned (Zig: `[]E.TemplatePart`). - for part in e_.parts_mut().iter_mut() { - p.visit_expr(&mut part.value); - } - // When mangling, inline string values into the template literal. Note that // it may no longer be a template literal after this point (it may turn into // a plain string literal instead). diff --git a/test/bundler/transpiler/scope-mismatch-panic.test.ts b/test/bundler/transpiler/scope-mismatch-panic.test.ts index 0f554b408f3..a7712c98037 100644 --- a/test/bundler/transpiler/scope-mismatch-panic.test.ts +++ b/test/bundler/transpiler/scope-mismatch-panic.test.ts @@ -147,6 +147,91 @@ describe("TypeScript 'declare' statements discard scopes of dropped statements", }); }); +describe("macro tagged templates visit their interpolations", () => { + // When a tagged template's tag resolves to a macro import, the macro dispatch + // replaces the whole expression (dead code, macros disabled, or the macro call + // failing) without visiting the template parts. Scopes recorded during the parse + // pass for arrows/functions inside the interpolations were then never consumed by + // the visit pass, panicking with "Scope mismatch while visiting" on the next scope. + const macroFile = `export function mac(...args: any[]) { return "from-macro"; }`; + + test.concurrent("tagged template macro with arrow interpolation reports the macro error", async () => { + using dir = tempDir("macro-template-scope", { + "macro.ts": macroFile, + "index.ts": ` +import { mac } from './macro.ts' with { type: 'macro' }; +const r = mac\`a\${() => { let q = 1; }}b\`; +function g() { { let y = 1; } } +g();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Must fail with the intended transpiler error, not a scope mismatch panic. + expect(stderr).not.toContain("Scope mismatch"); + expect(stderr).toContain("template literal macro invocations are not supported"); + expect(exitCode).not.toBe(0); + }); + + test.concurrent("tagged template macro with arrow interpolation in dead code is erased", async () => { + using dir = tempDir("macro-template-scope-dead", { + "macro.ts": macroFile, + "index.ts": ` +import { mac } from './macro.ts' with { type: 'macro' }; +false && mac\`a\${() => { let q = 1; }}b\`; +function g() { { let y = 2; console.log("ran", y); } } +g();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Scope mismatch"); + expect(stdout).toBe("ran 2\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("member-expression macro tag with function interpolation reports the macro error", async () => { + using dir = tempDir("macro-template-scope-ns", { + "macro.ts": macroFile, + "index.ts": ` +import * as macros from './macro.ts' with { type: 'macro' }; +macros.mac\`x\${function inner() { let z = 3; }}y\`; +class C { m() { { let w = 4; } } } +new C().m();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Scope mismatch"); + expect(stderr).toContain("template literal macro invocations are not supported"); + expect(exitCode).not.toBe(0); + }); +}); + describe("dropped TypeScript class members discard scopes", () => { // Decorators and computed keys are parsed before the parser knows whether the class // member they belong to will be kept. When the member is then dropped (an overload