Inikio

Better initial-style DSLs in Kotlin   


Gradle set-up

Inikio is available through Jitpack.

repositories {
maven(url = "https://jitpack.io")
}
dependencies {
implementation("com.github.serras.inikio:inikio-core:$inikioVersion")
}

Initial-style DSLs

Sometimes you need to model actions or behaviors as part of your model. For example, rules for filtering data, smart contracts, or trading card games. In most cases these actions come from a particular language, called a domain-specific language (DSL for short). Some people separate those actions from the programming language were they are used by means tools like JetBrains MPS, but here we're concerned with actually representing those actions in our favorite programming language, Kotlin.

There are different patterns to embed DSLs in an existing language. Initial-style is one of them, referring to the case in which the actions are represented as data. Here's an example showing the basic elements of this pattern:

sealed interface Casino<out A>

data class Done<out A>(val result: A): Casino<A>
data class FlipCoin<out A>(val next: (Outcome) -> Casino<A>): Casino<A> {
enum class Outcome { HEADS, TAILS }
}
  1. An interface Casino which forms the top of the sealed hierarchy.

  2. A few primitive actions; in this case only one, FlipCoin. Those actions from the basis of what you can express using your language.

  3. A data class which "ends" the actions; in this case Done.

Each of the primitive actions in (2) also follow a particular shape. Their last argument is a continuation, a function which specifies the "next" action to be executed after this one. That function has as argument the type of data which is "consumed" by the action, and always refers back to the top of the sealed hierarchy as result.

Using Casino we can express different games which depend on flipping coins. For example, here's a game in which you flip two coins, and you win whenever both are heads. Kotlin's ability to drop parentheses for a block argument gives us nice syntax to express what to do next after each FlipCoin.

val doubleCoin =
FlipCoin { o1 ->
FlipCoin { o2 ->
if (o1 == Outcome.HEADS && o2 == Outcome.HEADS)
Done(WIN)
else
Done(LOSE)
}
}

Execution

Values of that initial-style DSL can be executed in different ways (sometimes we also say they are interpreted). In fact, the main advantage of describing actions using this pattern is that writing those interpretations is much simpler than in others. In most cases, you have a big when to handle each of the primitive instructions, which calls itself (tail recursively) with the continuation.

tailrec fun <A> Casino<A>.execute(): A =
when(this) {
is Done -> result
is FlipCoin -> next(Random.nextFlipOutcome()).execute()
}

fun Random.nextFlipOutcome(): FlipCoin.Outcome =
if (nextBoolean()) FlipCoin.Outcome.HEADS else FlipCoin.Outcome.TAILS

suspended syntax

As useful as it is for writing interpretations, initial-style DSLs suffer from a not-so-nice interface for creating values. The doubleCoin above is a prime example: you need to nest everytime you use a primitive action, and remember to wrap the final result with Done. Conceptually, though, that problem is a sequence of operations, and we would like that to be reflected in the way we write our code.

Fortunately, Kotlin's coroutine system gives us enough tools to fulfill our wishes. Inikio just wraps them in a nice package, and provides a small compiler plug-in for the boilerplate we need to write. The main idea is to have a Builder in which each primitive operation is represented as a suspended method, and a runner which turns the Builder into the actual initial-style DSL.

// the builder class
class CasinoBuilder<A>: ProgramBuilder<Casino<A>, A> {
suspend fun flipCoin(): FlipCoin.Outcome
}
// the runner
fun <A> casino(block: CasinoBuilder<A>.() -> A): Casino<A>

The doubleCoin example can be re-written as follows.

val doubleCoin = casino {
val o1 = flipCoin()
val o2 = flipCoin()
if (o1 == Outcome.HEADS && o2 == Outcome.HEADS) WIN
else LOSE
}

At the core of this technique we have the ProgramBuilder class, which implements a state machine on top of coroutines (thanks to Simon Vergauwen and Raúl Raja for teaching it to me!). The details are not important, but the general idea is that by turning each primitive operation into a suspended function, the runner can "detect" when it's called, and produce the corresponding data class instance from the initial-style DSL.

The great news is that you can get all the benefits of this nicer style without having to write anything else than the initial-style DSL. The compiler plug-in generates the Builder and the runner for you.

Packages

Link copied to clipboard
common

The core ProgramBuilder and corresponding runner. Every DSL generated by the plug-in is based on these two.

Link copied to clipboard
common

Information about Inikio's compiler plug-in, that creates Builders automatically for your initial-style DSLs.