5.4.14. Macro Tutorial 14: Pass Macro
Previous tutorials showed macros attached to specific language constructs — calls, functions, structures, blocks, loops, enums, and typeinfo expressions. Pass macros operate at a higher level: they receive the entire program and run during a specific compilation phase.
AstPassMacro is the base class for all pass macros. It has a
single method:
apply(prog : ProgramPtr; mod : Module?) → boolprogis the full program being compiled.modis the module that registered the macro. The return value depends on the annotation (see below).
Five annotations control when the macro runs:
Annotation |
Behaviour |
|---|---|
|
Runs after clean type inference. Returning |
|
Runs during each dirty inference pass (the AST may be half-resolved). All dirty macros fire on every pass. |
|
Invoked for each module compiled after the macro module,
during the lint phase. Read-only — use it for analysis and
diagnostics. Use |
|
Same as |
|
Runs during the optimization loop, after built-in
optimisations. Returning |
This tutorial demonstrates the two most common types:
Section 1 —
[lint_macro]: compile-time analysis (CodeStatsLint).Section 2 —
[infer_macro]: AST transformation (TraceCallsPass).
5.4.14.1. The module file
Full source: pass_macro_mod.das
Both macros live in a single module that the user requires.
5.4.14.1.1. Section 1 — lint_macro (compile-time analysis)
[lint_macro]
class CodeStatsLint : AstPassMacro {
def override apply(prog : ProgramPtr; mod : Module?) : bool {
let WARN_THRESHOLD = 4
get_ptr(prog) |> for_each_module() $(var m : Module?) {
if (m.moduleFlags.builtIn) {
return // skip C++ built-in modules
}
m |> for_each_function("") <| $(var func : FunctionPtr) {
if (func.body == null || !(func.body is ExprBlock)) {
return
}
let body = func.body as ExprBlock
let nStmts = length(body.list)
if (nStmts > WARN_THRESHOLD) {
print("[lint] '{func.name}' has {nStmts} statements (>{WARN_THRESHOLD})\n")
}
}
}
return false // lint macros don't modify the AST
}
}
Key points:
[lint_macro]means this class runs after inference succeeds, during the read-only lint phase.get_ptr(prog) |> for_each_module()walks the entire compiled program.m.moduleFlags.builtInskips C++ built-in modules so only user code is inspected.for_each_function("")iterates each module’s functions. The empty string means “all names”.The lint checks function body size: any function with more than
WARN_THRESHOLDtop-level statements triggers a compile-time warning viaprint.print(...)outputs at compile time — the message appears before any runtime output.The return value of a lint macro is ignored; lint macros never trigger re-inference.
In production code, use
compiling_program() |> macro_error(at, text)to emit real compiler errors instead ofprint. Seedaslib/lint.dasfor a full example.
5.4.14.1.2. Section 2 — infer_macro (AST transformation)
The infer macro instruments every function in the program by inserting a
_trace_enter("function_name") call at the start of each function
body. It follows the same visitor pattern as daslib/heartbeat.das.
First, a helper function that the injected code will call:
def public _trace_enter(name : string) {
print(">>> {name}\n")
}
The visitor walks the AST and modifies function bodies:
class TraceCallsVisitor : AstVisitor {
astChanged : bool = false
@do_not_delete func : Function?
def override preVisitFunction(var fun : FunctionPtr) {
func = get_ptr(fun)
}
def override visitFunction(var fun : FunctionPtr) : FunctionPtr {
// Skip our own helper to avoid infinite recursion at runtime.
if (string(fun.name) == "_trace_enter") {
func = null
return <- fun
}
if (fun.body == null || !(fun.body is ExprBlock)) {
func = null
return <- fun
}
var body = fun.body as ExprBlock
if (length(body.list) == 0) {
func = null
return <- fun
}
// Idempotency: skip if already instrumented.
if ((body.list[0] is ExprCall) &&
(body.list[0] as ExprCall).name == "_trace_enter") {
func = null
return <- fun
}
// Insert _trace_enter("function_name") at the beginning.
let fname = string(fun.name)
var inscope expr <- qmacro(_trace_enter($v(fname)))
body.list |> emplace(expr, 0)
astChanged = true
func.not_inferred()
func = null
return <- fun
}
}
Key visitor techniques:
``preVisitFunction`` captures a raw pointer (
@do_not_delete func : Function?) to the function being visited.``visitFunction`` fires after the function body has been visited. It returns a
FunctionPtr— returning<- funkeeps the original unchanged.Self-exclusion —
_trace_entermust not instrument itself, or it would cause infinite recursion at runtime.Idempotency — checking whether the first statement is already a
_trace_entercall prevents the macro from modifying the same function again on re-inference.``qmacro(_trace_enter($v(fname)))`` generates a call expression.
$v(fname)splices the string value offnameas a constant.``body.list |> emplace(expr, 0)`` inserts the expression at position 0 (the start of the function body).
``func.not_inferred()`` marks the function as needing re-inference, so the compiler processes the injected call.
``astChanged = true`` signals that the pass made modifications.
The pass macro creates the visitor and walks the full program:
[infer_macro]
class TraceCallsPass : AstPassMacro {
def override apply(prog : ProgramPtr; mod : Module?) : bool {
var astVisitor = new TraceCallsVisitor()
var inscope astVisitorAdapter <- make_visitor(*astVisitor)
visit(prog, astVisitorAdapter)
var result = astVisitor.astChanged
unsafe {
delete astVisitor
}
return result
}
}
Key points:
``new TraceCallsVisitor()`` allocates the visitor on the heap (macro code cannot use stack-allocated visitors).
``make_visitor(*astVisitor)`` wraps it in a
smart_ptradapter for thevisitfunction.``visit(prog, astVisitorAdapter)`` walks the entire program — all modules, all functions. Use
visit(func, adapter)to walk a single function instead.Return value —
truetells the compiler to re-infer. Since the idempotency check prevents double-instrumentation, the second pass returnsfalseand inference stabilises.``unsafe { delete astVisitor }`` cleans up the raw pointer. The smart-ptr adapter is destroyed by
var inscope.
5.4.14.2. The usage file
Full source: 14_pass_macro.das
require pass_macro_mod
def greet(name : string) {
print("Hello, {name}!\n")
}
def sum_to(n : int) : int {
var total = 0
for (i in range(n)) {
total += i
}
return total
}
def countdown(n : int) {
var i = n
while (i > 0) {
i--
}
print("counted down from {n}\n")
}
[export]
def main() {
greet("world")
let s = sum_to(5)
print("sum = {s}\n")
countdown(3)
}
The user code contains no trace calls — the [infer_macro] injects
them automatically. The [lint_macro] prints its compile-time
message before any runtime output.
5.4.14.3. Output
[lint] 'main' has 5 top-level statements (>4)
>>> main
>>> greet
Hello, world!
>>> sum_to
sum = 10
>>> countdown
counted down from 3
The first line is the lint macro’s compile-time message — when linting
the user module, it found that main has 5 top-level statements (the
infer macro already added a _trace_enter call, raising its count
above the threshold). The >>> lines come from the injected
_trace_enter calls, proving that every user function was
instrumented at compile time.
5.4.14.4. How it works — compilation pipeline
When the compiler processes the user’s program:
Parsing — the source is parsed into an AST.
Dirty inference —
[dirty_infer_macro]macros run on each pass (not used in this tutorial).Clean inference — type inference runs. After it succeeds,
[infer_macro]macros execute.TraceCallsPassinstruments functions and returnstrue. The compiler re-infers. On the second pass, no new changes are made, so it returnsfalse.Optimisation —
[optimization_macro]macros run in the optimisation loop (not used here).Lint —
[lint_macro]macros run for each module compiled after the macro module.CodeStatsLintinspects the user module viacompiling_module()and warns about large function bodies. ([global_lint_macro]macros run once for the entire program.)Execution — the instrumented program runs.
5.4.14.5. Avoiding infinite loops
An infer macro that always returns true will cause the compiler to
hit its maximum pass limit and report an error. Two techniques prevent
this:
Idempotency check — before modifying a function, verify that the modification hasn’t already been applied (e.g. check whether the first statement is already the injected call).
Targeted modification — only modify functions that match specific criteria (e.g. skip
_trace_enterto avoid self-instrumentation).
5.4.14.6. Real-world examples
The standard library uses pass macros for several purposes:
``daslib/heartbeat.das`` —
[infer_macro]that inserts aheartbeat()call at every function and loop entry, enabling cooperative multitasking for long-running scripts.``daslib/coverage.das`` —
[infer_macro]that instruments every block with coverage-tracking calls.``daslib/lint.das`` —
[lint_macro]that enables paranoid compilation checks (unreachable code, unused variables, const upgrades, etc.).``daslib/lint_everything.das`` —
[global_lint_macro]that applies paranoid checks to all modules, not just the one that requires it.
See also
Full source:
14_pass_macro.das,
pass_macro_mod.das
Previous tutorial: tutorial_macro_enumeration_macro
Next tutorial: tutorial_macro_type_macro
Standard library: daslib/heartbeat.das, daslib/coverage.das,
daslib/lint.das
Language reference: Macros — full macro system documentation