Comptime AST Injection: Every Event Can Walk the Program

· 6 min read

Sometimes a feature clicks into place so naturally that you wonder why it wasn’t always there. Today we added something simple but powerful: any [comptime] event can now request the program AST and allocator, and the compiler injects them automatically.

The Problem

We were building a route collector for Orisha, a compile-time web framework. Routes are declared with [norun] (metadata-only) events:

~[norun]event route { expr: Expression, source: Source }

~route(GET /) {
    "file": "public/index.html"
}

~route(GET /api/health) {
    "content-type": "application/json",
    "body": "{"status": "ok"}"
}

These routes exist in the AST but generate no code. A separate [comptime] event needs to collect them and generate the routing table. But how does that event see the routes?

Previously, only [comptime|transform] events got AST injection. Transforms receive invocation, item, program, and allocator automatically. But transforms are meant to modify the AST - we just wanted to read it.

The Solution

We extended the injection to ALL [comptime] events. Now you can write:

~[comptime]event collect_routes {
    program: *const Program,
    allocator: std.mem.Allocator
}
| done { route_count: usize }

~[comptime]proc collect_routes {
    std.debug.print("Walking {} items...\n", .{program.items.len});

    var route_count: usize = 0;
    for (program.items) |item| {
        if (item == .flow) {
            const inv = item.flow.invocation;
            // Check if this is a route event...
            if (isRouteEvent(inv)) {
                route_count += 1;
                // Access the Source block config
                for (inv.args) |arg| {
                    if (arg.source_value) |source| {
                        std.debug.print("Config: {s}\n", .{source.text});
                    }
                }
            }
        }
    }
    return .{ .done = .{ .route_count = route_count } };
}

The magic: You declare program and/or allocator as parameters. The compiler sees them, and injects the values automatically. No special annotation needed beyond [comptime].

How It Works

Three changes made this possible:

1. comptime_main() receives the AST

// Before
pub fn comptime_main() void { ... }

// After
pub fn comptime_main(program: *const Program, allocator: Allocator) void { ... }

The compiler’s evaluate_comptime pass now passes ctx.ast and ctx.allocator to the generated comptime entry point.

2. comptime_flowN() functions pass them through

Each comptime flow now receives and forwards these parameters:

pub fn comptime_flow0(program: *const Program, allocator: Allocator) void {
    const result = collect_routes_event.handler(.{
        .program = program,
        .allocator = allocator
    });
    // ...
}

3. emitArgs() injects based on declaration

When generating handler calls, the emitter checks: “Does this event declare program or allocator parameters that weren’t explicitly provided?” If yes, inject them.

// COMPTIME INJECTION: inject program and allocator if event declares them
if (is_comptime_emission) {
    if (event_decl) |event| {
        for (event.input.fields) |field| {
            if (std.mem.eql(u8, field.name, "program") and !already_provided) {
                try emitter.write(".program = program");
            }
            if (std.mem.eql(u8, field.name, "allocator") and !already_provided) {
                try emitter.write(".allocator = allocator");
            }
        }
    }
}

The Result

Running our route collector now produces:

=== ORISHA ROUTE COLLECTOR ===
Walking AST (66 items)...

Route 1: GET /
  Config:
    "file": "public/index.html"

Route 2: GET /about
  Config:
    "file": "public/about.html"

Route 3: GET /api/health
  Config:
    "content-type": "application/json",
    "body": "{"status": "ok"}"

=== Found 3 routes ===

The comptime event walks the entire AST, finds every [norun] route declaration, and extracts both the route expression (GET /) and the Source block configuration.

Why This Matters

Separation of Concerns

  • [norun] = Declare metadata (routes, schemas, configs)
  • [comptime] = Process that metadata at compile time
  • No coupling = The declaration doesn’t know about the collector

Full Zig Power

Remember: Koru [comptime] is full Zig runtime, not just Zig comptime. You can:

  • Read files from disk
  • Make network calls
  • Use any Zig library
  • Allocate freely

The backend is a program that runs during compilation. It’s just code.

Universal Pattern

This pattern works for anything:

  • Route collection (Orisha)
  • Schema generation
  • Dependency analysis
  • Code generation
  • Documentation extraction

Any metadata you can express with [norun] events, you can collect with [comptime] events.

The Source Block Syntax

One thing that made this particularly clean: Koru’s Source blocks are opaque by design.

~route(GET /api/users) {
    "file": "public/users.html",
    "cache": "1h",
    "compress": true
}

This isn’t JSON - and it isn’t any specific syntax at all. Source blocks capture raw text. The JSON-like format is purely a convention we chose for Orisha routes. You could equally write:

~route(GET /api/users) {
    file = public/users.html
    cache = 1h
    compress = true
}

Or YAML, TOML, SQL, HTML, your own DSL - whatever makes sense for your domain. The compiler doesn’t parse it. The collector receives source.text containing exactly what you wrote, character for character.

The language provides the capture mechanism. You choose the syntax.

This is deliberate: different domains want different notations. Routes want something config-like. SQL queries want SQL. Templates want template syntax. By keeping Source blocks opaque, Koru supports all of them without imposing opinions.

What’s Next

With AST injection working, the next step is generating the actual router:

  1. Parse the JSON-like configs
  2. Generate @embedFile calls for file routes
  3. Generate inline responses for body routes
  4. Emit a routing switch

All at compile time. Zero runtime cost. The routes compile into the binary.


The injection feature is available now in the main branch. Try it: declare `program:const Programin any[comptime]` event and watch the AST appear.*