Kotlin with Mill
This page contains a quick introduction to getting start with using Mill to build a simple Kotlin program. We will walk through a series of Mill builds of increasing complexity to show you the key features and usage of the Mill build tool.
The other pages of this section on Kotlin go into more depth into individual features, with more examples of how to use Mill for Kotlin and more details of how the Mill build tool works. They aren’t intended to be read comprehensively top-to-bottom, but rather looked up when you have a particular interest e.g. in testing, linting, publishing, and so on.
Mill Kotlin support is currently focused on the JVM, with a lot of APIs still under active development. It is expected to continue evolving over time as Android and Multiplatform support is fleshed out. Try it out, but please be aware of its limitations! |
Simple Kotlin Module
package build
import mill._, kotlinlib._
object foo extends KotlinModule {
def kotlinVersion = "1.9.24"
def mainClass = Some("foo.FooKt")
def ivyDeps = Agg(
ivy"com.github.ajalt.clikt:clikt-jvm:4.4.0",
ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0"
)
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
This is a basic Mill build for a single KotlinModule
, with two
third-party dependencies and a test suite using the JUnit framework.
You can download this example project using the download link above
if you want to try out the commands below yourself, or browse the full sources
of the example (including supporting files) via the browse link. The only requirement is
that you have some version of the JVM installed; the ./mill
script takes
care of any further dependencies that need to be downloaded. All examples
in this documentation site are executable and are continually exercised as
part of Mill’s CI workflows, and they range from the simple hello-world
projects on this page to more sophisticated
web build examples or
example
builds for real-world projects
The source code for this module lives in the src/
folder.
Output for this module (compiled files, resolved dependency lists, …)
lives in out/
.
build.mill foo/ src/ foo/Foo.kt resources/ ... test/ src/ foo/FooTest.kt out/foo/ compile.json compile.dest/ ... test/ compile.json compile.dest/ ...
The default Mill source folder layout foo/src/ differs from that of Maven/Gradle’s
foo/src/main/kotlin . If you wish to use the Maven source folder layout, e.g. for migrating
an existing codebase, you should use
Maven-Compatible Modules
|
This example project uses two third-party dependencies - Clikt for CLI argument parsing, Apache Commons Text for HTML escaping - and uses them to wrap a given input string in HTML templates with proper escaping.
Typical usage of a KotlinModule
is shown below
> ./mill resolve foo._ # List what tasks are available to run
foo.assembly
...
foo.compile
...
foo.run
...
> ./mill inspect foo.compile # Show documentation and inputs of a task
foo.compile(KotlinModule...)
Compiles all the sources to JVM class files.
Compiles the current module to generate compiled classfiles/bytecode.
When you override this, you probably also want/need to override [[bspCompileClassesPath]],
as that needs to point to the same compilation output path.
Keep in sync with [[bspCompileClassesPath]]
Inputs:
foo.allJavaSourceFiles
foo.allKotlinSourceFiles
foo.compileClasspath
foo.upstreamCompileOutput
foo.javacOptions
foo.zincReportCachedProblems
foo.kotlincOptions
foo.kotlinCompilerClasspath
...
> ./mill foo.compile # compile sources into classfiles
...
Compiling 1 Kotlin sources to...
> ./mill foo.run # run the main method, if any
error: Error: missing option --text
...
> ./mill foo.run --text hello
<h1>hello</h1>
> ./mill foo.test
...
Test foo.FooTesttestSimple finished, ...
Test foo.FooTesttestEscaping finished, ...
Test foo.FooTest finished, ...
Test run finished: 0 failed, 0 ignored, 2 total, ...
> ./mill foo.assembly # bundle classfiles and libraries into a jar for deployment
> ./mill show foo.assembly # show the output of the assembly task
".../out/foo/assembly.dest/out.jar"
> java -jar ./out/foo/assembly.dest/out.jar --text hello
<h1>hello</h1>
> ./out/foo/assembly.dest/out.jar --text hello # mac/linux
<h1>hello</h1>
The output of every Mill task is stored in the out/
folder under a name
corresponding to the task that created it. e.g. The assembly
task puts its
metadata output in out/assembly.json
, and its output files in
out/assembly.dest
. You can also use show
to make Mill print out the
metadata output for a particular task.
Additional Mill tasks you would likely need include:
$ mill runBackground # run the main method in the background
$ mill clean <task> # delete the cached output of a task, terminate any runBackground
$ mill launcher # prepares a foo/launcher.dest/run you can run later
$ mill jar # bundle the classfiles into a jar suitable for publishing
$ mill -i console # start a Scala console within your project
$ mill -i repl # start an Ammonite Scala REPL within your project
$ mill -w compile # watch input files and re-compile whenever a file changes
You can run mill resolve __
to see a full list of the different tasks that
are available, mill resolve _
to see the tasks within foo
,
mill inspect compile
to inspect a task’s doc-comment documentation or what
it depends on, or mill show foo.scalaVersion
to show the output of any task.
The most common tasks that Mill can run are cached tasks, such as
compile
, and un-cached commands such as foo.run
. Cached tasks do not
re-evaluate unless one of their inputs changes, whereas commands re-run every
time. See the documentation for Tasks for
details on the different task types.
Mill build files are written in Scala, but you do not need to have prior experience in Scala to read or write them. Like Gradle Groovy or Maven XML, anyone can learn enough Scala for Mill without needing to become an expert in the language.
Custom Build Logic
Mill makes it very easy to customize your build graph, overriding portions
of it with custom logic. In this example, we override the JVM resources
of
our KotlinModule
- normally the resources/
folder - to instead contain a
single generated text file containing the line count of all the source files
in that module
package build
import mill._, kotlinlib._
object foo extends KotlinModule {
def kotlinVersion = "1.9.24"
def mainClass = Some("foo.FooKt")
/** 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))
}
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
The addition of lineCount
and resources
overrides the previous resource
folder provided by KotlinModule
(labelled resources.super
below), replacing
it with the destination folder of the new resources
task, which is wired
up to lineCount
:
> mill foo.run
...
Line Count: 17
> mill show foo.lineCount
17
> mill inspect foo.lineCount
foo.lineCount(build.mill:...)
Total number of lines in module source files
Inputs:
foo.allSourceFiles
Above, def lineCount
is a new build task we define, which makes use of
allSourceFiles
(an existing task) and is in-turn used in our override of
resources
(also an existing task). The override
keyword is optional in Mill.
This generated file can then be loaded and used at runtime, as see in the output
of mill run
.
If you’re not familiar with what tasks you can override
or how they are related,
you can explore the existing tasks via autocomplete in your IDE, or use the
mill visualize.
os.read.lines
and os.write
come
from the OS-Lib library, which is
one of Mill’s Bundled Libraries.
You can also import any other library you want from Maven Central using
import $ivy, so you are not limited
to what is bundled with Mill.
Custom user-defined tasks in Mill benefit from all the same things that built-in tasks do: automatic caching (in the out/ folder), parallelism (with the -j/--jobs flag), inspectability (via show/inspect), and so on. While these things may not matter for such a simple example that runs quickly, they ensure that custom build logic remains performant and maintainable even as the complexity of your project grows.
Multi-Module Project
package build
import mill._, kotlinlib._
trait MyModule extends KotlinModule {
def kotlinVersion = "1.9.24"
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
object foo extends MyModule {
def mainClass = Some("foo.FooKt")
def moduleDeps = Seq(bar)
def ivyDeps = Agg(
ivy"com.github.ajalt.clikt:clikt-jvm:4.4.0"
)
}
object bar extends MyModule {
def mainClass = Some("bar.BarKt")
def ivyDeps = Agg(
ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0"
)
}
This example contains a simple Mill build with two modules, foo
and bar
,
which you can run tasks on such as foo.run
or
bar.run
. You can define multiple modules the same way you define a single
module, using def moduleDeps
to define the relationship between them. Modules
can also be nested within each other, as foo.test
and bar.test
are nested
within foo
and bar
respectively
Note that we split out the test
submodule configuration common to both
modules into a separate trait MyModule
. This Trait Module
lets us avoid the need to copy-paste common settings, while still letting
us define any per-module configuration such as ivyDeps
specific to a
particular module. This is a common pattern within Mill builds.
The above builds expect the following project layout:
build.mill foo/ src/ foo/Foo.kt bar/ src/ bar/Bar.kt test/ src/ bar/BarTests.kt out/ foo/ compile.json compile.dest/ ... bar/ compile.json compile.dest/ ... test/ compile.json compile.dest/ ...
Typically, both source code and output files in Mill follow the module
hierarchy, so e.g. input to the foo
module lives in foo/src/
and
compiled output files live in out/foo/compile.dest
. You can use
mill resolve
to list out what tasks you can run, e.g. mill resolve __.run
below which lists out all the run
tasks:
> mill resolve __.run
foo.run
bar.run
> mill foo.run --foo-text hello --bar-text world
Foo.value: hello
Bar.value: <h1>world</h1>
> mill bar.run world
Bar.value: <h1>world</h1>
> mill bar.test
...
...bar.BarTests...simple...
...bar.BarTests...escaping...
Mill’s evaluator will ensure that the modules are compiled in the right order, and recompiled as necessary when source code in each module changes. The unique path on disk that Mill automatically assigns each task also ensures you do not need to worry about choosing a path on disk to cache outputs, or filesystem collisions if multiple tasks write to the same path.
You can use wildcards and brace-expansion to select multiple tasks at once or to shorten the path to deeply nested tasks. If you provide optional task arguments and your wildcard or brace-expansion is resolved to multiple tasks, the arguments will be applied to each of the tasks.
Wildcard |
Function |
|
matches a single segment of the task path |
|
matches arbitrary segments of the task path |
|
is equal to specifying two tasks |
You can use the + symbol to add another task with optional arguments.
If you need to feed a + as argument to your task, you can mask it by
preceding it with a backslash (\
).
> mill foo._.compile # Runs `compile` for all direct sub-modules of `foo`
> mill foo.__.test # Runs `test` for all transitive sub-modules of `foo`
> mill {foo,bar}.__.testCached # Runs `testCached` for all sub-modules of `foo` and `bar`
> mill __.compile + foo.__.test # Runs all `compile` tasks and all tests under `foo`.
For more details on the query syntax, check out the query syntax documentation
Maven-Compatible Modules
Mill’s default folder layout of foo/src/
and foo/test/src
differs from that
of Maven or Gradle’s foo/src/main/kotlin/
and foo/src/test/kotlin/
. If you are
migrating an existing codebase, you can use Mill’s KotlinMavenModule
and
KotlinMavenTests
as shown below to preserve filesystem compatibility with an existing
Maven or Gradle build:
package build
import mill._, kotlinlib._
object foo extends KotlinModule with KotlinMavenModule {
def kotlinVersion = "1.9.24"
object test extends KotlinMavenTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
KotlinMavenModule
is a variant of KotlinModule
that uses the more verbose folder layout of Maven, SBT, and other tools:
-
foo/src/main/java
-
foo/src/main/kotlin
-
foo/src/test/java
-
foo/src/test/kotlin
Rather than Mill’s
-
foo/src
-
foo/test/src
This is especially useful if you are migrating from Maven to Mill (or vice versa), during which a particular module may be built using both Maven and Mill at the same time
> mill foo.compile
Compiling 1 Kotlin source...
> mill foo.test.compile
Compiling 1 Kotlin source...
> mill foo.test.test
...foo.FooTestshello ...
> mill foo.test
...foo.FooTestshello ...
Realistic Kotlin Example Project
Below, we should a realistic example of a build for a Kotlin project. This example touches on library dependencies, testing, publishing, code generation, and other topics covered in more detail in the Kotlin section of the Mill docs, and you can browse each respective page if you want to learn more.
package build
import mill._, kotlinlib._, publish._
trait MyModule extends KotlinModule with PublishModule {
def publishVersion = "0.0.1"
def pomSettings = PomSettings(
description = "Hello",
organization = "com.lihaoyi",
url = "https://github.com/lihaoyi/example",
licenses = Seq(License.MIT),
versionControl = VersionControl.github("lihaoyi", "example"),
developers = Seq(Developer("lihaoyi", "Li Haoyi", "https://github.com/lihaoyi"))
)
def kotlinVersion = "1.9.24"
def ivyDeps = Agg(ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
object foo extends MyModule {
def moduleDeps = Seq(bar, qux)
def mainClass = Some("foo.FooKt")
def generatedSources = Task {
os.write(
T.dest / "Version.kt",
s"""
package foo
object Version {
fun value() = "${publishVersion()}"
}
""".stripMargin
)
Seq(PathRef(T.dest))
}
}
object bar extends MyModule {
def moduleDeps = Seq(qux)
}
object qux extends MyModule {
def mainClass = Some("qux.QuxKt")
}
A semi-realistic build setup, combining all the individual Mill concepts:
-
Three `KotlinModule`s that depend on each other
-
With unit testing and publishing set up
-
With generated sources to include the
publishVersion
as a string in the code, so it can be printed at runtime
Note that for multi-module builds like this, using using queries to run tasks on multiple modules at once can be very convenient:
__.test __.publishLocal
Also note how you can use trait
s to bundle together common combinations of
modules: MyModule
not only defines a KotlinModule
with some common
configuration, but it also defines a object test
module within it with its
own configuration. This is a very useful technique for managing the often
repetitive module structure in a typical project
> mill resolve __.run
bar.run
bar.test.run
foo.run
foo.test.run
qux.run
> mill foo.run
foo version 0.0.1
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337
> mill bar.test
...bar.BarTestsworld ...
> mill qux.run
Qux.value: 31337
> mill __.compile
> mill __.test
...bar.BarTestsworld ...
...foo.FooTestshello ...
> mill __.publishLocal
Publishing Artifact(com.lihaoyi,foo,0.0.1) to ivy repo...
Publishing Artifact(com.lihaoyi,bar,0.0.1) to ivy repo...
Publishing Artifact(com.lihaoyi,qux,0.0.1) to ivy repo...
...
> mill show foo.assembly # mac/linux
".../out/foo/assembly.dest/out.jar"
> ./out/foo/assembly.dest/out.jar # mac/linux
foo version 0.0.1
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337
History
Mill’s Kotlin support originated as the third-party plugin lefou/mill-kotlin, which was later included with the main Mill codebase under its Apache 2.0 License.