Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
March 24, 2022 08:51 pm GMT

Combining scripts and DSLs is Kotlins most underrated feature

I discovered Kotlin recently and realized that, for many people, it was only a new syntax for Java. But the more I play with it, the more convinced I am that a combo of two of its features is vastly underrated:

  • The ability to create domain-specific languages (DSL), thanks to some syntactic sugar;
  • The ability to write scripts and create interpreters for them.

You can easily guess how nice these features can play together. We can create a DSL, tailor it to our needs, and use it in scripts. Why scripts? Let me introduce the example I will develop here to illustrate why.

Not long ago, I started keeping track of my personal finances. I wanted to keep track of the total value of everything I own (money in bank accounts, stocks, real estate), and I was disappointed in the third-party services I found. So I developed my own tool to fit my needs. (Want to know more? I wrote a book about it!)

My programs input had to be the values of my accounts over time (I wanted to manually write each value instead of relying on an unreliable API to fetch them automatically). To provide this input, I had quite a few options:

  • I could store them in a database and develop tools to populate it (a GUI, a CLI)
  • I could put them in a JSON file
  • Why not store them in a Kotlin file?

Providing the input in a Kotlin file sounded like a weird thing to do. On the one hand, I could benefit from IntelliJs autocomplete features since I would use the functions and classes I wrote. On the other hand, it meant I would have to rebuild the program each time I updated the input?? How unpractical does it seem?

Here is where scripts come into play. With Kotlin, you can write a script interpreter embedding your domain logic, including a DSL, and write scripts providing the input to that logic.

In this article, well build a minimum viable example, a net worth tracker to demonstrate how it can work. But if you already know Kotlin, you likely already use scripts and DSLs without even knowing it: in your build.gradle.kts file, you are using the Gradle DSL in a script!

The full example is available on GitHub. The main branch contains the final version, and youll find there two branches step-1 and step-2 with the code of the first two sections. I removed the imports from the code examples to make the post lighter, but you can follow the link attached to code blocks to see the full file content.

Step 1: Create the domain logic

We want to write a program that takes accounts and snapshots as inputs and displays a report as an output. A snapshot represents the states of several accounts at a given date, so the class contains a date attribute and a mapping between accounts and their respective value.

// https://tinyurl.com/yckwfds4data class Account(val id: String, val name: String)
// https://tinyurl.com/57vr36mwdata class Snapshot(val date: LocalDate, val balances: Map<Account, Double>)

To create a report, we need to provide a list of accounts and snapshots. Our minimal report will have two features: displaying the account list and displaying each snapshot (with, for each of them, the value of each account).

// https://tinyurl.com/2whmpup6class Report(val accounts: List<Account>, val snapshots: List<Snapshot>) {  fun displayAccountList() {    println("ACCOUNTS:")    accounts.forEach { println("- ${it.name}") }  }  fun displaySnapshots() {    println("SNAPSHOTS:")    snapshots.forEach { snapshot ->      println("* ${snapshot.date}:")      accounts.forEach { account ->        println("  - ${account.name}: ${snapshot.balances[account]?.toString() ?: "-"}")      }    }  }}

As a first step, lets use these classes in the main function. We first create two accounts, then two snapshots defining a value for these accounts. Finally, we create a report and display the accounts and the snapshots list.

// https://tinyurl.com/s8564hy5fun main(args: Array<String>) {  val checking = Account("checking", "Checking")  val savings = Account("savings", "Savings")  val snapshots = listOf(    Snapshot(      LocalDate.parse("2022-01-01"),      mapOf(checking to 1000.0, savings to 2000.0)    ),    Snapshot(      LocalDate.parse("2022-02-01"),      mapOf(checking to 1200.0, savings to 2500.0)    )  )  val report = Report(listOf(checking, savings), snapshots)  report.displayAccountList()  report.displaySnapshots()}

Ok, our program works, but we will improve it in two ways: first, we will make it possible to extract the content of main into a script (so we dont have to rebuild the application when we add new snapshots), then we will create some helpers (our DSL) to make it easier to edit the script.

Step 2: Create a script definition

Using Kotlin to write scripts is unfortunately not a very well-documented feature. It changed a lot in the past, so the tutorials you can find are not always still valid. But it doesnt mean we cannot enjoy it!

The first step is to include some dependencies to our project:

// https://tinyurl.com/5ase8sdrdependencies {  // ...  implementation("org.jetbrains.kotlin:kotlin-scripting-common:1.6.0")  implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:1.6.0")  implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host:1.6.0")}

To be able to write a script interpreter, we need to create two entities: a script definition and a script host. Lets start with the script definition, NwtScript (nwt stands for net worth tracker). It can be as simple as an abstract class annotated with @KotlinScript. To make writing script easier, we can also provide some classes that will automatically be imported, using a ScriptCompilationConfiguration object:

// https://tinyurl.com/yc26svyp@KotlinScript(  fileExtension = "nwt.kts",  compilationConfiguration = NwtScriptConfiguration::class)abstract class NwtScriptobject NwtScriptConfiguration: ScriptCompilationConfiguration({  defaultImports(Account::class, Snapshot::class, Report::class, LocalDate::class)})

We also provide an extension for our scripts; note that, so we enjoy IntelliJs features such as autocomplete, this extension has to be composed of a prefix of your choice, then .kts.

We can now create the script host NwtScriptHost to use this script definition. This class has nothing very complex: it has a public execFile method that accepts a filename, evaluates it using a BasicJvmScriptingHost, and displays possible log messages.

// https://tinyurl.com/2p88u9a5class NwtScriptHost {  private fun evalFile(scriptFile: File): ResultWithDiagnostics<EvaluationResult> {    val compilationConfiguration = createJvmCompilationConfigurationFromTemplate<NwtScript> {      jvm {        // Script will have access to everything in the classpath        dependenciesFromCurrentContext(wholeClasspath = true)      }    }    return BasicJvmScriptingHost().eval(scriptFile.toScriptSource(), compilationConfiguration, null)  }  fun execFile(path: String) {    val scriptFile = File(path)    val res = evalFile(scriptFile)    res.reports.forEach {      if (it.severity > ScriptDiagnostic.Severity.DEBUG) {        println(" : ${it.message}" + if (it.exception == null) "" else ": ${it.exception}")      }    }  }}

As the final step before we create our script, we need to tell IntelliJ about our script definition, so it knows what classes we can access and provide the autocomplete and code annotation features we like.

We need to create a folder src/main/resources/META-INF/kotlin/script/templates and in it an empty file named nwt.script.NwtScript.classname (the full class name + .classname). Here is the part Im least comfortable explaining because I found how to make it work on some forums and not in official documentation .

After creating the file, rebuild the application, and you may need to restart IntelliJ, invalidate caches To be honest, Im not sure exactly what works, but in the end, when you create a .nwt.kts file (lets say my.nwt.kts at the projects root), IntelliJ should tell you there is a script definition available for it .

Put the content of main in the new script file, and if everything went well, you should see that IntelliJ annotates the parameters of Account, for instance:

// https://tinyurl.com/4enfpevmval checking = Account("checking", "Checking")val savings = Account("savings", "Savings")val snapshotJanuary = Snapshot(  LocalDate.parse("2022-01-01"),  mapOf(checking to 1000.0, savings to 2000.0))val snapshotFebruary = Snapshot(  LocalDate.parse("2022-02-01"),  mapOf(checking to 1200.0, savings to 2500.0))val report = Report(  listOf(checking, savings),  listOf(snapshotJanuary, snapshotFebruary))report.displayAccountList()report.displaySnapshots()

To transform our program into an interpreter for our script, we can now update our main function:

// https://tinyurl.com/mpzhf66efun main(vararg args: String) {  if (args.size != 1) {    println("usage: <app> <script file>")  } else {    NwtScriptHost().execFile(args[0])  }}

To run it, we can update the projects run configuration, defining my.nwt.kts in the Program arguments field. You will notice that the application won't be rebuilt if you update the script file and rerun it!

Update the run configuration to include the script name as argument.

It is already lovely, but we can make our script better by providing high-level functions, so we dont feel like writing code!

Step 3: Create builders and the DSL

We will use a well-known design pattern to implement our DSL: the Builder pattern. For instance, to create a Snapshot, we can create a class SnapshotBuilder. Its constructor takes two parameters: the snapshots date and a map of accounts (we will see later how we provide these accounts).

// https://tinyurl.com/4tuffu26class SnapshotBuilder(val date: LocalDate, val accounts: Map<String, Account>) {  private val balances: MutableMap<Account, Double> = mutableMapOf()  fun balance(accountId: String, amount: Double) {    val account = accounts[accountId] ?: throw Exception("Invalid account ID")    balances[account] = amount  }  fun build(): Snapshot {    return Snapshot(date, balances)  }}

To use the builder, we can call its balance method to define the accounts balances; then, we call the build method to get the snapshot.

val builder = SnapshotBuilder(LocalDate.parse("2022-01-01"), accounts)builder.balance("checking", 1000.0)builder.balance("savings", 2000.0)val snapshot = builder.build()

But we can do better. We can define a snapshot function taking two parameters:

  • date: the snapshots date,
  • initialize: a function to initialize the builder. This function will receive the builder as this (thanks to the call to apply), meaning that we will be able to call the balance method without any prefix.
