5.4.7. Macro Tutorial 7: Block Macros

Tutorials 3–6 transformed functions and structs. Block macros operate on block closures instead — the $ { ... } expressions that are passed to functions as arguments.

[block_macro(name="X")] registers a class that extends AstBlockAnnotation. When a block is annotated with [X], the compiler calls the macro’s methods at two stages of the compilation pipeline:

Method

When it runs

apply()

During parsing, before inference. Can modify the block’s statement list and finally list, validate annotation arguments, and inject code.

finish()

After all inference and optimization. Read-only — argument types are fully resolved, useful for diagnostics.

This tutorial builds a [traced(tag="X")] annotation that:

  1. Prepends an enter-message and appends an exit-message (via finalList) to the block body — the exit message runs even on early return (apply).

  2. Prints a compile-time summary of each block’s typed arguments and statement count (finish).

run_block() $ [traced(tag="setup")] {
    print("  initializing\n")
}

After compilation the block body gains print(">> setup\n") at the start and print("<< setup\n") in its finally section, so every invocation prints entry and exit markers around the user code.

5.4.7.1. Block annotation syntax

Block annotations are placed between the $ sigil and the parameter list (or body, for parameterless blocks):

// Parameterless block
$ [annotation(args)] { body }

// Block with parameters
$ [annotation(args)] (params) { body }

Multiple annotations can be comma-separated inside the brackets, just like function annotations:

$ [traced(tag="x"), REQUIRE(hp)] (v : int) { ... }

5.4.7.2. Why only two methods?

Structure macros have three methods (apply, patch, finish) because structs often need type-aware code generation in patch() after inference resolves field types. Block macros skip patch() entirely:

  • apply() — The block is parsed but types may not be resolved. You can modify blk.list (the statement list) and blk.finalList (the finally section). Validation and code injection happen here.

  • finish() — Everything is final: argument types are resolved, code is optimized. No modifications allowed. Use it for diagnostics, compile-time reporting, or verifying block structure.

Blocks are self-contained expressions — simpler than functions or structs — so two methods are sufficient. If you need post-inference code generation, use a function macro or structure macro instead.

5.4.7.3. The module: block_macro_mod.das

5.4.7.3.1. Registration

[block_macro(name="traced")]
class TracedBlockMacro : AstBlockAnnotation {
    ...
}

[block_macro(name="traced")] tells the compiler:

When a block is annotated with [traced], call this class’s methods during compilation.

Block annotations are registered as function annotations under the hood — add_new_block_annotation internally calls add_function_annotation. This is why block annotations share the AnnotationArgumentList parameter type with function macros.

5.4.7.3.2. Inside apply()

The method receives the block expression, annotation arguments, and an error string. It runs during parsing, before inference.

5.4.7.3.2.1. Step 1 — Validate arguments

let labelArg = find_arg(args, "tag")
if (!(labelArg is tString)) {
    errors := "[traced] requires a 'tag' string argument"
    return false
}
let lbl = labelArg as tString

We use find_arg (from daslib/ast_boost) to look up the tag argument by name. It returns an RttiValue variant — we check is tString and cast with as tString. Returning false aborts compilation with the error message.

5.4.7.3.2.2. Step 2 — Prepend enter-print

var inscope enterExpr <- qmacro(print($v(">> {lbl}\n")))
blk.list |> emplace(enterExpr, 0)

blk.list is the block’s statement array. emplace(vec, val, 0) inserts at position 0, pushing existing statements down. This places the enter-print before any user code.

$v(">> {lbl}\n") splices the compile-time string (with the tag value baked in) as a constant expression in the generated code.

5.4.7.3.2.3. Step 3 — Append exit-print to finalList

var inscope exitExpr <- qmacro(print($v("<< {lbl}\n")))
blk.finalList |> emplace(exitExpr)

blk.finalList is the block’s finally section — statements here run when the block exits, regardless of how it exits. This guarantees the exit message prints even if the block has an early return.

Note

finalList on loop blocks (blockFlags.inTheLoop) only runs once at loop exit, not per iteration. For block closures passed to functions (like in this tutorial), finalList runs on every invoke, which is correct for enter/exit tracing.

