5.4.3. Macro Tutorial 3: Function Macros

This tutorial builds three function macros that demonstrate the three main methods of AstFunctionAnnotation:

  • ``[log_calls]`` uses apply() to rewrite a function’s body, adding entry/exit logging with nested-call indentation.

  • ``[expect_range]`` uses verifyCall() to validate every call site, rejecting constant arguments that fall outside a given range.

  • ``[no_print]`` uses lint() to walk the fully-compiled body and reject calls to the builtin print function.

[log_calls]
def add(a, b : int) : int {
    return a + b
}

At compile time, [log_calls] rewrites the function body to:

def add(a, b : int) : int {
    if (true) {
        print(">> ")
        print("add({a}, {b})\n")
        if (true) {
            return a + b       // original body
        }
    } finally {
        print("<< add\n")
    }
}

Recursive calls produce indented output that visualizes the call tree:

>> fib(3)
  >> fib(2)
    >> fib(1)
    << fib
    >> fib(0)
    << fib
  << fib
  >> fib(1)
  << fib
<< fib

New concepts introduced:

  • ``[function_macro]`` — macro kind that modifies functions

  • ``AstFunctionAnnotation`` — base class with apply(), verifyCall(), and lint() methods

  • ``apply()`` — transforms a function’s AST at definition time

  • ``verifyCall()`` — validates each call site after type inference

  • ``lint()`` — walks the fully-compiled AST after all types are resolved

  • ``ExprStringBuilder`` — build string interpolations from AST nodes

  • ``qmacro_block`` with ``finally`` — generate statement blocks with cleanup sections

  • ``if (true) { … }`` — scoping trick for variable isolation

  • ``$e(func.body)`` — inject the original function body

  • ``func.body |> move`` — replace the function body

  • ``var public`` — module-level mutable state shared across modules

  • ``AnnotationArgumentList`` — reading annotation argument names and values (iValue)

  • ``ExprConstInt`` — extracting compile-time integer values from AST

  • ``AstVisitor`` — walking the compiled AST tree

  • ``make_visitor`` / ``visit()`` — adapting and running a visitor

  • ``expr.func._module.name`` — identifying a function’s source module

5.4.3.1. Prerequisites

You should be comfortable with the material in tutorial_macro_call_macro and tutorial_macro_when_expression[call_macro], visit(), qmacro, $e(), $i(), and qmacro_block.

5.4.3.2. Function macros vs. call macros

Call macros (tutorials 1 and 2) transform call expressions — they receive a call site and return a replacement expression.

Function macros transform function definitions. They receive a FunctionPtr and modify the function’s AST (body, arguments, return type, annotations) before the function is compiled. The base class is AstFunctionAnnotation and it provides several overridable methods:

  • ``apply()`` — runs once when the function is compiled. Use it to transform the function’s body, arguments, or annotations.

  • ``verifyCall()`` — runs at every call site after type inference. Use it to validate arguments (return false to reject the call).

  • ``transform()`` — runs at every call site and can replace the call expression entirely (used by [constant_expression] in daslib).

  • ``lint()`` — runs after the function is fully compiled (types resolved, overloads selected). Use it to validate structural properties of the finished AST.

This tutorial demonstrates apply() with [log_calls], verifyCall() with [expect_range], and lint() with [no_print].

5.4.3.3. Part 1: [log_calls] — apply()

The apply() method receives the function being compiled and can modify its AST arbitrarily:

[function_macro(name="log_calls")]
class LogCallsMacro : AstFunctionAnnotation {
    def override apply(var func : FunctionPtr;
                       var group : ModuleGroup;
                       args : AnnotationArgumentList;
                       var errors : das_string) : bool {
        // ... transform func ...
        return true
    }
}

Returning true from apply() means the transformation succeeded. Returning false aborts compilation with an error.

5.4.3.4. Building the call signature string

To log which function was called and with what arguments, we need a string like "add(2, 3)\n" at runtime. This must be built as an ExprStringBuilder — a compile-time AST node that generates string interpolation code:

var inscope call_sb <- new ExprStringBuilder(at = func.at)
call_sb.elements |> emplace_new <| qmacro($v("{string(func.name)}("))
for (i, arg in count(), func.arguments) {
    if (i > 0) {
        call_sb.elements |> emplace_new <| quote(", ")
    }
    call_sb.elements |> emplace_new <| qmacro($i(arg.name))
}
call_sb.elements |> emplace_new <| quote(")\n")

How it works:

  • ``ExprStringBuilder`` is the AST node behind daslang’s "..." string interpolation. Its elements array holds a mix of literal strings and expression nodes that become {expr} segments.

  • ``$v(“text”)`` (value) injects a constant string — here the function name and opening parenthesis.

  • ``$i(arg.name)`` (identifier) creates a variable reference — at runtime it evaluates to the argument’s actual value.

  • ``quote(“text”)`` creates a literal string expression — used for fixed separators like ", " and ")\n".

  • ``count()`` and ``func.arguments`` — the arguments array on FunctionPtr holds the function’s parameter declarations. count() provides a 0-based index for the comma-separator logic.

