Quartz User Macro Design
Status
Existing Infrastructure (Phase 5.9):
- String template macros (
"${param}"substitution) - Quote/unquote macros (AST cloning with substitution)
- Hygiene via gensym (
__prefix_Nautomatic renaming) - Variadic parameters (
param...) - 4 built-in macros:
$try,$unwrap,$assert,$debug - Both C bootstrap (
macro.c, 1430 lines) and self-hosted (macro_expand.qz, 465 lines) - Pipeline: Parse -> Macro Expand -> Resolve Imports -> Desugar -> Typecheck
What’s Missing: User-defined macros. Only the 4 built-in macros exist.
Survey of Macro Systems
| Language | Style | Hygiene | Timing | Strengths | Weaknesses |
|---|---|---|---|---|---|
| Rust proc-macros | Token stream transforms | Semi (span-based) | Pre-type-check | Arbitrary computation | Complex API, slow builds |
| Rust declarative | Pattern matching | Yes (by default) | Pre-type-check | Declarative, readable | Limited power |
| Elixir defmacro | AST transforms (quoted) | Yes (automatic) | Compile-time | quote/unquote intuitive | Debugging can be hard |
| Zig comptime | Regular code at compile-time | N/A (same language) | Type-check time | No separate macro language | Limited to type-level ops |
| Nim templates | AST substitution | None (untyped) | Pre-type-check | Simple | No hygiene |
Design: Three Levels
Level 1: User String Template Macros
Already implemented in C bootstrap — just needs wiring so user macro definitions are collected and expanded.
macro log(msg) do
"eputs(${msg})"
end
$log("hello") # expands to: eputs("hello")
Implementation: The macro_registry_register() and macro_registry_lookup() functions already exist. The C bootstrap parser already parses NODE_MACRO_DEF at top level. The expansion pipeline already handles user macros.
Gap: The self-hosted compiler’s macro_expand.qz only expands the 4 built-in macros. User definitions are parsed but ignored during expansion.
Level 2: User Quote/Unquote Macros
Already implemented in C bootstrap — clone_with_unquote() handles user-defined quote blocks.
macro double(x) do
quote do
unquote(x) + unquote(x)
end
end
$double(21) # expands to: 21 + 21
Gap: Same as Level 1 — self-hosted expansion needs to support user macros.
Level 3: Procedural Macros (Future)
Compile a macro as a separate program that receives AST as input and produces AST as output. Similar to Rust proc-macros.
# Hypothetical syntax
proc_macro derive_debug(item) do
# Full Quartz code runs at compile time
# Access AST via intrinsics
var struct_name = ast_name(item)
var fields = ast_fields(item)
# Generate debug impl
...
end
Decision: Defer. Levels 1-2 cover the immediate use cases. Level 3 requires a separate compilation step and AST serialization protocol.
Error Propagation
Macro expansion errors must report both the call site and the macro body:
error[QZ0501]: Macro expansion error
--> main.qz:10:5
|
10 | $log(42, "extra")
| ^^^^^^^^^^^^^^^^^ too many arguments (expected 1, got 2)
|
note: macro defined here
--> lib.qz:2:1
|
2 | macro log(msg) do
| ^^^^^^^^^^^^^^^^
Current state: The C bootstrap tracks call_line/call_col through expansion. Errors from typechecking expanded code report the expansion site (call line), not the macro body line. This is acceptable for Level 1-2.
Hygiene
Current mechanism (already implemented):
HygieneContextper expansion tracks all renamed bindingslet x = ...inside macro body becomeslet __x_42 = ...(gensym)- Identifier references to renamed bindings are also renamed
unquote(x)preserves the user’s original names (no rename)
Rules:
- Macro-introduced names are isolated (cannot leak into caller scope)
- User-provided expressions (via unquote) keep their original names
- Nested macro calls get independent gensym counters
Limitation: No var hygiene — macros that introduce mutable bindings visible to the caller require explicit naming (convention: __macro_result).
Examples
Assert with message
macro assert(cond, msg) do
quote do
if unquote(cond) == 0
panic(unquote(msg))
end
end
end
Timing
macro timed(body) do
quote do
var __start = clock_gettime_ns()
unquote(body)
var __elapsed = clock_gettime_ns() - __start
eputs("elapsed: " + int_to_str(__elapsed) + "ns")
end
end
Variadic debug
macro debug_all(vals...) do
quote do
for v in unquote(vals)
eputs(int_to_str(v))
end
end
end
Implementation Plan for TS.21
Phase A: Enable User Macros in C Bootstrap (already works)
The C bootstrap already supports user-defined macros. Verify with a test:
macro double(x) do
quote do
unquote(x) + unquote(x)
end
end
def main(): Int
print_int($double(21))
return 0
end
Phase B: Enable User Macros in Self-Hosted Compiler
- macro_expand.qz: Add user macro collection (walk program, register
NODE_MACRO_DEF) - macro_expand.qz: Add user macro expansion (when
NODE_MACRO_CALLname matches a user macro, expand it) - String template expansion: parse
"${param}"patterns, substitute argument ASTs - Quote/unquote expansion: clone body AST, replace
NODE_UNQUOTEwith argument ASTs
Phase C: Tests
- User string template macro: define + invoke
- User quote/unquote macro: define + invoke
- Hygiene: macro bindings don’t leak
- Variadic:
param...with multiple args - Nested macros: macro calling another macro
- Error: wrong argument count
- Error: undefined macro
- Cross-module: macro defined in imported file (requires resolver integration)
Phase D: Cross-Module Macros (Optional)
Currently macros are module-local. To support import lib bringing in macros:
- Resolver must merge macro definitions alongside function/type definitions
- Or: macros expanded per-module before resolution (simpler, already the case)
Decision: Keep macros module-local for now. Users can work around this by defining macros in each file or using a shared header pattern.
Constraints
- Max 256 macros per compilation
- Max 64 parameters per macro
- Max expansion depth: 64 (infinite recursion guard)
- String template buffer: 4KB
- No macro definition inside function bodies (top-level only)
- No
$prefix collision detection (user responsibility)