Comptime AST Injection: Every Event Can Walk the Program
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:
- Parse the JSON-like configs
- Generate
@embedFilecalls for file routes - Generate inline responses for body routes
- 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.*