For add(a, b : int), this produces the equivalent of:

"{string(func.name)}({a}, {b})\n"

which at runtime evaluates to "add(2, 3)\n".

5.4.3.5. The if(true) scoping pattern

The generated code uses if (true) { ... } blocks that may look redundant. They serve a real purpose — each if (true) { ... } creates a new lexical scope. This is important because:

  1. The macro may introduce local variables (like ref_time in extended versions). Scoping prevents name clashes with the original body.

  2. The original body may contain return statements. Wrapping it in a scope ensures finally still runs.

  3. Multiple [log_calls] annotations (or other function macros) can each add their own scoped variables without conflicts.

5.4.3.6. Constructing the replacement body

The heart of the macro builds a new function body using qmacro_block. This generates a statement list (ExprBlock) rather than a single expression:

var inscope new_body <- qmacro_block() {
    if (true) {
        print("{repeat("  ",LOG_DEPTH++)}>> ")
        print($e(call_sb))
        if (true) {
            $e(func.body)
        }
    } finally {
        print("{repeat("  ",--LOG_DEPTH)}<< {$v(string(func.name))}\n")
    }
}

Key details:

  • ``qmacro_block() { … }`` produces an ExpressionPtr containing a block of statements (an ExprBlock). Unlike qmacro() which produces a single expression, qmacro_block can hold multiple statements, if/else, finally, etc.

  • ``$e(call_sb)`` splices in the ExprStringBuilder we built earlier. At runtime this becomes the print("add(2, 3)\n") call.

  • ``$e(func.body)`` splices the function’s original body into the inner if (true) block. This is the key technique — the macro wraps the original code rather than replacing it.

  • ``finally { … }`` — the generated block has a finally section that runs even when the original body executes a return. This guarantees the exit log line is always printed and LOG_DEPTH is decremented.

  • ``LOG_DEPTH++`` / ``–LOG_DEPTH`` — pre/post-increment controls indentation depth. repeat("  ", LOG_DEPTH++) prints the current depth’s indentation then increments; --LOG_DEPTH decrements before printing the exit indentation.

  • ``$v(string(func.name))`` injects the function name as a compile-time constant string into the exit log.

5.4.3.7. Replacing the function body

Finally, we swap the function’s body with our new block:

func.body |> move <| new_body
return true

move replaces func.body with new_body and clears new_body. This is the standard pattern for function body replacement in apply() — the old body has already been captured inside the new one via $e(func.body), so no information is lost.

5.4.3.8. Public variables for shared state

LOG_DEPTH is declared at module scope with var public:

var public LOG_DEPTH = 0

This makes it accessible from any module that require-s function_macro_mod. Each [log_calls] function increments it on entry and decrements on exit, producing correct indentation for nested calls. Because it is a single global variable, it tracks depth across all annotated functions — not just recursive ones.

5.4.3.9. Part 2: [expect_range] — verifyCall()

While apply() transforms the function at definition time, verifyCall() runs at every call site after type inference. It receives the call expression and can accept or reject it.

[function_macro(name="expect_range")]
class ExpectRangeMacro : AstFunctionAnnotation {
    def override verifyCall(var call : smart_ptr<ExprCallFunc>;
                            args, progArgs : AnnotationArgumentList;
                            var errors : das_string) : bool {
        // ... validate call.arguments ...
        return true
    }
}

The parameters:

  • ``call`` — the call expression at the call site. call.func is the function being called, call.arguments are the argument expressions.

  • ``args`` — the annotation’s argument list (e.g., for [expect_range(value, min=0, max=255)], it contains three entries).

  • ``errors`` — an output string for the error message. Set it and return false to produce a compile error.

Returning false emits error code 40102 (annotation_failed).

5.4.3.10. Reading annotation argument values

Annotation arguments like [expect_range(value, min=0, max=255)] are stored in an AnnotationArgumentList. Each entry has a name and typed value fields:

var arg_name = ""
var range_min = int(0x80000000)
var range_max = int(0x7FFFFFFF)
for (aa in args) {
    if (aa.basicType == Type.tBool) {
        arg_name = string(aa.name)   // bare name: "value"
    } elif (aa.name == "min") {
        range_min = aa.iValue         // integer value: 0
    } elif (aa.name == "max") {
        range_max = aa.iValue         // integer value: 255
    }
}

How it works:

  • Bare names like value in [expect_range(value, ...)] are stored with basicType == Type.tBool and their name is the argument identifier. This is how [constexpr(a)] in daslib’s constant_expression.das identifies which function parameter to check.

  • Named values like min=0 are stored with their name ("min") and the integer value in iValue.

  • ``string(aa.name)`` — converts the name for comparison. For name == "min" the comparison works directly.

