tabular-center

tabular-center Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source

Kotlin

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.

The matrix

@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.

The code you write

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.

What KSP generates

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.

When a cell is missing

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).

Composition and suspend

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.

Composing steps

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.

Rendering

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.

Setting it up

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.

Examples

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