In this blog, we will look at how to write a DSL in Kotlin.

Some basic familarity with Kotlin or some understanding of how to write functions and classes in Kotlin will be required.

Table of Contents

1.0 What is DSL

This is what Wikipedia says about DSL:

domain-specific language (DSL) is a computer language specialized to a particular application domain. This is in contrast to a general-purpose language (GPL), which is broadly applicable across domains.

A domain-specific language is created specifically to solve problems in a particular domain and is not intended to be able to solve problems outside it (although that may be technically possible).

In contrast, general-purpose languages are created to solve problems in many domains.

Kotlin has many features that allow us to easily write type-safe DSL logic. e.g

  • Use of lambdas outside of method parentheses
  • Lambdas with receivers
  • Extension functions

We will look into them in detail later. Let’s look first at few well know examples of DSL.

2.0 Some common examples of DSL

2.1 SQL Queries

The SQL query language is the best example of a DSL. It is used to perform specific actions (insert/update/delete/query), on the given database only.

e.g.
select first_name, last_name from employee where age > 55;

 

2.2 HTML

e.g.

<html>   <body>     <h1>DSL in Kotlin</h1>     <p>This is a brief introduction to writing DSL in Kotlin</p>  </body> </html>

2.3 CSS

body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
}

.light-red {
  background-color: rgb(208, 243, 224);
}

2.4 Gradle Build Script

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    compile     "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.1"
    compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '0.23.3'
    compile 'khttp:khttp:0.1.0'
    testCompile "io.kotlintest:kotlintest-runner-junit5:3.1.0"
}

∆ Top

3.0 DSL Example 1 : Creating DSL similar to test suites.

All of us must have written test cases in some or other language – JUnit / Specs2, etc. or any other.

In this example we will demonstrate how to create a DSL which similar to the test suites we have worked on.

3.1 Requirements for this DSL

We will take a very simple example for create a test suite DSL.

Let’s assume we want to write test cases in this format.

testSuite ("String Equality Tests") {

    test ("Test 1" , "Sunit" isEqual "Punit")

    test ("Test 2" , "Sunit" isEqual "sunit")
}

And when this test suite is run or evaluated, it should print test evaluation

String Equality Tests
	Test1: FAIL
	Test2: PASS

So to summarize, some of the key requirements of this DSL are:

  1. We should be able to define a testSuite
    1. Test Suite should have a name or description
    2. It can contain multiple test
  2. Each Tests will have following
    1. A name or description
    2. An expression that evaluates to True or False.
  3. When this test suite is executed
    1. It should print the name of testSuite.
    2. For each Test, it should print test name and whether the test is PASS or FAIL.
  4. We need a infix operator isEqual that can be used to compare two Strings.

3.2 Implementation of DSL

If we look at the DSL, there are three keywords in the DSL (apart from the user data)

  • testSuite
  • test
  • isEqual

Let’s start with the smallest component of the DSL – the isEqual operator that is used to compare two Strings.

3.2.1 Creating the Extension Function for String comparison

Let’s look at the isEqual method now.

A very simple example looks like "A" isEqual "B".
If we remove the syntactic sugar, this is actually equivalent to "A".isEqual("B")

This means we need an isEqual method for String class, and the method should take another String object as an argument.

Now String is an OOB class provided by Kotlin and we cannot add a new method to it.

However Kotlin allows us to write such functions using Infix Notations and Extension Functions. Below is the code for for adding the isEqual infix extension function on Strings.

infix fun String.isEqual(otherString: String): Boolean {
    return this.equals(otherString, true)
}

With the help of above extension functions, we can write expressions like "A" isEqual "B", and it will return a boolean true or false.

After this step, we can write test case expressions like below,

"Sunit" isEqual "Punit"

"Sunit" isEqual "sunit"

 

3.2.2 Creating the function for test case

Now each Test Case can be a function test,

  • which takes 2 arguments
    • A test name
    • An expression that evaluates to true or false
  • prints following
    • test name
    • PASS/FAIL message, based on whether the expression arguments evaluates to true or false.
  • returns nothing.

The function will look something like this below

fun test(name: String, expr: Boolean) {
    println("t$name: ${if(expr) "PASS" else "FAIL"}")
}

After this step, we should be able to write DSL for single test case, like given below

test ("Test1" , "Sunit" isEqual "Punit")

 

3.3.3 Writing function for testSuite

Now we need to wrap our testCases within a testSuite function

  • which takes 2 arguments
    • name of test Suite
    • A function that returns Unit
  • prints
    • name of test Suite
  • executes the function body

The function body will look like this below

