(Experimental) Kotlin with Mill
Mill is a fast multi-language JVM build tool that supports Kotlin, making your common development workflows 5-10x faster to Maven, or 2-4x faster than Gradle, and easier to use than SBT. Mill aims to make your JVM project’s build process performant, maintainable, and flexible even as it grows from a small project to a large codebase or monorepo with hundreds of modules:
-
Performance: Mill’s build graph automatically caches and parallelizes build tasks, keeping your workflows fast and responsive. Mill adds minimal overhead over the logic necessary to build your project, while providing tools to let you identify and resolve bottlenecks in your build
-
Beyond YAML and Bash: Mill config and custom logic is written in concise type-checked code, rather than shell scripts, XML or YAML. This lets IDEs (IntelliJ or VSCode) understand and navigate around and refactor your build as easily as any application codebase.
-
Flexibility: Mill’s custom tasks and modules allow anything from adding simple build steps, up to entire language toolchains. You can import any JVM library as part of your build, use Mill’s rich ecosystem of Third-Party Mill Plugins, or write plugins yourself and publish them on Maven Central for others to use.
Mill borrows ideas from other tools like Maven, Gradle, Bazel, but tries to learn from the strengths of each tool and improve on their weaknesses. Although Maven and Gradle are mature widely-used tools, they have fundamental limitations in their design (Maven, Gradle) that make them difficult to improve upon incrementally.
-
Mill follows Maven’s innovation of good built-in defaults: Mill’s built-in
JavaModule
s follow Maven’s "convention over configuration" style, so small Mill projects require minimal effort to get started, and larger Mill projects have a consistent structure building on these defaults. -
Mill makes customizing the build tool much easier than Maven. Projects usually grow beyond just compiling a single language: needing custom code generation, linting workflows, tool integrations, output artifacts, or support for additional languages. Mill makes doing this yourself easy, so you are not beholden to third-party plugins that may not exist or interact well with each other.
-
Mill automatically caches and parallelizes your build: Not just the built-in tasks that Mill ships with, but also any custom tasks or modules. This maximizes performance and snappiness of your command-line build workflows, and especially matters in larger codebases where builds tend to get slow: a Maven
clean install
taking over a minute might take just a few seconds in Mill.
-
Mill follows Gradle’s conciseness: Rather than pages and pages of verbose XML, every line in a Mill build is meaningful. e.g. adding a dependency is 1 line in Mill, like it is in Gradle, and unlike the 5 line
<dependency>
declaration you find in Maven. Skimming and understanding a 100-line Millbuild.mill
file is often much easier than skimming the equivalent 500-line Mavenpom.xml
. -
Mill builds more performant: Although both Mill and Gradle automatically cache and parallelize your build, Mill does so with much less fixed overhead, resulting in 2-3x speedups in common command-line workflows. This means less time waiting for your build tool, and more time focusing on the things that really matter to your project.
-
Mill enforces best practices by default. All Mill tasks are cached by default, even custom tasks. All Mill tasks write their output to disk a standard place. All task inter-dependencies are automatically captured, without needing manual annotation. All Mill builds are incremental, not just tasks but also configuration and other phases. Where Gradle requires considerable effort and expertise to maintain your build, Mill automates it so the easiest thing to do is almost always the right thing to do.
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, it’s easy to learn enough Scala for Mill without needing to become an expert in the language.
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.
If you’re interested in the fundamental ideas behind Mill, rather than the user-facing benefits discussed above, check out the section on Mill Design Principles:
The rest of this page contains a quick introduction to getting start with using Mill to build a simple Kotlin program. The other pages of this doc-site go into more depth, with more examples of how to use Mill and more details of how the Mill build tool works.
Simple Kotlin Module
package build
import mill._, kotlinlib._
object `package` extends RootModule with 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. As a
single-module project, it extends RootModule
to mark object
as the
top-level module in the build. This lets us directly perform operations
package
./mill compile
or ./mill run
without needing to prefix it as
foo.compile
or foo.run
.
You can download this example project using the download link above
if you want to try out the commands below yourself. 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.
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 src/ foo/Foo.kt resources/ ... test/ src/ foo/FooTest.kt out/ compile.json compile.dest/ ... test/ compile.json compile.dest/ ...
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 _ # List what tasks are available to run
assembly
...
clean
...
compile
...
run
...
show
...
inspect
...
> ./mill inspect compile # Show documentation and inputs of a task
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:
allJavaSourceFiles
allKotlinSourceFiles
compileClasspath
upstreamCompileOutput
javacOptions
zincReportCachedProblems
kotlincOptions
kotlinCompilerClasspath
...
> ./mill compile # compile sources into classfiles
...
Compiling 1 Kotlin sources to...
> ./mill run # run the main method, if any
error: Error: missing option --text
...
> ./mill run --text hello
<h1>hello</h1>
> ./mill test
...
Test foo.FooTesttestSimple finished, ...
Test foo.FooTesttestEscaping finished, ...
Test foo.FooTest finished, ...
Test run finished: 0 failed, 0 ignored, 2 total, ...
> ./mill assembly # bundle classfiles and libraries into a jar for deployment
> ./mill show assembly # show the output of the assembly task
".../out/assembly.dest/out.jar"
> java -jar ./out/assembly.dest/out.jar --text hello
<h1>hello</h1>
> ./out/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
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 targets, such as
compile
, and un-cached commands such as foo.run
. Targets do not
re-evaluate unless one of their inputs changes, whereas commands re-run every
time.
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 `package` extends RootModule with 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())
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 resource.super
below), replacing
it with the destination folder of the new resources
task, which is wired
up to lineCount
:
> mill run
...
Line Count: 17
> mill show lineCount
17
> mill inspect lineCount
lineCount(build.mill:...)
Total number of lines in module source files
Inputs:
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). os.read.lines
and os.write
come
from the OS-Lib library, which is
one of Mill’s [Bundled Libraries]. This generated file can then be
loaded and used at runtime, as see in the output of mill run
While this is a toy example, it shows how easy it is to customize your Mill build to include the kinds of custom logic common in the build config of most real-world projects.
This customization is done in a principled fashion familiar to most programmers - object-orienting overrides - rather than ad-hoc monkey-patching or mutation common in other build tools. You never have "spooky action at a distance" affecting your build / graph definition, and your IDE can always help you find the final override of any particular build task as well as where any overriden implementations may be defined.
Unlike normal methods, 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
.
We don’t mark either module as top-level using extends RootModule
, so
running tasks needs to use the module name as the prefix e.g. 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.
Note that we split out the test
submodule configuration common to both
modules into a separate trait MyModule
. This 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.
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.
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 documentation for [Task Query Syntax]
Watch and Re-evaluate
You can use the --watch
flag to make Mill watch a task’s inputs,
re-evaluating the task as necessary when the inputs
change:
$ mill --watch foo.compile
$ mill --watch foo.run
$ mill -w foo.compile
$ mill -w foo.run
Mill’s --watch
flag watches both the files you are building using Mill, as
well as Mill’s own build.mill
file and anything it imports, so any changes to
your build.mill
will automatically get picked up.
For long-running processes like web servers, you can use runBackground
to make sure they recompile and restart when code changes,
forcefully terminating the previous process even though it may be still alive:
$ mill -w foo.compile
$ mill -w foo.runBackground
Parallel Task Execution
By default, mill will evaluate all tasks in parallel, with the number of concurrent tasks equal to the number of cores on your machine.
You can use the --jobs
(-j
) to configure explicitly how many concurrent tasks you
wish to run
Example: Use up to 4 parallel threads to compile all modules:
mill -j4 __.compile
To disable parallel execution use -j1
.
mill
generates an output file in out/mill-chrome-profile.json
that can be
loaded into the Chrome browser’s chrome://tracing
page for visualization.
This can make it much easier to analyze your parallel runs to find out what’s
taking the most time:
Note that the maximal possible parallelism depends both on the number of cores available as well as the task and module structure of your project, as tasks that depend on one another other cannot be processed in parallel
Mill Cheat Sheet
Mill is a command-line tool and supports various options. Run mill --help
for
a complete list of options and a cheat-sheet of how to work with tasks:
mill --help
Mill Build Tool, version 0.12.0-RC3-8-5d7328
Usage: mill [options] task [task-options] [+ task ...]
task cheat sheet:
mill resolve _ # see all top-level tasks and modules
mill resolve __.compile # see all `compile` tasks in any module (recursively)
mill foo.bar.compile # compile the module `foo.bar`
mill foo.run --arg 1 # run the main method of the module `foo` and pass in `--arg 1`
mill -i foo.console # run the Scala console for the module `foo` (if it is a ScalaModule)
mill foo.__.test # run tests in modules nested within `foo` (recursively)
mill foo.test arg1 arg2 # run tests in the `foo` module passing in test arguments `arg1 arg2`
mill foo.test + bar.test # run tests in the `foo` module and `bar` module
mill '{foo,bar,qux}.test' # run tests in the `foo` module, `bar` module, and `qux` module
mill foo.assembly # generate an executable assembly of the module `foo`
mill show foo.assembly # print the output path of the assembly of module `foo`
mill inspect foo.assembly # show docs and metadata for the `assembly` task on module `foo`
mill clean foo.assembly # delete the output of `foo.assembly` to force re-evaluation
mill clean # delete the output of the entire build to force re-evaluation
mill path foo.run foo.sources # print the task chain showing how `foo.run` depends on `foo.sources`
mill visualize __.compile # show how the `compile` tasks in each module depend on one another
options:
-D --define <k=v> Define (or overwrite) a system property.
--allow-positional Allows command args to be passed positionally without `--arg` by default
-b --bell Ring the bell once if the run completes successfully, twice if it fails.
--bsp Enable BSP server mode.
--color <bool> Toggle colored output; by default enabled only if the console is interactive
-d --debug Show debug output on STDOUT
--disable-callgraph Disables fine-grained invalidation of tasks based on analyzing code changes.
If passed, you need to manually run `clean` yourself after build changes.
--help Print this help message and exit.
-i --interactive Run Mill in interactive mode, suitable for opening REPLs and taking user
input. This implies --no-server. Must be the first argument.
--import <str> Additional ivy dependencies to load into mill, e.g. plugins.
-j --jobs <str> The number of parallel threads. It can be an integer e.g. `5` meaning 5
threads, an expression e.g. `0.5C` meaning half as many threads as available
cores, or `C-2` meaning 2 threads less than the number of cores. `1` disables
parallelism and `0` (the default) uses 1 thread per core.
-k --keep-going Continue build, even after build failures.
--meta-level <int> Select a meta-level to run the given tasks. Level 0 is the main project in
`build.mill`, level 1 the first meta-build in `mill-build/build.mill`, etc.
--no-server Run without a background server. Must be the first argument.
-s --silent Make ivy logs during script import resolution go silent instead of printing
--ticker <bool> Enable ticker log (e.g. short-lived prints of stages and progress bars).
-v --version Show mill version information and exit.
-w --watch Watch and re-run the given tasks when when their inputs change.
task <str>... The name or a pattern of the tasks(s) you want to build.
Please see the documentation at https://mill-build.org for more details
Note that options and flags given before the task
name are passed to Mill, while
options and flags after the task
name are passed to the task. This mirrors how
most command line script or task runners work (e.g. python <python-args> script.py <script-args>
)