Kotlin DSLs

Motivation

You start with some class that requires setting some configuration.

As you migrated from Java to Kotlin, you have already replaced setName("blah") with name = "blah", but you feel there is still something missing… there is still something too boilerplate about it all.

So you decide to give Kotlin DSLs a shot.

Preparation

We’ll start with auto-generating an empty project.

malachi@enki:~/work$ mkdir ktDsl
malachi@enki:~/work$ cd ktDsl
malachi@enki:~/work/ktDsl$ gradle init --dsl kotlin
Starting a Gradle Daemon, 2 incompatible and 1 stopped Daemons could not be reused, use --status for details

Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 2

Select implementation language:
  1: C++
  2: Groovy
  3: Java
  4: Kotlin
  5: Swift
Enter selection (default: Java) [1..5] 4

Project name (default: ktDsl): 

Source package (default: ktDsl): com.malachid.ktdsl


BUILD SUCCESSFUL in 16s
2 actionable tasks: 2 executed

And validate that it works.

malachi@enki:~/work/ktDsl$ ./gradlew run
Starting a Gradle Daemon, 2 incompatible and 2 stopped Daemons could not be reused, use --status for details

> Task :run
Hello world.

BUILD SUCCESSFUL in 14s
2 actionable tasks: 2 executed

Creating our DSL

What kind of DSL are we going to model? Let’s do something simple.

How about website bookmarks?

Our model will assume:

  1. There is a top-level folder
  2. Folders can be nested
  3. Each folder has a label
  4. Each folder has a list of bookmarks
  5. Each bookmark has a label and a url

DSL Marker

First, we need to define our DSL Marker.

You’ll create a BookmarkDsl.kt. For this sample, I kept everything in the same package, but you can organize them as you see fit.

This class will contain a single annotation class, with an annotation tag on it; like so:

package com.malachid.ktdsl

@DslMarker
annotation class BookmarkDsl

Bookmark.kt

You might be wondering why we jumped to the bottom of the list.

It’s easier to define a bookmark which has no dependencies, than to define a folder that depends on the bookmark.

We’ll create a new empty Bookmark.kt.

In this file, we will define our data class as well as our builder for that data class.

Bookmark data class

First up is our data class.

Inside of Bookmark.kt add the new Bookmark data class.

data class Bookmark(
    val label: String,
    val url: String
)

You might be wondering why we are using String instead of URL or URI or some other class for the url. No particular reason - just to keep this sample easy.

That’s it - we now have our data class that represents a single Bookmark. Over time you could add things like last visited, caching options, etc - but for now, let’s move on.

BookmarkBuilder

The next piece is the BookmarkBuilder. This class also goes inside Bookmark.kt.

Let’s build this one up one step at a time.

Basic class

First, our basic class.

class BookmarkBuilder {
    
}
Variables

Then, we want a variable for each parameter we need to pass to the data class.

var label: String = ""
var url: String = ""

A couple of things to note here:

  1. While the data class is using val, we are using var in the builder. This is to allow the value to change.
  2. The data class doesn’t allow null values, so we are setting (somewhat stupid and unvalidated) values here to make sure they aren’t null.
  3. Currently they will allow assignment. If you only want to allow lambda setters (next), then make them private.
Lambda Functions

Now, we add some lambda functions to set them.

fun label(lambda: () -> String) { label = lambda() }
fun url(lambda: () -> String) { url = lambda() }
Builder

Next, we add a build function to return a copy of the data class based on our internal variables.

fun build() = Bookmark(
    label = label,
    url = url
)
Annotation

And we add our annotation

@BookmarkDsl
class BookmarkBuilder {
    var label: String = "a bookmark"
    var url: String = ""

    fun label(lambda: () -> String) { label = lambda() }
    fun url(lambda: () -> String) { url = lambda() }

    fun build() = Bookmark(
        label = label,
        url = url
    )
}
Public Function

To make it easier to use, we will also make a public function to call the builder.

fun bookmark(lambda: BookmarkBuilder.() -> Unit) = BookmarkBuilder().apply(lambda).build()

It may look a little scary, but what this is doing is allowing you to pass a lambda to a bookmark and have the BookmarkBuilder use it to build a Bookmark data class.

We’ll take a concrete look at what this looks like in a few moments.

BookmarksBuilder (note the s)

One last piece to our Bookmark.kt. When we want a List<Bookmark>, we want an easy way to build them without having to iterate over them.

