Kotlin Single-File Scripts

This page documents the use cases and features of Mill’s single-file Kotlin modules, or "scripts". These are Kotlin modules with a single source file, with build configuration included in a header comment block at the top of the file.

Kotlin scripts can be standalone or part of a larger Mill project. They can be run via ./mill Foo.kt from the command line, or have other tasks on them executed via ./mill Foo.kt:compile or ./mill Foo.kt:assembly. Apart from being limited to a single source file, single-file script modules otherwise support all the same tasks that normal KotlinModules do.

Script Use Cases

Mill Single-file Kotlin programs can make it more convenient to script simple command-line workflows interacting with files, subprocesses, and HTTP endpoints. This section walks through a few examples where one-off single-file Kotlin programs can be useful in day-to-day development.

While initially single-file Kotlin programs may be a bit more verbose than the equivalent Bash script containing cp or curl commands, as the script grows in complexity the value of IDE support, typechecking, and JVM libraries makes writing them in Kotlin an attractive proposition. This is especially true if you already have developers fluent in Kotlin which may not be as familiar with the intricacies of writing robust and maintainable Bash code.

JSON API Client

For example, below is a simple script using KotlinX-Serialization, Unirest-Java, and Clikt, to write a program that crawls wikipedia and saves the crawl results to a file:

JsonApiClient.kt (download, browse)
//| mvnDeps:
//| - com.github.ajalt.clikt:clikt:5.0.3
//| - com.konghq:unirest-java:3.14.5
//| - org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.int
import kotlinx.serialization.json.*
import kong.unirest.Unirest
import java.nio.file.*

fun fetchLinks(title: String): List<String> {
    val response = Unirest.get("https://en.wikipedia.org/w/api.php")
        .queryString("action", "query")
        .queryString("titles", title)
        .queryString("prop", "links")
        .queryString("format", "json")
        .header("User-Agent", "WikiFetcherBot/1.0 (https://example.com; contact@example.com)")
        .asString()

    if (!response.isSuccess) return emptyList()

    val json = Json.parseToJsonElement(response.body).jsonObject
    val pages = json["query"]?.jsonObject?.get("pages")?.jsonObject ?: return emptyList()
    return pages.values.flatMap { page ->
        page.jsonObject["links"]
            ?.jsonArray
            ?.mapNotNull { it.jsonObject["title"]?.jsonPrimitive?.content }
            ?: emptyList()
    }
}

class Crawler : CliktCommand(name = "wiki-fetcher") {
    val startArticle by option(help = "Starting Wikipedia article").required()
    val depth by option(help = "Depth of link traversal").int().required()

    override fun run() {
        var seen = mutableSetOf(startArticle)
        var current = mutableSetOf(startArticle)

        repeat(depth) {
            val next = current.flatMap { fetchLinks(it) }.toSet()
            current = (next - seen).toMutableSet()
            seen += current
        }

        val jsonOut = Json { prettyPrint = true }
            .encodeToString(JsonElement.serializer(), JsonArray(seen.map { JsonPrimitive(it) }))
        Files.writeString(Paths.get("fetched.json"), jsonOut)
    }
}

fun main(args: Array<String>) = Crawler().main(args)
> ./mill JsonApiClient.kt --start-article singapore --depth 2

> cat fetched.json
[
    "Calling code",
    "+65",
    "British Empire",
    "1st Parliament of Singapore",
    ...
]

HTML Web Scraper

Below is another web crawler, but instead of interacting with Wikipedia via a JSON API it scrapes the website’s HTML pages using JSoup. JSoup is not bundled with Mill, but can be included easily via mvnDeps header declaration:

HtmlScraper.kt (download, browse)
//| mvnDeps: [org.jsoup:jsoup:1.7.2]

import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element

fun fetchLinks(title: String): List<String> {
    val url = "https://en.wikipedia.org/wiki/$title"
    val doc: Document = Jsoup.connect(url)
        .header("User-Agent", "Mozilla/5.0 (compatible; JsoupBot/1.0; +https://example.com/bot)")
        .get()

    return doc.select("main p a")
        .mapNotNull { a ->
            val href = a.attr("href")
            if (href.startsWith("/wiki/")) href.removePrefix("/wiki/") else null
        }
}

fun main(args: Array<String>) {
    if (args.size < 2) {
        System.err.println("Usage: kotlin Scraper <startArticle> <depth>")
        return
    }

    val startArticle = args[0]
    val depth = args[1].toInt()

    var seen = mutableSetOf(startArticle)
    var current = mutableSetOf(startArticle)

    repeat(depth) {
        val next = mutableSetOf<String>()
        for (article in current) {
            for (link in fetchLinks(article)) {
                if (seen.add(link)) next.add(link)
            }
        }
        current = next
    }

    seen.forEach { println(it) }
}
> ./mill HtmlScraper.kt singapore 1
Hokkien
Conscription_in_Singapore
Malaysia_Agreement
Government_of_Singapore
...

Web Server

This example shows off running a simple webserver in a single-file script. While a production-quality website is a lot more complex and would need a proper build.mill file, sometimes you just need a simple webserver running for local testing or experimentation. In these scenarios, writing a single-file script in Kotlin to run the server is a great way to start, and it can always grow into a more full-featured project later if necessary.