5.4.3.11. Extracting constant integer values

To check whether a call-site argument is a compile-time constant and extract its value, we need a helper that navigates the AST:

[macro_function]
def public getConstantInt(expr : ExpressionPtr;
                          var result : int&) : bool {
    if (expr is ExprRef2Value) {
        return getConstantInt(
            (expr as ExprRef2Value).subexpr, result)
    } elif (expr is ExprConstInt) {
        result = (expr as ExprConstInt).value
        return true
    }
    return false
}

Key details:

  • ``ExprRef2Value`` — the compiler sometimes wraps constant values in a reference-to-value conversion node. The helper unwraps it recursively via .subexpr.

  • ``ExprConstInt`` — one of the ExprConst* family of AST nodes (ExprConstFloat, ExprConstString, ExprConstBool, etc.). All have a .value field of the corresponding type.

  • ``is`` / ``as`` — daslang’s type-test and downcast operators work on AST node types just like on classes.

  • ``[macro_function]`` — marks the function as available during compilation (macro expansion time). Without this annotation, the function would only exist at runtime.

Daslib’s constant_expression.das uses a more general approach: expr.__rtti |> starts_with("ExprConst") checks for any constant type. Our helper is specific to integers because [expect_range] only makes sense for numeric bounds.

5.4.3.12. Reporting compile-time errors

The error reporting pattern is straightforward — set the errors string and return false:

var val = 0
if (getConstantInt(ce, val)) {
    if (val < range_min || val > range_max) {
        errors := "{arg_name} = {val} is out of range [{range_min}..{range_max}]"
        return false
    }
}

The compiler wraps this into a full error message:

error[40102]: call annotated by expect_range failed
_test_error.das:12:4
    set_channel("red", 300)
    ^^^^^^^^^^^
value = 300 is out of range [0..255]

The error code 40102 (annotation_failed) is always the same for verifyCall failures. The string you set in errors becomes the detail message below the source location.

5.4.3.13. Runtime values pass through

An important design decision: verifyCall only checks constant arguments. When a runtime variable is passed, getConstantInt returns false and the call is allowed:

var alpha = 200
set_channel("alpha", alpha)   // runtime value — compiles fine

This is intentional. verifyCall is a best-effort compile-time check — it catches mistakes in literal arguments but cannot validate runtime expressions. For runtime validation, you would use apply() to inject runtime bounds checks into the function body.

5.4.3.14. Part 3: [no_print] — lint()

The lint() method runs after the function is fully compiled — types are resolved, overloads are selected, and the AST is ready to simulate. This makes it ideal for structural validation that needs complete type information.

[function_macro(name="no_print")]
class NoPrintMacro : AstFunctionAnnotation {
    def override lint(var func : FunctionPtr;
                      var group : ModuleGroup;
                      args, progArgs : AnnotationArgumentList;
                      var errors : das_string) : bool {
        // ... walk func body ...
        return true
    }
}

Compared to the other methods:

  • ``apply()`` — runs at definition time, before type checking. The body has parsed expressions but types may not be resolved yet.

  • ``verifyCall()`` — runs at each call site after type inference. Has access to call arguments but not the full function body.

  • ``lint()`` — runs after everything is compiled. The function’s body has full type annotations, ExprCall nodes have their .func pointers linked to the resolved Function objects.

Ironic contrast: [log_calls] adds print calls to every function, while [no_print] forbids them.

5.4.3.15. Walking the AST with a visitor

To inspect the function body, lint() uses the visitor pattern. We define a class that inherits from AstVisitor and overrides preVisitExprCall to intercept function calls:

[macro]
class NoPrintVisitor : AstVisitor {
    found_print : bool = false
    @safe_when_uninitialized print_at : LineInfo
    def override preVisitExprCall(
            expr : smart_ptr<ExprCall>) : void {
        if (expr.func != null
                && expr.name == "print"
                && expr.func._module.name == "$") {
            found_print = true
            print_at = expr.at
        }
    }
}

Key details:

  • ``[macro]`` — required annotation for classes used during compilation. Without it, the visitor class would not exist at macro expansion time.

  • ``preVisitExprCall`` — called before each ExprCall node in the AST walk. The AstVisitor base class has preVisit* and visit* hooks for every AST node type (ExprFor, ExprWhile, ExprNew, etc.).

  • ``@safe_when_uninitialized``LineInfo is a struct with no default initializer. This annotation tells the compiler it is intentionally left uninitialized until found_print is set.

  • ``expr.func`` — at lint time, this pointer is always linked to the resolved Function object (unlike at apply() time where function resolution may not be complete).

5.4.3.16. Checking the function’s module

