Quartz v5.25

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_N automatic 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

LanguageStyleHygieneTimingStrengthsWeaknesses
Rust proc-macrosToken stream transformsSemi (span-based)Pre-type-checkArbitrary computationComplex API, slow builds
Rust declarativePattern matchingYes (by default)Pre-type-checkDeclarative, readableLimited power
Elixir defmacroAST transforms (quoted)Yes (automatic)Compile-timequote/unquote intuitiveDebugging can be hard
Zig comptimeRegular code at compile-timeN/A (same language)Type-check timeNo separate macro languageLimited to type-level ops
Nim templatesAST substitutionNone (untyped)Pre-type-checkSimpleNo 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 bootstrapclone_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):

  1. HygieneContext per expansion tracks all renamed bindings
  2. let x = ... inside macro body becomes let __x_42 = ... (gensym)
  3. Identifier references to renamed bindings are also renamed
  4. 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

  1. macro_expand.qz: Add user macro collection (walk program, register NODE_MACRO_DEF)
  2. macro_expand.qz: Add user macro expansion (when NODE_MACRO_CALL name matches a user macro, expand it)
  3. String template expansion: parse "${param}" patterns, substitute argument ASTs
  4. Quote/unquote expansion: clone body AST, replace NODE_UNQUOTE with 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:

  1. Resolver must merge macro definitions alongside function/type definitions
  2. 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)