API Stability Notice
Macroforge is under active development. The API is not yet stable and may change between versions. Some documentation sections may be outdated.
Expansion, Hygiene & Composition
Expansion contexts
Every macro call expands in one of three contexts, and the context changes the output:
| Context | When | Effect |
|---|---|---|
| Statement | The call is the whole expression statement | Block body is spliced as-is |
| Expression | Anywhere else in value position | Block body is wrapped in an IIFE |
| Type | A $Macro<…> reference in type position | No wrapping |
Statement position is detected by unwrapping parentheses and TypeScript casts first — so ($m(x)); and $m(x) as unknown; are both still statement position.
Block bodies: IIFE wrapping and return injection
A { … } macro body follows Rust’s block semantics: the final expression is the block’s value. JavaScript arrow bodies don’t do that, so in expression position the expander wraps the block and injects a return:
const $vec = macroRules`
($($x:Expr),+) => {
const __v = [];
$( __v.push($x); )+
__v
}
`;
const xs = $vec(1, 2);expands to:
const xs = (() => {
const __v$1 = [];
__v$1.push(1);
__v$1.push(2);
return __v$1;
})();The return is injected only when the trailing expression has no ;. A trailing semicolon means “discard this value”, exactly as in Rust:
($x:Expr) => { compute($x) } // value is compute($x)
($x:Expr) => { compute($x); } // value is undefinedAn explicit return, or a trailing if / throw / nested block, is left alone — no double injection.
In statement and type position there is no wrapping at all.
Hygiene
Identifiers beginning with __ that are declared inside the macro body get a unique per-expansion suffix, so a macro’s temporaries can’t collide with names at the call site:
const __v = "mine";
const xs = $vec(1, 2); // macro's __v becomes __v$1 — yours is untouchedThe precise rule matters:
- Only names declared by
const,let, orvarinside the expansion are renamed. - Names containing
$are skipped. - Pure references to externally-declared
__names are deliberately left alone — this is what lets a shared-runtime macro call its__helperwithout breaking the link. - Destructuring binders (
const { __x } = …) are out of scope and are not renamed.
Renaming is lexically aware: it respects string literals, comments, regex literals, and template-literal text, so console.log("__v") is never corrupted.
Escaping
$$ emits a literal $. The body parser is string-literal aware, so $name inside a string is still substituted, while $$name is not.
Composition
A macro body may call another macro. Composition re-parses the intermediate output, so the result is real syntax rather than text splicing:
const $double = macroRules`($x:Expr) => ($x * 2)`;
const $quad = macroRules`($x:Expr) => $double($double($x))`;
$quad(3); // → ((3 * 2) * 2)Two disambiguation rules govern when a $name is treated as a call:
$ident(is a macro call only when the(is on the same line (horizontal whitespace only).$Ident<is a type-macro call only under strict adjacency, with a conservative angle-bracket balance check.
$__cluster__ is reserved and never parses as a call.
Nested value expansions run in statement context, and nested type expansions in type context. Composition always uses the callee’s expand arms — its mode and call arms are ignored.
Recursion limit
Expansion depth is capped at 256. Mutual recursion between macros is caught by that cap rather than by static analysis — there is no cycle detection wired into the pipeline, so a recursive macro that never bottoms out fails with RecursionLimit rather than a “cycle detected” error.
Where calls are recognized
Rewriting works in more places than plain statements and initializers, including JSX expression containers, class field initializers, decorator arguments, and tuple type elements.