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:
//| 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:
//| 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
//| 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:
//| 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:
//| 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
//| 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:
//| 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())
}
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:
//| 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:
//| 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
//| 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.
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"
)
}
//| moduleDeps: [bar]
fun main(args: Array<String>){
println(bar.generateHtml(args[0]))
}
> ./mill Foo.kt hello
<h1>hello</h1>