Expression and Source: Comptime Fuel

The opaque strings that power every transform

· 8 min read

Expression and Source: Comptime Fuel

When you write this:

~std.kernel:shape(Body) {
    x: f64,
    y: f64,
    mass: f64,
}

what happens to Body and the field list?

The parser sees an event invocation with an argument in parentheses and a block in braces. It captures both — faithfully, without interpretation — and moves on. It does not know that Body is a type name, or that x: f64 is a field declaration. Those are just strings.

The strings travel through the compilation pipeline as-is, until they reach the transform that declared it wants them. That transform — kernel:shape — is where the interpretation happens. It reads Body, it reads x: f64, y: f64, mass: f64, and it generates a Zig struct definition at compile time. Zero overhead. Zero runtime representation.

The strings are the fuel. The transform is the engine.

The Two Parameter Types

Koru comptime transforms declare their inputs using two special field types.

Expression captures whatever text appears inside the parentheses:

~std.kernel:shape(Body) { ... }        // expr = "Body"
~std.kernel:shape(Vec3) { ... }        // expr = "Vec3"
~std.kernel:pairwise(0..bodies.len) {  // expr = "0..bodies.len"
    k.mass += k.other.mass
}

Not every Expression event needs a block. ~if and ~for take only the condition or range — the body comes through continuations, not a source block:

~if(x > threshold)
| then |> ...
| else |> ...

~for(0..items.len)
| each item |> ...

The transform receives "x > threshold" or "0..items.len" as a string and generates the corresponding Zig control flow. Expression is the only fuel. No Source field is declared.

Note that ~if and ~for appear without module qualifiers — that is not because of Expression. It is a separate, orthogonal annotation: [keyword]. Expression and Source control the call-site shape. [keyword] controls whether the module qualifier is required. The two concepts are independent.

Source captures whatever text appears inside the braces:

~std.kernel:shape(Body) {
    x: f64,
    y: f64,
    mass: f64,
}
// source.text = "x: f64,\n    y: f64,\n    mass: f64,"

In the event declaration, they appear as ordinary field types:

~[comptime|transform]pub event shape {
    expr: Expression,
    source: Source,
    invocation: *const Invocation,
    item: *const Item,
    program: *const Program,
    allocator: std.mem.Allocator
}
| transformed { program: *const Program }

The proc receives expr as []const u8 and source as a struct with a .text field. Both are just strings. What the proc does with them is entirely up to it.

The Implicit Call Site

The important part is what you do not have to write at the call site.

You do not write:

~std.kernel:shape(expr: "Body", source: { x: f64, y: f64, mass: f64 })

You write:

~std.kernel:shape(Body) {
    x: f64,
    y: f64,
    mass: f64,
}

The positions determine the mapping. The parenthesized argument becomes the Expression. The block becomes the Source. No field names. No quotes. The natural form.

The call site still identifies the module — ~std.kernel:shape is clearly a library event, not a built-in. What Expression and Source remove is the field-name ceremony: no expr: "Body", no source: { ... }. The call site reads like syntax because the argument positions carry the meaning, not because the compiler treats it specially.

The convention has a name. Declaring:

expr: Expression,
source: Source,

is the signal. The type names Expression and Source are what activate the implicit call-site mapping. A field of type Expression is filled from the parenthesized argument without requiring the caller to write expr: .... A field of type Source is filled from the block. The field names expr and source are the agreed-upon choice — they could be anything, but the convention exists so that every transform declaration reads the same way.

This is one of the very few naming conventions in the language. Most things in Koru are either enforced by the type system or left entirely to the author. This sits in between: the types enforce the behavior, the names signal the intent.

Both Required, Both Optional

shape and init require both. You cannot call them without a type name and a field block:

~std.kernel:shape(Body) {   // expr required: names the type
    x: f64,                 // source required: declares the fields
    y: f64,
    mass: f64,
}

~std.kernel:init(Body) {    // expr required: which shape to initialise
    x: 42.0,                // source required: the initial values
    y: 0,
    mass: 1.5,
}

pairwise is different. The source (the loop body) is always required. The expression (an outer range) is optional:

// Source only — standard N²/2 pairwise over all elements
~std.kernel:pairwise {
    k.mass += k.other.mass
}

// Expression + Source — outer range wraps the pairwise loop
~std.kernel:pairwise(0..steps) {
    k.mass += k.other.mass
}

This is declared as expr: ?Expression — a nullable expression field. The proc receives null when no parentheses are given, and the actual text when they are:

~[comptime|transform]pub event pairwise {
    expr: ?Expression,   // null when called without (...)
    source: Source,      // always present
    ...
}

Inside the proc:

const has_outer_range = expr != null;

No inspection of invocation.args. No checking source_value fields. The type carries the information.

The Same Principle, Different Scale

In Dumb Boundaries, Smart Middles, we wrote about how annotations are opaque strings — the parser captures [transform] and [fs:open!] without knowing what they mean, and passes claim their vocabulary from the AST later.

Expression and Source are the per-invocation version of the same principle.

When you write ~std.kernel:shape(Body) { x: f64 }, the parser does not know that Body is a type name. It captures the string. When kernel:shape runs, it interprets Body as a Zig type identifier and emits a struct declaration for it. The parser was not there for that decision. Neither was the emitter.

The interpretation lives in the transform. The transform lives in user space. The compiler just moves the strings.

What This Enables

Any comptime event can declare Expression, Source, or both. That is all it takes to acquire a custom call-site syntax.

The @koru/sqlite3 library uses Source alone for parameterized queries. The entire SQL statement — including compile-time variable bindings — lives in the block:

~[comptime|transform]pub event query {
    source: Source,
    invocation: *const Invocation,
    ...
}

Which makes this valid:

libs.sqlite3:query(conn: db) {
    SELECT * FROM users WHERE id = {{user_id:d}}
}
| row r |> ...
| empty |> ...
| err e |> ...

The transform receives the entire SQL text — {{user_id:d}} and all — as source.text. It parses the {{var:type}} placeholders at compile time, replaces them with ?, emits a sqlite3_prepare_v2 call, and generates type-safe sqlite3_bind_* calls for each variable. The call site reads like embedded SQL. No runtime DSL. No string interpolation at runtime. Zero overhead.

conn arrives as a named argument through invocation.args, not as an Expression. Sometimes Source alone is all the fuel you need — the query and its bindings are a single coherent block of text, and the transform knows how to read it.