In this example, the webserver script uses the Ktor framework to run the web server

WebServer.kt (download, browse)
//| mvnDeps: [io.ktor:ktor-server-core:2.3.7, io.ktor:ktor-server-cio:2.3.7]
import io.ktor.server.engine.*
import io.ktor.server.cio.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main() {
    embeddedServer(CIO, port = 8080) {
        routing {
            post("/reverse-string") {
                val body = call.receiveText()
                call.respondText(body.reversed())
            }
        }
    }.start(wait = true)
}
> ./mill WebServer.kt:runBackground

> curl -d 'helloworld' localhost:8080/reverse-string
dlrowolleh

Database Queries

This example shows populating and querying a simple database using a Kotlin script. While you often want to query databases using SQL directly, sometimes it is useful to be be able to query them programmatically from Kotlin, possibly making use of the Kotlin libraries and application code you use in the rest of your codebase. It uses a local Sqlite database populated by the contents of a local sqlite-customers.sql file.

In this case, we make use of the popular Jetbrains/Exposed library to perform the queries:

Database.kt (download, browse)
//| mvnDeps:
//| - org.xerial:sqlite-jdbc:3.43.0.0
//| - org.jetbrains.exposed:exposed-core:0.55.0
//| - org.jetbrains.exposed:exposed-dao:0.55.0
//| - org.jetbrains.exposed:exposed-jdbc:0.55.0
//| - org.jetbrains.exposed:exposed-java-time:0.55.0
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.date
import org.jetbrains.exposed.sql.transactions.transaction
import java.nio.file.*
import java.time.LocalDate

object BuyerTable : Table("buyer") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 255)
    val dateOfBirth = date("date_of_birth")
    override val primaryKey = PrimaryKey(id)
}

object ShippingInfoTable : Table("shipping_info") {
    val id = integer("id").autoIncrement()
    val buyerId = integer("buyer_id") references BuyerTable.id
    val shippingDate = date("shipping_date")
    override val primaryKey = PrimaryKey(id)
}

fun main(args: Array<String>) {
    Database.connect("jdbc:sqlite:./file.db", driver = "org.sqlite.JDBC") // Initialize database

    transaction {
        // Populate database from SQL file - split by semicolons and execute each statement
        val sqlContent = Files.readString(Paths.get("sqlite-customers.sql"))
        sqlContent.split(";")
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .forEach { statement -> exec(statement) }

        // Find names of buyers whose shipping date is today or later
        val query = BuyerTable
            .join(ShippingInfoTable, JoinType.INNER, BuyerTable.id, ShippingInfoTable.buyerId)
            .select(BuyerTable.name)
            .where { ShippingInfoTable.shippingDate greaterEq LocalDate.parse(args[0]) }

        for (row in query) println(row[BuyerTable.name])
    }
}
> ./mill Database.kt 2011-01-01
James Bond
John Doe

Static Site Generator

This example is a small static site generator written as a single-file Kotlin script. It lists markdown files in a post/ folder, converts them to HTML using the Commonmark-Java library, wraps them in a HTML template and writes them to HTML files in an out/ folder along with an index.html page that links to them:

StaticSite.kt (download, browse)
//| mvnDeps:
//| - org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0
//| - com.atlassian.commonmark:commonmark:0.13.1

import java.io.File
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import kotlinx.html.*
import kotlinx.html.stream.appendHTML

fun main(args: Array<String>) {
    val outDir = File("site-out")
    val outPostDir = outDir.resolve("post")

    val postInfo = File("post").listFiles { f -> f.extension == "md" }!!
        .map { f ->
            val match = Regex("""(\d+) - (.+)\.md""").matchEntire(f.name)
                ?: error("Invalid post filename: ${f.name}")
            val (prefix, suffix) = match.destructured
            Triple(prefix.toInt(), suffix, f)
        }
        .sortedBy { it.first }

    if (outDir.exists()) outDir.deleteRecursively()
    outPostDir.mkdirs()

    fun writeHtml(path: File, pageTitle: String, bodyContent: FlowContent.() -> Unit) {

    }

    val parser = Parser.builder().build()
    val renderer = HtmlRenderer.builder().build()

    for ((_, suffix, file) in postInfo) {
        val document = parser.parse(file.readText())
        val htmlContent = renderer.render(document)

        val slug = suffix.replace(" ", "-").lowercase()
        val outputFile = outPostDir.resolve("$slug.html")
        outputFile.writer().use { w ->
            w.appendLine("<!DOCTYPE html>")
            w.appendHTML().html {
                body {
                    h1 { +"Blog / $suffix" }
                    unsafe{
                        +htmlContent
                    }
                }
            }
        }
    }
}
> ./mill StaticSite.kt

> cat site-out/post/my-first-post.html
<!DOCTYPE html>
<html>
  <body>
    <h1>Blog / My First Post</h1>
<p>Sometimes you want numbered lists:</p>
<ol>
<li>One</li>
<li>Two</li>
<li>Three</li>
</ol>
  </body>