fun snapshot(val date: String, initialize: SnapshotBuilder.() -> Unit): Snapshot {  // Well see soon where `accounts` comes from  return SnapshotBuilder(LocalDate.parse(date), accounts).apply(initialize).build()}val snapshot = snapshot("2022-02-01") {  balance("checking", 1000.0)  balance("savings", 2000.0)}

To me, this is what makes Kotlin great for writing DSLs. When using the snapshot function, you dont realize you are creating and using a builder!

Lets create another builder to build several snapshots as a next step. This new SnapshotsBuilder will use the SnapshotBuilder and embed the snapshot we just saw (renaming it on for a better DSL, youll see):

// https://tinyurl.com/52fbfhhnclass SnapshotsBuilder(val accounts: Map<String, Account>) {  private val snapshots: MutableList<Snapshot> = mutableListOf()  fun on(date: String, initialize: SnapshotBuilder.() -> Unit) {    val snapshotBuilder = SnapshotBuilder(LocalDate.parse(date), accounts).apply(initialize)    snapshots.add(snapshotBuilder.build())  }  fun build(): List<Snapshot> {    return snapshots  }}

We will use this builder via another function, snapshots:

fun snapshots(initialize: SnapshotsBuilder.() -> Unit) {  // Well see soon where `accounts` comes from  snapshots.addAll(SnapshotsBuilder(accounts).apply(initialize).build())}snapshots {  on("2022-01-01") {    balance("checking", 1000.0)    balance("savings", 2000.0)  }  on("2022-02-01") {    balance("checking", 1200.0)    balance("savings", 2500.0)  }}

Lets continue by creating a builder for reports. The ReportBuilder class technically doesnt implement the builder pattern since it doesnt build anything (and doesnt have a build method), but it will still be helpful to us:

// https://tinyurl.com/2v35d9fuclass ReportBuilder(val accounts: List<Account>, val snapshots: List<Snapshot>) {  private val report = Report(accounts, snapshots)  fun accounts() {    report.displayAccountList()  }  fun snapshots() {    report.displaySnapshots()  }}

Same as we did for snapshots, we can create a report function.

fun report(initialize: ReportBuilder.() -> Unit) {  // Well see soon where `accounts` and `snapshots` come from  ReportBuilder(accounts.values.toList(), snapshots).apply(initialize)}report {  accounts()  snapshots()}

Last but not least, lets create the last builder to unify them all! Our NwtBuilder embeds the snapshots and report functions as methods and is responsible for keeping the state of our program: the accounts and the snapshots:

// https://tinyurl.com/2p8n8av6class NwtBuilder {  private val accounts: MutableMap<String, Account> = mutableMapOf()  private val snapshots: MutableList<Snapshot> = mutableListOf()  fun account(id: String, name: String) {    accounts[id] = Account(id, name)  }  fun snapshots(initialize: SnapshotsBuilder.() -> Unit) {    snapshots.addAll(SnapshotsBuilder(accounts).apply(initialize).build())  }  fun report(initialize: ReportBuilder.() -> Unit) {    ReportBuilder(accounts.values.toList(), snapshots).apply(initialize)  }}
// https://tinyurl.com/3cue36refun nwt(initialize: NwtBuilder.() -> Unit) {  NwtBuilder().apply(initialize)}

With the new nwt function, we wont need access to the classes Account, Snapshot, and Report in the script. So we can update the script definition:

// https://tinyurl.com/mhecyc6n@KotlinScript(fileExtension = "nwt.kts", compilationConfiguration = NwtScriptConfiguration::class)abstract class NwtScriptobject NwtScriptConfiguration: ScriptCompilationConfiguration({  // give access to our `nwt` function without importing it  defaultImports("nwt.dsl.nwt")})

Now we have everything we need to update our script. No more val, no more explicit object creation. Only the bare minimum to provide to the interpreter the inputs (the accounts and their balances over time) and what we want as output (the report definition).

// https://tinyurl.com/3xh24mnynwt {  account("checking", "Checking")  account("savings", "Savings")  snapshots {    on("2022-01-01") {      balance("checking", 1000.0)      balance("savings", 2000.0)    }    on("2022-02-01") {      balance("checking", 1200.0)      balance("savings", 2500.0)    }  }  report {    accounts()    snapshots()  }}

Again, since we updated the script definition, you may need to rebuild the project, invalidate caches, restart IntelliJ, etc., to make it effective. But look at how nice it is that IntelliJ recognizes our DSL:

IntelliJ recognizes our DSL!

Kotlin is already widely used to write domain-specific languages. You can use it to write Gradle configuration files, to create HTML documents, and I hope I convided you that creating your own DSL is worth it too, especially used in combination with the ability to write script interpreters.


Original Link: https://dev.to/scastiel/combining-scripts-and-dsls-is-kotlins-most-underrated-feature-54oj

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To