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:

ContextWhenEffect
StatementThe call is the whole expression statementBlock body is spliced as-is
ExpressionAnywhere else in value positionBlock body is wrapped in an IIFE
TypeA $Macro<…> reference in type positionNo 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 undefined

An 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 untouched

The precise rule matters:

  • Only names declared by const, let, or var inside 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 __helper without 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.