Kotlin Module Configuration

This page goes into more detail about the various configuration options for KotlinModule.

Many of the APIs covered here are listed in the API documentation:

Common Configuration Overrides

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

object `package` extends RootModule with KotlinModule {
  // You can have arbitrary numbers of third-party dependencies
  def ivyDeps = Agg(
    ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0"
  )

  def kotlinVersion = "1.9.24"

  // Choose a main class to use for `.run` if there are multiple present
  def mainClass = Some("foo.Foo2Kt")

  // Add (or replace) source folders for the module to use
  def sources = T.sources {
    super.sources() ++ Seq(PathRef(millSourcePath / "custom-src"))
  }

  // Add (or replace) resource folders for the module to use
  def resources = T.sources {
    super.resources() ++ Seq(PathRef(millSourcePath / "custom-resources"))
  }

  // Generate sources at build time
  def generatedSources: T[Seq[PathRef]] = Task {
    for (name <- Seq("A", "B", "C")) os.write(
      T.dest / s"Foo$name.kt",
      s"""
package foo

object Foo$name {
  val VALUE: String = "hello $name"
}
      """.stripMargin
    )

    Seq(PathRef(T.dest))
  }

  // Pass additional JVM flags when `.run` is called or in the executable
  // generated by `.assembly`
  def forkArgs: T[Seq[String]] = Seq("-Dmy.custom.property=my-prop-value")

  // Pass additional environmental variables when `.run` is called. Note that
  // this does not apply to running externally via `.assembly
  def forkEnv: T[Map[String, String]] = Map("MY_CUSTOM_ENV" -> "my-env-value")
}

If you want to better understand how the various upstream tasks feed into a task of interest, such as run, you can visualize their relationships via

> mill visualizePlan run
VisualizePlanJava.svg

(right-click open in new tab to see full sized)

Compilation & Execution Flags

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

object `package` extends RootModule with KotlinModule {

  def kotlinVersion = "1.9.24"

  def mainClass = Some("foo.FooKt")

  def forkArgs = Seq("-Xmx4g", "-Dmy.jvm.property=hello")
  def forkEnv = Map("MY_ENV_VAR" -> "WORLD")

  def kotlincOptions = super.kotlincOptions() ++ Seq("-Werror")
}

You can pass flags to the Kotlin compiler via kotlincOptions.

> ./mill run
hello WORLD

> echo 'fun deprecatedMain(){Thread.currentThread().stop()}' >> src/foo/Foo.kt

> ./mill run
error: .../src/foo/Foo.kt... warning: 'stop(): Unit' is deprecated. Deprecated in Java

Classpath and Filesystem Resources

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

object foo extends KotlinModule {

  def kotlinVersion = "1.9.24"

  object test extends KotlinTests with TestModule.Junit5 {
    def otherFiles = T.source(millSourcePath / "other-files")

    def forkEnv = super.forkEnv() ++ Map(
      "OTHER_FILES_DIR" -> otherFiles().path.toString
    )

    def ivyDeps = super.ivyDeps() ++ Agg(
      ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
    )
  }
}
foo/src/Foo.kt (browse)
package foo

object Foo {
    // Read `file.txt` from classpath
    fun classpathResourceText(): String {
        // Get the resource as an InputStream
        return Foo::class.java.classLoader.getResourceAsStream("file.txt").use {
            it.readAllBytes().toString(Charsets.UTF_8)
        }
    }
}
foo/test/src/FooTests.kt (browse)
package foo

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.nio.file.Files
import java.nio.file.Paths

class FooTests :
    FunSpec({

        test("simple") {
            // Reference app module's `Foo` class which reads `file.txt` from classpath
            val appClasspathResourceText = Foo.classpathResourceText()
            appClasspathResourceText shouldBe "Hello World Resource File"

            // Read `test-file-a.txt` from classpath
            val testClasspathResourceText =
                Foo::class.java.classLoader.getResourceAsStream("test-file-a.txt").use {
                    it.readAllBytes().toString(Charsets.UTF_8)
                }
            testClasspathResourceText shouldBe "Test Hello World Resource File A"

            // Use `MILL_TEST_RESOURCE_DIR` to read `test-file-b.txt` from filesystem
            val testFileResourceDir = Paths.get(System.getenv("MILL_TEST_RESOURCE_DIR"))
            val testFileResourceText =
                Files.readString(
                    testFileResourceDir.resolve("test-file-b.txt"),
                )
            testFileResourceText shouldBe "Test Hello World Resource File B"

            // Use `MILL_TEST_RESOURCE_DIR` to list files available in resource folder
            val actualFiles = Files.list(testFileResourceDir).toList().sorted()
            val expectedFiles =
                listOf(
                    testFileResourceDir.resolve("test-file-a.txt"),
                    testFileResourceDir.resolve("test-file-b.txt"),
                )
            actualFiles shouldBe expectedFiles

            // Use the `OTHER_FILES_DIR` configured in your build to access the
            // files in `foo/test/other-files/`.
            val otherFileText =
                Files.readString(
                    Paths.get(System.getenv("OTHER_FILES_DIR"), "other-file.txt"),
                )
            otherFileText shouldBe "Other Hello World File"
        }
    })

Kotlin Compiler Plugins

The Kotlin compiler requires plugins to be passed explicitly. To do this, you can define a module to contain the exact annotation processors you want, and pass in -Xplugin to kotlincOptions:

build.mill (download, browse)
package build

import mill._, kotlinlib._
import java.io.File

object foo extends KotlinModule {

  def kotlinVersion = "1.9.24"

  def ivyDeps = super.ivyDeps() ++ Agg(
    ivy"org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
  )