fun testSuite(name: String, fn: () -> Unit) {
    println(name)  // print the test suite name
    fn()  //execute the function body
}

Now when the last argument of a function is a higher order function, then we can invoke the function in following ways

  • Regular way (where function is passed as last argument within the paranthesis)
function(arg1, arg2, ... , { // body of higher order func })
  • Or we can move the function outside of the method paranthesis
function(arg1, arg2, ... ) { // body of higher order func }




We will use the second way of function invokation for our testSuite function.
The function body will contain all the tests that needs to be run as part of this testSuite.

Now, we should be able to write and execute our test DSL like this.

testSuite ("String Equality Tests") { 
    test ("Test 1" , "Sunit" isEqual "Punit") 
    test ("Test 2" , "Sunit" isEqual "sunit") 
}

∆ Top

4.0 DSL Example 2 : Creating a DSL similar to Gradle build script

Let’s look at another example on how to create a DSL.
In this example we will try to create a DSL that is similar to gradle build script

4.1 Requirements for this DSL

We won’t be creating the DSL for a full fledged gradle build script.
But we will take an example of the dependencies DSL and showcase how to create something similar to it.
Below is the example of DSL we want to create.

dependencies { 
    compile("com.google : gson : 2.1.0") 
    testCompile("junit : junit: 4.12") 
}

Since we are not actually importing any dependencies, so on execution of this DSL script we will just print out the name of dependencies being imported

Importing Gradle Build Dependencies
	Importing: Artifact=com.google , Group= gson , Version= 2.1.0
	Importing: Artifact=junit , Group= junit, Version= 4.12

 

4.2 Implementation of DSL

If we look at the DSL, there are three keywords (apart from the user data)

  • dependencies
  • compile
  • testCompile

Let’s start with compile/testCompile functions first.

 

4.2.1 Creating the Dependency class with compile / testCompile functions

We will create a class Dependency that will contain methods – compile and testCompile. Both these methods will take a String, and print out the artifact being imported

class Dependency {
    fun compile(artifact: String) {
        importArtifact(artifact)
    }

    fun testCompile(artifact: String) {
        importArtifact(artifact)
    }

    private fun importArtifact(artifact: String) {
        val array = artifact.split(':')
        println("tImporting: Artifact=${array[0]}, Group=${array[1]}, Version=${array[2]}")
    }
}

 

Once we have done this, we can write DSL forcompile and testCompile like below

Dependency().compile("com.google : gson : 2.1.0")
Dependency().testCompile("junit : junit: 4.12")

However we need to create an instance of Dependency class to invokecompile and testCompile functions. We will take a look later on how to get rid of this.

4.2.2 Creating the dependencies function

We need to do following now

  • We need to wrap the compile and testCompile within a function body of testSuite.
  • We can do this by using a lambda function as argument of function testSuite.

We can create the testSuite function as below

fun dependencies ( executeFn: () -> Unit ) {
    println("Importing Gradle Build Dependencies")
    executeFn()
}

So now we can write our DSL in the following form now

dependencies {
    Dependency().compile("com.google : gson : 2.1.0")
    Dependency().testCompile("junit : junit: 4.12")
}

4.2.3 Getting rid of the Dependency class instance

The DSL is now very close the requirement we had initially.
However we still have to get rid of creating a new instance of Dependency class for each call to compile and testCompile function

We can acheive this by

  • Creating the Dependency object instance within the testSuite function, and pass it to the lambda function
  • The lambda function then does not needs to create a new instance for each call to compile and testCompile.
  • The lambda function can refer to the object instance using the it keyword.

Lets modify the testSuite function first

fun dependencies(executeFn: (Dependency) -> Unit){
    println("Importing Gradle Build Dependencies")
    executeFn( Dependency() )
}

The DSL will now look like this

dependencies {
    it.compile("com.google : gson : 2.1.0")
    it.testCompile("junit : junit: 4.12")
}

4.2.4 Removing references to the it

Now, the user of the Gradle DSL we have build, won’t have any idea on what it refers to in the compile/testCompile lines.  So ideally we can remove it.

Kotlin provides a way for it using Function Literal with Reciever

If we change the argument of dependencies function from
(Dependency) -> Unit to Dependency.() -> Unit, then the lambda function body does not needs to refer to the Dependency object using it reference.

So the re-written function would look like

fun dependencies(executeFn: Dependency.() -> Unit){
    println("Importing Gradle Build Dependencies")
    executeFn( Dependency() )
}

As a result of the above change, we can now write the DSL as we wanted

dependencies {
    compile("com.google : gson : 2.1.0")
    testCompile("junit : junit: 4.12")
}

This concludes this blog about writing DSL examples in Kotlin.

∆ Top