We’ll create another builder with another public function.

@BookmarkDsl
class BookmarksBuilder {
    private var bookmarks = mutableListOf<Bookmark>()

    fun bookmark(lambda: BookmarkBuilder.() -> Unit) {
        bookmarks.add(BookmarkBuilder().apply(lambda).build())
    }

    fun build() = bookmarks
}

fun bookmarks(lambda: BookmarksBuilder.() -> Unit) = BookmarksBuilder().apply(lambda).build()

The key thing you will notice here is that the internal bookmark function looks just like our public one, except that it is adding the result to our list. A bookmarks (plural) can contain multiple bookmark (singular).

Folder.kt

Now, we need our Folder. Create Folder.kt and we will follow a similar pattern.

Folder data class

Create our data class:

data class Folder(
    val label: String,
    val folders: List<Folder> = emptyList(),
    val bookmarks: List<Bookmark> = emptyList()
)

And our builder

Variables
class FolderBuilder {
    var label: String = "folder"
    var folders: MutableList<Folder> = mutableListOf()
    var bookmarks: MutableList<Bookmark> = mutableListOf()
}

First, the builder defines the lists as mutable. You could also use MutableMap, etc - if your data needed it.

Lambdas
fun label(lambda: () -> String) { label = lambda() }

// @TODO folders lambda

fun bookmarks(lambda: BookmarksBuilder.() -> Unit) {
    bookmarks.addAll(BookmarksBuilder().apply(lambda).build())
}

We’ll come back to the middle one. We’ll need to write the plural version of FolderBuilder just like we did with the Bookmarks.

Builder function
fun build() = Folder(
    label = label,
    folders = folders,
    bookmarks = bookmarks
)
Public function
fun folder(lambda: FolderBuilder.() -> Unit) = FolderBuilder().apply(lambda).build()
Annotation

Don’t forget to annotate the class

@BookmarkDsl
class FolderBuilder {
And the plural version…

Just like we did with the BookmarksBuilder…

@BookmarkDsl
class FoldersBuilder {
    private var folders = mutableListOf<Folder>()

    fun folder(lambda: FolderBuilder.() -> Unit) {
        folders.add(FolderBuilder().apply(lambda).build())
    }
    
    fun build() = folders
}

fun folders(lambda: FoldersBuilder.() -> Unit) = FoldersBuilder().apply(lambda).build()
Wire it up

Now we need to fix our previous TODO placeholder.

fun folders(lambda: FoldersBuilder.() -> Unit) {
    folders.addAll(FoldersBuilder().apply(lambda).build())
}

Trying it out

Now that we have our DSL in place (admittedly without any documentation or tests) let’s try it.

Open App.kt and inside the main block, let’s call our new builders.

    val rootFolder = folder {
        label = "Root Folder"
        bookmarks {
            bookmark {
                label = "Malachi's Blog"
                url = "https://www.malachid.com/"
            }
            bookmark {
                label { "Malachi's Resume" }
                url { "https://www.malachid.com/resume/" }
            }
        }
        folders {
            folder {
                label = "Social"
                bookmarks {
                    bookmark {
                        label = "LinkedIn"
                        url = "https://www.linkedin.com/in/malachid"
                    }
                    bookmark {
                        label = "GitHub"
                        url = "https://github.com/malachid"
                    }
                }
            }
            folder {
                label = "Misc"
            }
        }
    }

Obviously, you can use your own folder and bookmark information.

A few things you will notice.

  1. To the right side of each {, if you are in a JetBrains IDE, it will tell you which builder you are actually using.
  2. The first bookmark uses assignment = operator while the second one uses the { ... } lambda operator. The first one is able to be called because we didn’t make the builder variables private.
  3. There are no commas between elements

Visualizing Results

Let’s try to visualize our results by adding some pretty printing.

Create a new Printer.kt class. We’ll just do some simple padded printing for now.

package com.malachid.ktdsl

private fun pad(num: Int) = repeat(num) { print(" ") }

fun Bookmark.print(pad: Int = 0) {
    pad(pad)
    println("* [$label]($url)")
}

fun Folder.print(pad: Int = 0) {
    pad(pad)
    println("- $label")
    bookmarks.forEach { it.print(pad + 2) }
    folders.forEach { it.print(pad + 2) }
}

Then back in App.kt, call rootFolder.print(). The output should look something like this:


© 2019. All rights reserved.