</html>

Packaging Assemblies and Native Binaries

Mill Single-File Scripts can be packaged into executable assemblies or Graal native images for convenient distribution or deployment. Note that to create Graal native image binaries, you need to define your jvmId to be a graalvm version

Bar.kt (download, browse)
//| kotlinVersion: 2.0.20
//| jvmId: "graalvm-community:17"
//| nativeImageOptions: ["--no-fallback"]
fun main(args: Array<String>) {
    println("Hello World")
}
> ./mill Bar.kt:assembly

> ./out/Bar.kt/assembly.dest/out.jar # mac/linux
Hello World

> ./mill Bar.kt:nativeImage

> ./out/Bar.kt/nativeImage.dest/native-executable
Hello World

Custom Script Module Classes

By default, Mill single-file Kotlin modules inherit their behavior from the builtin mill.script.KotlinModule. However, you can also customize them to inherit from a custom Module class that you define as part of your meta-build in mill-build/src/. For example, if we want to add a resource file generated by processing the source file of the script, this can be done in a custom LineCountKotlinModule as shown below:

Qux.kt (download, browse)
//| extends: [millbuild.LineCountKotlinModule]
//| kotlinVersion: 2.0.20
package qux

fun getLineCount(): String =
    ::main.javaClass.classLoader
        .getResourceAsStream("line-count.txt")
        .readAllBytes()
        .toString(Charsets.UTF_8)

fun main() {
    println("Line Count: " + getLineCount())
}
mill-build/src/LineCountKotlinModule.scala (download, browse)
package millbuild
import mill.*, kotlinlib.*, script.*

class LineCountKotlinModule(scriptConfig: ScriptModule.Config)
    extends mill.script.KotlinModule(scriptConfig) {

  /** Total number of lines in module source files */
  def lineCount = Task {
    allSourceFiles().map(f => os.read.lines(f.path).size).sum
  }

  /** Generate resources using lineCount of sources */
  override def resources = Task {
    os.write(Task.dest / "line-count.txt", "" + lineCount())
    super.resources() ++ Seq(PathRef(Task.dest))
  }
}
> ./mill Qux.kt
...
Line Count: 13

> ./mill show Qux.kt:lineCount
13

Your custom LineCountKotlinModule must be a class take a mill.script.ScriptModule.Config as a parameter that is passed to the mill.script.KotlinModule. Custom script module classes allows you to customize the semantics of your Java, Scala, or Kotlin single-file script modules. If you have a large number of scripts with a similar configuration, or you need customizations that cannot be done in the YAML build header, placing these customizations in a custom script module class can let you centrally define the behavior and standardize it across all scripts that inherit it via extends.

Note that single-file scripts can only inherit from a single concrete module class, unlike normal config-based or programmatic modules that can inherit from one-or-more abstract module `trait`s. This difference arises from the way scripts are instantiated dynamically when resolved, rather than being compiled ahead-of-time like other modules are.

Relative and Absolute Script moduleDeps

M Mill single-file scripts can import each other via either relative or absolute imports. For example, given a bar/Bar.kt file such as below:

bar/Bar.kt (download, browse)
//| kotlinVersion: 2.0.20
package bar

fun generateHtml(text: String): String = "<h1>" + text + "</h1>"

It can be imported via a ./Bar.kt import relative to its own enclosing folder (in this case bar/), as shown below where it is imported from the bar/BarTests.kt test suite in the same folder:

bar/BarTests.kt (download, browse)
//| extends: [mill.script.KotlinModule.Junit5]
//| moduleDeps: [./Bar.kt]
//| mvnDeps:
//| - io.kotest:kotest-runner-junit5:5.9.1
//| - com.github.sbt.junit:jupiter-interface:0.11.2
package bar

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class BarTests :
    FunSpec({
        test("simple") {
            generateHtml("hello") shouldBe "<h1>hello</h1>"
        }
    })

Or it can be imported via bar/Bar.kt absolute import, as shown below where it is imported from the foo/Foo.kt

foo/Foo.kt (download, browse)
//| moduleDeps: [bar/Bar.kt]
//| kotlinVersion: 2.0.20

fun main(args: Array<String>) = println(bar.generateHtml(args[0]))

This examples can be exercised as follows:

> ./mill bar/Bar.kt:compile
> ./mill bar/BarTests.kt
> ./mill foo/Foo.kt hello

Project moduleDeps

Single-file scripts can depend on programmatic modules. This can be useful to integrate your scripts into a large project, where your scripts may make of classes and methods defined in your library or application code to perform domain-specific actions that may not be available in the standard library or third-party libraries.

build.mill (download, browse)
package build
import mill.*, kotlinlib.*

object bar extends KotlinModule {
  def kotlinVersion = "1.9.24"
  def mvnDeps = Seq(
    mvn"org.jetbrains.kotlinx:kotlinx-html:0.11.0"
  )
}
Foo.kt (download, browse)
//| moduleDeps: [bar]

fun main(args: Array<String>){
    println(bar.generateHtml(args[0]))
}
> ./mill Foo.kt hello
<h1>hello</h1>