Skip to content

Support tagged template literals in macros#30545

Open
robobun wants to merge 7 commits into
mainfrom
farm/46de06ae/macro-tagged-template
Open

Support tagged template literals in macros#30545
robobun wants to merge 7 commits into
mainfrom
farm/46de06ae/macro-tagged-template

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #18047 — calling a macro as a tagged template literal no longer panics.

Repro

// macro.ts
export const ico = (name: TemplateStringsArray) => `/svg/spritesheet.svg#${name[0]}`;

// index.ts
import { ico } from "./macro" with { type: "macro" };
console.log(ico`hello`);
$ bun build ./index.ts
panic: TODO: support template literals in macros

Cause

Macro.Runner.run only handled .e_call expressions; the .e_template case was @panic("TODO: ...").

Fix

  • Macro.zig: implement the .e_template case. Build a TemplateStringsArray-shaped first argument — a JS array of cooked string segments with a .raw property holding the raw segments — and pass substitution values as the remaining arguments, matching tagged-template call semantics. Raw segments are cooked via the lexer's decodeEscapeSequences; segments whose escapes don't decode become undefined in the cooked array per spec.
  • visitExpr.zig: visit template substitution values before the macro call so literals are resolved for toJS, and report macro errors the same way the .e_call path does instead of silently returning the original expression.
  • lexer.zig: make decodeEscapeSequences pub so the macro runner can reuse it.

After

$ bun build ./index.ts
// index.ts
console.log("/svg/spritesheet.svg#hello");

