← Back to AILP Home

Complex Code-Generating Macros

Beyond the expression-level and single-statement output covered in code_gen.md, Nitpick macros can produce more complex shapes: multiple top-level declarations from a single invocation, struct field splices, impl method splices, and template literals whose interpolations respect the macro's defining scope.

This chapter covers MACRO-007 (closed in v0.31.15.x). It builds on basic.md, code_gen.md, and hygiene.md — read those first.

Multi-Declaration Output

A macro body may contain multiple top-level declarations (struct, func, impl). When invoked at module position with the name!(args); form, the body is flattened into the surrounding declaration stream as if the user had typed each declaration by hand.

macro:emit_point_pair = () {
    struct:Point = { int32:x; int32:y; };
    func:point_new = Point(int32:x, int32:y) {
        pass Point{ x: x, y: y };
    };
};

emit_point_pair!();    // module position — flattens both decls here

func:main = int32() {
    Point:p = point_new(3i32, 4i32);
    exit(p.x + p.y);   // 7
};
func:failsafe = int32(tbb32:e) { exit(1); };

After expansion the program contains two top-level entities (Point and point_new), exactly as if the macro body had been written inline.

When Flattening Happens

Flattening occurs at the splice point during type-checking (flattenSpliceMacros). The macro grammar itself is unchanged from v0.23.x — the body is still a BlockStmt. The flattener walks the post-clone block and inlines each child statement at the splice point's parent declaration list.

Struct Field Splices

The same name!(args); shape works inside a struct body, where it splices field declarations into the struct.

macro:emit_timestamps = () {
    int64:created_at;
    int64:updated_at;
};

struct:User = {
    int32:id;
    string:name;
    emit_timestamps!();    // splices created_at + updated_at
};

The parser uses a three-token lookahead (Ident ! () to disambiguate the splice form from a labelled field named name. No %spliceFields keyword is required.

Impl Method Splices

Identically, name!(args); inside an impl block splices method declarations.

macro:emit_getters = () {
    func:get_x = int32($$i self) { pass self.x; };
    func:get_y = int32($$i self) { pass self.y; };
};

impl:Point = {
    emit_getters!();    // splices get_x + get_y as Point methods
};

Template Literal Hygiene

Template-literal interpolations &{ident} inside a macro body resolve in two scopes:

  1. The macro's defining scope (recorded when the macro was declared).
  2. The macro's caller scope (the function/block where the macro is invoked).

When ident resolves in both scopes but to different bindings, the macro author probably intended one but the substitution would silently pick the other. The compiler emits ARIA-061 MACRO_HYGIENE_VIOLATION as a warning to flag the ambiguity.

int32:shared = 100i32;   // module scope (defining scope of report)

macro:report = () {
    println(`shared=&{shared}`);   // intends the module-level binding
};

func:main = int32() {
    int32:shared = 7i32;   // caller-scope local shadows
    report!();             // ARIA-061: 'shared' resolves to two
                           //           different bindings
    exit(0);
};
func:failsafe = int32(tbb32:e) { exit(1); };

ARIA-061 fires when:

The warning is not an error — existing macros that intentionally capture caller-scope bindings continue to compile.

Opting In to Caller Capture: #caller(NAME)

When the macro author wants the caller's binding, mark the interpolation explicitly with #caller(NAME):

int32:shared = 100i32;

macro:report_opt = () {
    println(`shared=&{#caller(shared)}`);   // explicit caller capture
};

func:main = int32() {
    int32:shared = 7i32;
    report_opt!();    // no warning — prints "shared=7"
    exit(0);
};
func:failsafe = int32(tbb32:e) { exit(1); };

#caller(NAME) parses as #caller(ident) and is rewritten by the hygiene walker into a bare &{NAME} resolved in the caller scope. ARIA-061 is silenced for that interpolation only.

Resolving the Warning the Other Way

If you intended the defining-scope binding instead, qualify the identifier so it can only resolve in one scope (e.g., use a module-qualified name or rename the macro-internal variable):

int32:shared_global = 100i32;   // unique name avoids collision

macro:report_unique = () {
    println(`val=&{shared_global}`);   // resolves only at module level
};

Interaction With Hygiene of Local Bindings

Template-literal hygiene is independent of the gensym-based hygiene for variable bindings introduced inside the macro body (covered in hygiene.md). Local int32:tmp = ...; inside a macro body is still gensym'd to a fresh name per call site. The hygiene-walker for template literals only inspects interpolation targets, not local declarations.

Inspecting Multi-Decl Expansion

--expand-macros shows the flattened output:

$ npkc myfile.npk --expand-macros
// macro expansion at myfile.npk:5:1
// call:     emit_point_pair!()
// expanded: Block([StructDecl(Point, ...), FuncDecl(point_new, ...)])

For template-literal hygiene, an ARIA-061 warning at compile time indicates the cross-scope resolution; use #caller(NAME) or rename to suppress.

Limits and Non-Goals

See META/NITPICK/ROADMAP/0.31/AUDIT_v0.31.15.x.md for the full decision ledger (MACRO2-DEC-001..010).