  def processors = Task {
    defaultResolver().resolveDeps(
      Agg(
        ivy"org.jetbrains.kotlin:kotlin-serialization-compiler-plugin:1.9.24"
      )
    )
  }

  def kotlincOptions = super.kotlincOptions() ++ Seq(
    s"-Xplugin=${processors().head.path}"
  )

  object test extends KotlinTests with TestModule.Junit5 {
    def ivyDeps = super.ivyDeps() ++ Agg(
      ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
    )
  }
}
> ./mill foo.test
Test foo.ProjectTest simple started
Test foo.ProjectTest simple finished...
...

Doc-Jar Generation

To generate API documenation you can use the docJar task on the module you’d like to create the documentation for, configured via dokkaOptions:

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

object foo extends KotlinModule {

  def kotlinVersion = "1.9.24"

}
> ./mill show foo.docJar
...
...Generation completed successfully...

> unzip -p out/foo/docJar.dest/out.jar root/foo/index.html
...
...My Awesome Docs for class Foo...
...
...My Awesome Docs for class Bar...

Specifying the Main Class

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

object `package` extends RootModule with KotlinModule {

  def kotlinVersion = "1.9.24"

  def mainClass = Some("foo.QuxKt")
}

Custom Tasks

build.mill (download, browse)
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")

  def generatedSources: T[Seq[PathRef]] = Task {
    val prettyIvyDeps = for (ivyDep <- ivyDeps()) yield {
      val org = ivyDep.dep.module.organization.value
      val name = ivyDep.dep.module.name.value
      val version = ivyDep.dep.version
      s""" "$org:$name:$version" """
    }
    val ivyDepsString = prettyIvyDeps.mkString(" + \"\\n\" + \n")
    os.write(
      T.dest / s"MyDeps.kt",
      s"""
package foo

object MyDeps {
  const val VALUE = $ivyDepsString;
}
      """.stripMargin
    )

    Seq(PathRef(T.dest))
  }

  def lineCount: T[Int] = Task {
    sources()
      .flatMap(pathRef => os.walk(pathRef.path))
      .filter(_.ext == "kt")
      .map(os.read.lines(_).size)
      .sum
  }

  def forkArgs: T[Seq[String]] = Seq(s"-Dmy.line.count=${lineCount()}")

  def printLineCount() = T.command { println(lineCount()) }
}
> mill run --text hello
text: hello
MyDeps.value: com.github.ajalt.clikt:clikt-jvm:4.4.0
my.line.count: 17

> mill show lineCount
17

> mill printLineCount
17

Overriding Tasks

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

object foo extends KotlinModule {

  def kotlinVersion = "1.9.24"

  def mainClass = Some("foo.FooKt")

  def sources = Task {
    os.write(
      T.dest / "Foo.kt",
      """package foo

fun main() = println("Hello World")
      """.stripMargin
    )
    Seq(PathRef(T.dest))
  }

  def compile = Task {
    println("Compiling...")
    super.compile()
  }

  def run(args: Task[Args] = T.task(Args())) = T.command {
    println("Running..." + args().value.mkString(" "))
    super.run(args)()
  }
}
object foo2 extends KotlinModule {

  def kotlinVersion = "1.9.24"

  def generatedSources = Task {
    os.write(T.dest / "Foo.kt", """...""")
    Seq(PathRef(T.dest))
  }
}

object foo3 extends KotlinModule {

  def kotlinVersion = "1.9.24"

  def sources = Task {
    os.write(T.dest / "Foo.kt", """...""")
    super.sources() ++ Seq(PathRef(T.dest))
  }
}

Native C Code with JNI

build.mill (download, browse)
package build
import mill._, kotlinlib._, util.Jvm

object `package` extends RootModule with KotlinModule {

  def mainClass = Some("foo.HelloWorldKt")

  def kotlinVersion = "1.9.24"

  // Additional source folder to put C sources
  def nativeSources = T.sources(millSourcePath / "native-src")

  // Compile C
  def nativeCompiled = Task {
    val cSourceFiles = nativeSources().map(_.path).flatMap(os.walk(_)).filter(_.ext == "c")
    val output = "libhelloworld.so"
    os.proc(
      "clang",
      "-shared",
      "-fPIC",
      "-I" + sys.props("java.home") + "/include/", // global JVM header files
      "-I" + sys.props("java.home") + "/include/darwin",
      "-I" + sys.props("java.home") + "/include/linux",
      "-o",
      T.dest / output,
      cSourceFiles
    )
      .call(stdout = os.Inherit)

    PathRef(T.dest / output)
  }

  def forkEnv = Map("HELLO_WORLD_BINARY" -> nativeCompiled().path.toString)

  object test extends KotlinTests with TestModule.Junit5 {
    def ivyDeps = super.ivyDeps() ++ Agg(
      ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
    )
    def forkEnv = Map("HELLO_WORLD_BINARY" -> nativeCompiled().path.toString)
  }
}

This is an example of how use Mill to compile C code together with your Kotlin code using JNI. There are three two steps: defining the C source folder, and then compiling the C code using clang. After that we have the libhelloworld.so on disk ready to use, and in this example we use an environment variable to pass the path of that file to the application code to load it using System.load.

The above builds expect the following project layout:

build.mill
src/
    foo/
        HelloWorld.kt

native-src/
    HelloWorld.c

test/
    src/
        foo/
            HelloWorldTest.kt

This example is pretty minimal, but it demonstrates the core principles, and can be extended if necessary to more elaborate use cases. The native* tasks can also be extracted out into a trait for re-use if you have multiple `KotlinModule`s that need native C components.

> ./mill run
Hello, World!

> ./mill test
Test foo.HelloWorldTest simple started
Test foo.HelloWorldTest simple finished...
...