Source-Blocks: Declare the Variables They Introduce
A Source-block is a block of text a transform interprets. It’s how Koru does
meta-programming: you write a little DSL inside braces, and a compile-time transform
decides what it means. The interesting design question isn’t what you can put in a
source-block — it’s how the block is handled. And working on the kernel’s reduce surfaced what seems to be the first time a source-block ever minted a
variable of its own.
The old way: a name minted from use
The version that shipped first let a source-block name a thing just by using it — std/kernel:reduce { total_x += k.x; count += 1 }. total_x appeared out of
nowhere: the transform scanned the text, saw a += under a name that was never
declared, guessed its type, and let it escape to the caller. It worked, but it was
magic — a name existed because you typed it under an operator, with no declaration
anywhere.
The mechanism: declare scoped, typed slots
The fix is the general mechanism, and it’s a declaration — the same spirit as a const block, but lighter:
import std/kernel
import std/io
std/kernel:shape(Pt) {
x: f64,
mass: f64,
}
std/kernel:init(Pt) {
{ x: 0.0, mass: 1.0 },
{ x: 1.0, mass: 2.0 },
{ x: 2.0, mass: 3.0 },
}
| kernel k |> std/kernel:reduce {
total_mass[f64]
count[i32]
total_mass += k.mass
count += 1
}
| reduce r |> std/io:print.blk {
total={{ r.total_mass:f }} count={{ r.count:d }}
} name[type] mints a scoped, typed slot. Nothing is inferred — count is i32 because you said so. And the transform decides what a declared slot is: a
block-local workspace, or — as here — an output that rides the enclosing
continuation’s binding, so you read it as r.total_mass after the block.
Crucially, a name is never minted from use anymore. Use a name you didn’t declare in a reduce block and the compiler refuses — located, with the name in the message. That’s the difference between a mechanism and a liberty.
The unconventional bit, named honestly
A name declared inside a source-block reaches out and binds onto the enclosing continuation: total_x lives in the reduce block, and you read it as r.total_x outside. That isn’t how most languages behave — an inner block minting a binding
for the outer scope.
It isn’t magic, and the difference from the magic it replaced is the whole point: you declared it. The declaration is exactly what says “this block’s output has a field named this.” The reduce block isn’t a closure computing into a void; it’s the event’s body, and its declared slots are the payload it hands back. Unconventional, but declared, scoped, and transform-decided.
Why this is the strength
It’s tempting to say a source-block is where you can toss anything. That’s the wrong lesson. The right one is: a source-block DSL has a declaration form, and how the transform handles declared slots is what gives the DSL its power. Declare a slot, type it, scope it — and the language gets to decide whether it’s a working variable or a result. That’s a first-class capability, and it’s the seed of the kernel’s whole interface: a body over a binding, with declared slots riding out on the handle.