5.4.7.3.3. Inside finish()

def override finish(var blk : smart_ptr<ExprBlock>; var group : ModuleGroup;
                    args, progArgs : AnnotationArgumentList;
                    var errors : das_string) : bool {
    let labelArg = find_arg(args, "tag")
    var lbl = "?"
    if (labelArg is tString) {
        lbl = labelArg as tString
    }

    // Count original statements (subtract injected enter-print)
    var numStmts = 0
    for (s in blk.list) {
        numStmts++
    }
    if (numStmts > 0) {
        numStmts -= 1
    }

    print("[traced] \"{lbl}\": {numStmts} statement(s)")

    // Iterate typed arguments — types are now resolved
    var numArgs = 0
    for (a in blk.arguments) {
        numArgs++
    }
    if (numArgs > 0) {
        print(", args = (")
        var first = true
        for (arg in blk.arguments) {
            if (!first) {
                print(", ")
            }
            print("{arg.name}:{describe(arg._type)}")
            first = false
        }
        print(")")
    }

    print("\n")
    return true
}

finish() runs after all inference and optimization. The block is in its final form — argument types are fully resolved. We iterate blk.arguments (a managed vector of VariablePtr) and call describe(arg._type) to get human-readable type names like int const.

We count statements by iterating blk.list and subtract one to account for the enter-print injected by apply().

5.4.7.4. The usage file

options gen2
require block_macro_mod

def run_block(blk : block) {
    invoke(blk)
}

def apply_to(x : int; blk : block<(v : int) : void>) {
    invoke(blk, x)
}

[export]
def main() {
    print("--- simple block ---\n")
    run_block() $ [traced(tag="setup")] {
        print("  initializing\n")
    }

    print("\n--- block with argument ---\n")
    apply_to(42) $ [traced(tag="process")] (v : int) {
        print("  received {v}\n")
    }
}

The first block is parameterless — it demonstrates the basic [traced] syntax. The second block takes an int parameter, showing that finish() can report its fully-typed arguments.

Both blocks are invoked by helper functions via invoke(blk) / invoke(blk, x). Each invocation executes the modified block body (with the injected enter/exit prints).

Compile-time output (from finish):

[traced] "setup": 1 statement(s)
[traced] "process": 1 statement(s), args = (v:int const)

Runtime output:

--- simple block ---
>> setup
  initializing
<< setup

--- block with argument ---
>> process
  received 42
<< process

5.4.7.5. Compilation pipeline summary

The full sequence for a [traced] block:

parse block closure
  ↓
apply() → validate tag, prepend enter-print, append exit-print to finalList
  ↓
infer types (block arguments and body are inferred)
  ↓
optimize
  ↓
finish() → compile-time diagnostic (statement count, typed args)
  ↓
simulate → invoke block at runtime (enter-print, user code, exit-print)

Unlike structure macros, there is no patchre-infer cycle. All code injection happens in apply() before the first inference pass.

5.4.7.6. Key takeaways

Concept

What it does

[block_macro(name="X")]

Registers a class as a block annotation

AstBlockAnnotation

Base class with apply and finish methods (no patch)

apply()

Pre-inference: modify statement list, inject code, validate args

finish()

Final: read-only diagnostics and reporting with resolved types

blk.list

The block’s statement array — modify to inject code

blk.finalList

The block’s finally section — runs on exit, like a finally block

blk.arguments

The block’s parameter list (VariablePtr elements)

emplace(vec, val, 0)

Insert at a specific position in a managed vector

find_arg

Look up annotation argument values by name

describe(arg._type)

Human-readable type name from a TypeDeclPtr

$ [ann(args)] (params) { body }

Block annotation syntax — annotation between $ and parameters

See also

Full source: block_macro_mod.das, 07_block_macro.das

Previous tutorial: tutorial_macro_structure_macro

Next tutorial: tutorial_macro_variant_macro

Standard library examples: daslib/decs_boost.das (REQUIRE / REQUIRE_NOT — marker block annotations), daslib/defer.das (finalList manipulation), daslib/heartbeat.das (emplace(list, expr, 0) prepend pattern)

Language reference: Macros — full macro system documentation