Kotlin Single-File Scripts
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.
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 Crawler.kt --start-article singapore --depth 2
> cat fetched.json
[
"Calling code",
"+65",
"British Empire",
"1st Parliament of Singapore",
...
]
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.
Relative and Absolute Script moduleDeps
Mill single-file scripts can import each other via either relative or absolute imports.
For example, given a bar/Bar.scala 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.{language-exr}
//| 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
Custom Script Module Classes
By default, single-file Mill script 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.