The check expr.func._module.name == "$" distinguishes the builtin print from any user-defined function that happens to be named print:

  • ``_module`` — the field name uses an underscore prefix because module is a reserved keyword in daslang. In C++ the field is Function::module; in daslang macros it is func._module.

  • ``”$”`` — the builtin module name. All built-in functions (print, assert, length, math functions, etc.) belong to module "$".

  • This pattern is used throughout daslib — for example, daslib/lint.das checks expr.func._module.name |> eq <| "$" to detect calls to panic.

5.4.3.17. Running the visitor from lint()

The lint() method creates the visitor, adapts it with make_visitor, and walks the function body with visit():

def override lint(...) : bool {
    var astVisitor = new NoPrintVisitor()
    var inscope adapter <- make_visitor(*astVisitor)
    visit(func, adapter)
    if (astVisitor.found_print) {
        errors := "function {string(func.name)} must not call builtin print"
        unsafe { delete astVisitor; }
        return false
    }
    unsafe { delete astVisitor; }
    return true
}
  • ``make_visitor(*astVisitor)`` — wraps the daslang visitor object into an adapter that the C++ visit() function can call. The * dereferences the smart pointer.

  • ``visit(func, adapter)`` — walks the function’s AST, calling the visitor’s preVisit* / visit* hooks at each node.

  • Error reporting uses the same pattern as verifyCall() — set errors and return false. The compiler emits error code 40102 with message text "function annotation lint failed" plus your detail string.

  • ``unsafe { delete astVisitor; }`` — explicit cleanup of the heap-allocated visitor. Required because new allocates on the heap and the visitor is not managed by inscope.

5.4.3.18. Usage examples

[log_calls] — simple functions:

[log_calls]
def add(a, b : int) : int {
    return a + b
}

[log_calls]
def greet(name : string) {
    print("hello, {name}!\n")
}

Calling add(2, 3) produces:

>> add(2, 3)
<< add

Calling greet("daslang") produces:

>> greet(daslang)
hello, daslang!
<< greet

Recursive functions show the call tree via indentation:

[log_calls]
def fib(n : int) : int {
    if (n <= 1) {
        return n
    } else {
        return fib(n - 1) + fib(n - 2)
    }
}

Calling fib(3) produces:

>> fib(3)
  >> fib(2)
    >> fib(1)
    << fib
    >> fib(0)
    << fib
  << fib
  >> fib(1)
  << fib
<< fib

[expect_range] — compile-time bounds checking:

[expect_range(value, min=0, max=255)]
def set_channel(name : string; value : int) {
    print("  {name} = {value}\n")
}

Valid calls compile normally:

set_channel("red", 128)    // ok
set_channel("green", 0)    // ok
set_channel("blue", 255)   // ok

Out-of-range constants are rejected at compile time:

// set_channel("red", 300)   // compile error!
// error[40102]: call annotated by expect_range failed
//   value = 300 is out of range [0..255]

Runtime variables are allowed through:

var alpha = 200
set_channel("alpha", alpha)   // runtime value — passes through

[no_print] — lint-time structural validation:

[no_print]
def compute(a, b : int) : int {
    return a * b + 1
}

This compiles — compute has no print calls. Adding [no_print] to a function that calls print fails at lint time:

// [no_print]   // uncomment to see:
// error[40102]: function annotation lint failed
//   function bad_compute must not call builtin print
def bad_compute(a, b : int) : int {
    print("computing {a} * {b}\n")
    return a * b
}

5.4.3.19. Extending with timing

A natural extension is to add execution timing using ref_time_ticks() and get_time_nsec(). The pattern is the same — the only addition is a local timing variable in the generated body:

var inscope new_body <- qmacro_block() {
    if (true) {
        let ref_time = ref_time_ticks()
        print(">> ")
        print($e(call_sb))
        if (true) {
            $e(func.body)
        }
    } finally {
        print("<< {$v(string(func.name))} - {get_time_nsec(ref_time)}ns\n")
    }
}

The if (true) scope keeps ref_time local to each instrumented function, preventing name clashes when multiple [log_calls] functions call each other.

5.4.3.20. Running the tutorial

daslang.exe tutorials/macros/03_function_macro.das

Expected output:

>> add(2, 3)
<< add
sum = 5

>> greet(daslang)
hello, daslang!
<< greet

>> fib(3)
  >> fib(2)
    >> fib(1)
    << fib
    >> fib(0)
    << fib
  << fib
  >> fib(1)
  << fib
<< fib
fib(3) = 2

color channels:
  red = 128
  green = 0
  blue = 255
  alpha = 200

compute = 13

See also

Full source: function_macro_mod.das, 03_function_macro.das

Previous tutorial: tutorial_macro_when_expression

Next tutorial: tutorial_macro_advanced_function_macro

Language reference: Macros — full macro system documentation