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:
- The macro's defining scope (recorded when the macro was declared).
- 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:
&{ident}appears inside a template literal in the macro body.identresolves in the defining scope to a non-nullSymbol*.identalso resolves in the caller scope to a non-nullSymbol*.- The two
Symbol*pointers differ.
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
- Procedural macros (Rust-style
proc_macro) remain out of scope — Nitpick macros stay declarative. - Cross-module macro export uses the existing v0.23.x mechanism; no new export plumbing was added.
- K semantics modelling: K core does not model the macro
expander itself —
MacroDeclStmt,MacroInvocationExpr, template-literal interpolation, and scope resolution all live in the C++ TypeChecker. K core tests 172 and 173 lock the post-expansion runtime value flow only.
See META/NITPICK/ROADMAP/0.31/AUDIT_v0.31.15.x.md for the full
decision ledger (MACRO2-DEC-001..010).