Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source
The matrix is declared in annotations and KSP generates the dispatcher, the
Cells interface with one member per HANDLE cell and effect, and the
effect-handler surface. The generated code is never committed; the runtime,
tabular-center-core, compiles against an empty classpath – no dependencies, not
even coroutines.
@Machine names the types; each @Row is a state; each CellSpec a cell. The
annotated interface’s single handle is the prototype: every generated
member copies its modifiers, receiver and visibility.
@Machine(
states = [S.Idle::class, S.Running::class],
actions = [A.Start::class, A.Stop::class],
effects = [F.Beep::class],
initial = S.Idle::class,
)
// Start Stop
@Row(S.Idle::class, [CellSpec(Kind.HANDLE), CellSpec(Kind.IGNORE)])
@Row(S.Running::class, [CellSpec(Kind.IGNORE), CellSpec(Kind.HANDLE)])
internal interface StopwatchSpec {
fun Clock.handle(ctx: Ctx, state: S, action: A): Step<S, F>
}
From tabular-center-kotlin/examples/06-generated/src/Stopwatch.tb.kt.
The types it names are ordinary sealed hierarchies:
internal sealed interface S {
data object Idle : S
data class Running(val since: Long) : S
}
From tabular-center-kotlin/examples/06-generated/src/StopwatchTypes.kt.
KSP turns the two HANDLE cells and the one effect into three abstract members.
Because the prototype is fun Clock.handle(...), every one of them is an
extension on Clock – a cell calls now() without being handed a clock, and
nothing can dispatch without one in scope:
internal class StopwatchImpl : Cells {
override fun Clock.idleStart(ctx: Ctx, state: S.Idle, action: A.Start): Step<S, F> =
Step.Go(S.Running(now()))
override fun Clock.runningStop(ctx: Ctx, state: S.Running, action: A.Stop): Step<S, F> {
ctx.lastElapsed = now() - state.since
return Step.Go(S.Idle, listOf(F.Beep))
}
override fun Clock.beep(ctx: Ctx, effect: F.Beep): A? {
ctx.beeps++
return null
}
}
From tabular-center-kotlin/examples/06-generated/src/StopwatchImpl.kt.
Receivers, suspend, internal, @Composable – whatever the prototype
carries, the generated surface carries. Nothing in the generator enumerates
them.
A dispatcher that is an exhaustive when over state then action, calling your
members for HANDLE cells and building the static ones inline. For the Timer,
the shape is exactly this (hand-written in the kotlinc-only example, so it
can be read without running Gradle). An interface with one member per
HANDLE cell and per effect, then a when with no else:
interface Cells {
fun idleStart(ctx: Ctx, state: S.Idle, action: A.Start): Step<S, F>
fun runningTick(ctx: Ctx, state: S.Running, action: A.Tick): Step<S, F>
fun startClock(ctx: Ctx, effect: F.StartClock): A?
fun stopClock(ctx: Ctx, effect: F.StopClock): A?
}
From tabular-center-kotlin/examples/02-timer/src/Timer.kt.
fun step(cells: Cells, ctx: Ctx, s: S, a: A): Step<S, F> = when (s) {
is S.Idle -> when (a) {
is A.Start -> cells.idleStart(ctx, s, a)
is A.Tick -> Step.Ignored
is A.Cancel -> Step.Ignored
}
is S.Running -> when (a) {
is A.Start -> Step.Ignored
is A.Tick -> cells.runningTick(ctx, s, a)
is A.Cancel -> Step.Go(S.Idle, listOf(F.StopClock(Reason.Cancelled)))
}
is S.Done -> when (a) {
is A.Start -> Step.Go(S.Running(0), listOf(F.StartClock))
is A.Tick -> Step.Ignored
is A.Cancel -> Step.Ignored
}
}
From tabular-center-kotlin/examples/02-timer/src/Timer.kt.
The when is in generated code only. Your file has no when to put an else
in, which is what turns Kotlin’s weakest guarantee – exhaustiveness that one
else defeats – into one as strong as Rust’s.
error: class 'Hole' is not abstract and does not implement abstract member
kotlinc’s own message, required by a fixture on every run. Declaration
mistakes – an unknown state in a @Row, a row with the wrong number of cells
– are refused by the processor with a tabular-center:: code and a pointer at the
annotation (Diagnostics).
Kind.DELEGATE names the child’s annotated interface; the parent requires the
child’s whole surface:
@Machine(
states = [S.Idle::class, S.Retrying::class, S.Done::class],
actions = [A.Run::class, A.Tick::class, A.Cancel::class],
effects = [F.Log::class],
initial = S.Idle::class,
)
// Run Tick Cancel
@Row(S.Idle::class, [CellSpec(Kind.HANDLE), CellSpec(Kind.IGNORE), CellSpec(Kind.IGNORE)])
@Row(S.Retrying::class, [CellSpec(Kind.DELEGATE, child = RetrySpec::class), CellSpec(Kind.DELEGATE, child = RetrySpec::class), CellSpec(Kind.GO, to = S.Done::class, emit = [F.Log::class])])
@Row(S.Done::class, [CellSpec(Kind.IGNORE), CellSpec(Kind.IGNORE), CellSpec(Kind.IGNORE)])
interface JobSpec {
fun handle(ctx: Ctx, state: S, action: A): Step<S, F>
}
From tabular-center-kotlin/examples/06-generated/src/Job.tb.kt.
Make the prototype suspend fun handle(...) and step, perform and every
member suspend; SuspendDriver runs it with no coroutine library, from
tabular-center-core alone. A suspending child under a plain parent does not compile.
A Step is a value, so a cell can be written from small decisions rather than
assembled in one expression: map relabels a target, flatMap chains a
decision onto a transition (its effects follow the first step’s), and zip
combines two.
fun enter(door: Door): Step<Door, Signal> =
if (door == Door.Open) Step.go(Door.Open, Signal.Chime) else Step.go(door)
From tabular-center-kotlin/test/StepAlgebra.kt.
fun unlock(codeOk: Boolean): Step<Door, Signal> {
val decided: Step<Door, Signal> = if (codeOk) Step.go(Door.Open) else Step.stay(Signal.Buzz)
return decided.flatMap(::enter)
}
From tabular-center-kotlin/test/StepAlgebra.kt.
fun openBoth(leftOk: Boolean, rightOk: Boolean): Step<Pair<Door, Door>, Signal> =
unlock(leftOk).zip(unlock(rightOk))
From tabular-center-kotlin/test/StepAlgebra.kt.
Stay and Ignored short-circuit: unlock(false) stays and buzzes without
calling enter, and zip drops the right side’s effects when the left does
not move. An Ignored anywhere absorbs, and the whole step emits nothing. The
rules and the laws are in Cells, section 6, and all three
implementations replay the same cases.
A second prototype, render, turns a state into whatever a UI needs, and the
generator gives it the same guarantee as the cells: one required member per
state, the state narrowed, and a render dispatcher with no else. It has
its own color, so @Composable goes here and never on a transition. The
Compose example describes its connection states through it:
interface ConnectionSpec {
fun handle(ctx: Ctx, state: S, action: A): Step<S, F>
fun render(state: S): Pair<String, String>
}
From tabular-center-kotlin/examples/07-compose/src/Machine.tb.kt.
object ConnectionDescriptions : Renders {
override fun renderIdle(state: S.Idle): Pair<String, String> = "Idle" to "Nothing connected."
override fun renderConnecting(state: S.Connecting): Pair<String, String> = "Connecting" to "Dialling..."
override fun renderLive(state: S.Live): Pair<String, String> = "Live" to "Connected at ${state.since}."
override fun renderFailed(state: S.Failed): Pair<String, String> = "Failed" to "The connection dropped."
}
From tabular-center-kotlin/examples/07-compose/src/ConnectionStateMachine.kt.
render(ConnectionDescriptions, state) returns the pair. Add a state and this
object stops compiling until it says how that state reads.
Not on Maven Central yet. The examples consume the repository the way you can today: the runtime as sources, the processor as an included build.
plugins {
kotlin("jvm") version "2.4.21"
id("com.google.devtools.ksp") version "2.3.12"
}
From tabular-center-kotlin/examples/06-generated/build.gradle.kts.
sourceSets["main"].kotlin.srcDir("src")
sourceSets["main"].kotlin.srcDir("../../core")
sourceSets["main"].kotlin.srcDir("../../annotations")
From tabular-center-kotlin/examples/06-generated/build.gradle.kts.
dependencies {
ksp("center.tabula:tabular-center-ksp:0.1.0")
}
From tabular-center-kotlin/examples/06-generated/build.gradle.kts.
includeBuild("../../ksp") {
dependencySubstitution {
substitute(module("center.tabula:tabular-center-ksp"))
.using(project(":"))
.because("the example builds the processor from source, not from a repository")
}
}
From tabular-center-kotlin/examples/06-generated/settings.gradle.kts.
The planned artifacts are tabular-center-core (runtime), tabular-center-annotations
(compile-only), tabular-center-ksp (the processor) and tabular-center-testing (a .tbl
fixture harness), so a production build carries the runtime and nothing else.
In tabular-center-kotlin/examples:
| adds | ||
|---|---|---|
| 1-4 | traffic-light, timer, retry, login |
the four core machines, compiled by kotlinc against core alone where they can be |
| 5 | suspend |
a suspending machine and SuspendDriver, with no coroutine library |
| 6 | generated |
the annotation surface above, through KSP |
| 7 | compose |
a Compose Desktop app driven by a machine |