Verification

  • New tagged-template tests in test/bundler/transpiler/macro-test.test.ts covering: no substitutions, substitutions, escape sequences (\n, \t, \`), \u{…} unicode escapes, invalid escapes (cooked → undefined), and namespace imports.
  • test/regression/issue/18047.test.ts covers both bun build and bun run.
  • All 60 existing bundler_string / template-literal tests still pass.

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minute and 39 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbbd9c3c-d792-4a67-b9df-548c29976d9e

📥 Commits

Reviewing files that changed from the base of the PR and between 3e2e17c and 916955a.

📒 Files selected for processing (4)
  • src/js_parser/ast/visitExpr.zig
  • src/js_parser_jsc/Macro.zig
  • test/bundler/transpiler/macro-test.test.ts
  • test/regression/issue/18047.test.ts

Walkthrough

This PR adds end-to-end support for tagged template literals as macro call syntax. Previously, using a template literal to invoke a macro would panic. The changes expose the lexer's escape-decoding method, implement template-to-JS conversion helpers in the macro runner, update the template visitor to properly handle macro invocation with parts pre-visiting and constant folding, and validate the feature with comprehensive tests.

Changes

Tagged Template Literal Macros

Layer / File(s) Summary
Lexer escape-decoding method visibility
src/js_parser/lexer.zig
The decodeEscapeSequences method is exposed as pub fn so the macro runner can reuse it to decode raw template segments.
Macro JSC template-to-JS conversion
src/js_parser_jsc/Macro.zig
Adds helper functions to cook raw template segments (decoding escapes, producing undefined for invalid ones) and build a TemplateStringsArray object with cooked and raw properties. The macro runner's caller case now recognizes .e_template and constructs JS arguments as [stringsArray, ...templateParts, optionalObject].
Template visitor macro processing
src/js_parser/ast/visitExpr.zig
The e_template visitor pre-visits all template-part substitution values before macro invocation, uses structured error handling (generic error for MacroFailed, specific error name for others), and optionally folds the result when constant folding is enabled, avoiding re-visiting parts since they were already visited.
Test helpers and unit tests
test/bundler/transpiler/macro.ts, test/bundler/transpiler/macro-test.test.ts
Adds templateTag and ico helper macros. Unit tests cover tagged template invocation with substitutions, escape sequences, unicode escapes, invalid escapes (producing undefined), and both direct and namespace-imported macro access.
Regression tests for issue #18047
test/regression/issue/18047.test.ts
End-to-end tests verify that bun build and bun run correctly compile and execute code using tagged template macros, with proper escape handling and output validation.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: implementing support for tagged template literals when calling macros, which is the core objective.
Description check ✅ Passed The PR description follows the template with 'What does this PR do?' section explaining the fix, and includes detailed verification information exceeding the basic template requirements.
Linked Issues check ✅ Passed All coding requirements from issue #18047 are met: template literals no longer panic, TemplateStringsArray-shaped first argument is constructed, substitutions passed as additional args, and escape handling follows JavaScript semantics.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue #18047: macro/lexer implementation updates, template literal handling, and corresponding test additions with no extraneous modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. allow template literals in bun macros #21624 - Also implements tagged template literal support for macros by adding the .e_template case in Macro.Runner.run

🤖 Generated with Claude Code

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #21624 takes a similar approach but targets the old src/ast/Macro.zig path (since moved to src/js_parser_jsc/Macro.zig). This PR additionally:

  • decodes escape sequences so cooked values differ from raw where they should (\\n → newline, \\u{…} → codepoint)
  • attaches the .raw property to the strings array
  • returns undefined for cooked segments with undecodable escapes, per spec
  • visits substitution values before the macro call so literals resolve for toJS
  • matches .e_call's error reporting instead of silently returning the original expression

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser_jsc/Macro.zig`:
- Around line 557-586: The template arrays are left mutable and `.raw` is
defined enumerable; update the end of buildTemplateObject to define the `.raw`
property on cooked_array as non-enumerable (use the JS
defineProperty/put-with-attributes API rather than a plain put) and then freeze
both cooked_array and raw_array using the JS engine's object freeze API so they
become immutable like real tagged-template objects; refer to cooked_array,
raw_array, cooked_array.put(... jsc.ZigString.static("raw"), raw_array) and the
createEmptyArray/protect/unprotect sequence to locate where to add the
defineProperty-with-non-enumerable-attribute and the freeze calls.

In `@src/js_parser/ast/visitExpr.zig`:
- Around line 411-415: The `${...}` substitution visit is placed after
early-return checks so branches like is_control_flow_dead, no_macros, and
node_modules skip visiting substitutions; move the loop that iterates over
e_.parts and assigns part.value = p.visitExpr(part.value) earlier in visitExpr
(or ensure it's executed before each early return path) so substitutions are
always visited even when the macro is rejected (similar to how .e_call handles
folding and symbol reporting).

In `@test/regression/issue/18047.test.ts`:
- Line 32: The unconditional expect(stderr).toBe("") assertions are flaky under
ASAN/debug builds because benign warnings may be printed to stderr; remove or
relax those assertions in test/regression/issue/18047.test.ts (the two
occurrences around the expect(stderr).toBe("") statements) and rely on the
subprocess exit code assertion (e.g., expect(code).toBe(0)) as the primary
success signal, or replace the strict empty check with a more permissive pattern
match if you must validate stderr contents.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 99cabb17-0332-4527-890e-3ee36adb2b79

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 3e2e17c.

📒 Files selected for processing (6)
  • src/js_parser/ast/visitExpr.zig
  • src/js_parser/lexer.zig
  • src/js_parser_jsc/Macro.zig
  • test/bundler/transpiler/macro-test.test.ts
  • test/bundler/transpiler/macro.ts
  • test/regression/issue/18047.test.ts

Comment thread src/js_parser_jsc/Macro.zig
Comment thread src/js_parser/visit/visit_expr.zig
Comment thread test/regression/issue/18047.test.ts
Comment thread test/regression/issue/18047.test.ts Outdated
Comment thread src/js_parser/visit/visit_expr.zig
Comment thread src/js_parser_jsc/Macro.zig
Comment thread src/js_parser_jsc/Macro.zig
Comment thread src/js_parser_jsc/Macro.zig
Comment thread src/js_parser/ast/visitExpr.zig Outdated
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Rebased onto main (714e3de) and ported the implementation to the new Rust Macro.rs / visit_expr.rs after #30412. The diff is green — macro-test.test.ts (18 tagged-template cases) and 18047.test.ts pass on all CI lanes.

Remaining CI red on build #54261 is unrelated flake, none of it touches the parser/macro/lexer code in this diff:

Test Lane Failure
test/js/web/fetch/fetch-tcp-keepalive.test.ts x64-asan SO_KEEPALIVE timer check (recently-landed #30627/#30640)
test/js/bun/jsc-stress/jsc-stress.test.ts win2019-x64-baseline (retry) mprotect failed: 487 in Wasm BBQ/OMG
test/js/bun/spawn/spawn.test.ts win2019-x64-baseline (retry) timeout in kill/unref permutations
test/js/bun/spawn/spawn-pipe-leak.test.ts win11-aarch64 (retry) RSS delta 93MB (107%) over threshold

All review threads (CodeRabbit + claude[bot]) resolved. Ready for maintainer review/merge.

robobun and others added 7 commits May 14, 2026 11:13
Calling a macro as a tagged template (``ico`hello```) previously hit
@Panic("TODO: support template literals in macros") in Macro.Runner.run.

The e_template caller case now builds a TemplateStringsArray-shaped first
argument (cooked segments, with a .raw property) and passes the substitution
values as the remaining args, matching tagged-template call semantics.
Raw segments are cooked via the lexer's escape decoder; segments with
undecodable escapes become undefined per spec.

visitExpr now visits substitution values before the macro call so literals
are resolved, and reports macro errors the same way the e_call path does.

Fixes #18047
Mirrors the .e_call path so `"a" + "b"` folds to a literal before toJS.
Also: mark the two regression subprocess tests concurrent.
bun.String.createUninitialized asserts len > 0; return String.empty when
decodeEscapeSequences produces zero code units (e.g. a segment that is
only a backslash-newline).

Also drop the dead e_.fold() call on the macro fall-through path since
E.Template.fold is a no-op when tag != null.
The macro runner and e_template visitor moved to Rust (Macro.rs,
visit_expr.rs); the Rust e_template path emitted a hard error instead
of the old Zig panic. Port the implementation:

- Macro.rs: cook_raw_template_segment + make_template_strings_array,
  and fill the ExprData::ETemplate match arm in Runner::run.
- visit_expr.rs: visit substitution values (with constant-folding
  flags set, matching .e_call) before the macro call; report errors
  like .e_call does; skip the trailing fold/re-visit when the macro
  returns the original template.
- lexer.rs: expose decode_escape_sequences (was private).

The parallel .zig changes are kept in sync as the port reference.

Adjust the constant-folding test to avoid string-addition ropes,
which trip a pre-existing debug_assert in string_to_js (unrelated
to this change; affects identity("a"+"b") too).
@robobun robobun force-pushed the farm/46de06ae/macro-tagged-template branch from 916955a to 714e3de Compare May 14, 2026 11:39

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my earlier inline feedback is addressed and I found nothing new on this pass — but this is ~300 lines of new macro-execution / JSC-interop logic across parallel Zig and Rust paths (plus a couple of acknowledged deferred spec gaps), so it's worth a maintainer's eyes before merge.

Extended reasoning...

Overview

This PR implements tagged-template-literal support for Bun macros, replacing a @panic("TODO"). It adds two helpers (cookRawTemplateSegment, makeTemplateStringsArray) and a new .e_template arm in Runner.run in both src/js_parser_jsc/Macro.zig and the parallel Rust port Macro.rs; updates the .e_template visitor in visit_expr.{zig,rs} to pre-visit substitutions with constant-folding flags and to report macro errors like the .e_call path; flips decodeEscapeSequences to pub in the lexer; and adds ~90 lines of tests across macro-test.test.ts, macro.ts, and a new 18047.test.ts.

Security risks

Macros execute arbitrary user JS at build time, so the macro runner is a security-relevant subsystem. That said, this PR doesn't change the trust boundary — it adds a new call shape into the existing runner, reusing the same toJS, protect/unprotect, and node_modules / no_macros gates already applied to the .e_call path. The throwaway lexer for escape decoding operates on source bytes already in memory and writes only to a local log that's discarded. I don't see new injection, auth, or data-exposure surface here.

Level of scrutiny

Moderate-to-high. This is not a config tweak or mechanical refactor — it's a feature implementation with non-trivial pieces: building a TemplateStringsArray-shaped JS object via JSC bindings, GC protect/unprotect bookkeeping, a throwaway-lexer pattern for cooking raw segments, UTF-16 string materialization, and a parallel Rust port that must stay behaviorally identical. The author has also explicitly deferred three known minor gaps (no Object.freeze / non-enumerable .raw; legacy-octal / \\8-\\9 escapes don't produce undefined cooked values; a pre-existing one-GC-root leak on the toJS error path when javascript_object != .zero, copied verbatim from .e_call). Those are reasonable scope calls for a crash-fix PR, but a maintainer should sign off on them.

Other factors

  • All six of my prior inline comments (two rounds) are resolved: test.concurrent applied, constant-folding flags added around the substitution-visit loop, the buf.items.len == 0 line-continuation crash guarded, and the dead fold() block removed. The remaining two were acknowledged as intentional deferrals.
  • Test coverage is good: in-process cases for no-subs, subs, escapes, unicode, invalid escapes → undefined, line continuations, constant folding, namespace imports; plus subprocess bun build/bun run regression tests.
  • CI is green on the touched tests per the author's status comment; remaining red is unrelated flake.
  • No human reviewer has weighed in yet; the author has marked it ready for maintainer review.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: #31693 fixes the "Scope mismatch while visiting" panic from macro tagged templates by visiting the template parts before the macro dispatch (covers the dead-flow / macros-disabled / node_modules returns this PR's pre-call visit doesn't reach). If #31693 lands first, the rebase here is dropping the inserted visit loop and keeping the fold-flag wrapper around the (now earlier) parts visit.

Jarred-Sumner pushed a commit that referenced this pull request Jun 2, 2026
…emplates (#31693)

### Repro

```ts
// macro.ts
export function mac(...args: any[]) { return "x"; }

// index.ts
import { mac } from './macro.ts' with { type: 'macro' };
mac`a${() => { let q = 1; }}b`;
function g() { { let y = 1; } }
```

```
$ bun index.ts
panic: Scope mismatch while visiting
```

Any tagged-template macro invocation whose interpolations contain a
scope-creating expression (arrow, function, class) panics instead of
reporting the intended `template literal macro invocations are not
supported` error. The dead-code variant (`false && mac`…`` `), the
macros-disabled path, and the node_modules path crash the same way.
Panics on release builds (kind mismatch) and debug builds (loc
mismatch); same structure existed in the Zig-era `visitExpr.zig`, so
this predates the Rust port. Crash signature matches Sentry
[BUN-3BYK](https://bun-p9.sentry.io/issues/7504990169/) (`Scope mismatch
while visiting`, also seen in Zig-era releases).

### Cause

The parser records every scope pushed during the parse pass in
`scopes_in_order`; the visit pass replays them in the same order and
panics on divergence. In `e_template`
(`src/js_parser/visit/visit_expr.rs`), when the tag resolves to a macro
ref, **every** dispatch outcome returns before the `for part in
e_.parts_mut()` visit loop:

- `is_control_flow_dead` → replaced with `undefined`
- `no_macros` → error + `undefined`
- `node_modules` → error + `undefined`
- `macro_context.call(...)` failure → plain `return` (and template
invocations currently always fail with `template literal macro
invocations are not supported`, `src/js_parser_jsc/Macro.rs`)

The scopes recorded for arrows/functions inside the interpolations are
never consumed, so the next scope the visit pass pushes reads a stale
entry and trips the `Scope mismatch while visiting` check. Same bug
family as #31231 / #31340 / #31533 (constructs dropped without
consuming/discarding their recorded scopes).

### Fix

Visit the template parts right after visiting the tag, **before** the
macro dispatch — the same ordering `e_call` uses (arguments are visited
before its macro handling). All dispatch paths may then freely replace
the expression: the parts' scope entries have already been consumed. The
fall-through case no longer re-visits parts.

Also syncs `Cargo.lock` with `bun_bin`'s manifest (`bstr` was added to
`Cargo.toml` in 90f334a without the lock update; any local cargo
invocation regenerates this line).

### Verification

- 3 new tests in `test/bundler/transpiler/scope-mismatch-panic.test.ts`
(live tag, dead-flow, namespace-member tag). All three panic without the
fix and pass with it.
-
`test/bundler/transpiler/{scope-mismatch-panic,macro-test,transpiler,template-literal}.test.ts`
and `test/bundler/bundler_string.test.ts` all pass.

Note: #30545 (tagged-template macro support, feature) inserts a parts
visit before the macro *call*, which would cover the live path but not
the dead-flow / macros-disabled / node_modules returns. This fix is
independent and minimal; #30545 rebases on top by dropping its duplicate
visit loop (its fold-flag wrapper can stay).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

crash: support template literals in macros

1 participant