Modules

mill.Module serves two main purposes:

  1. As objects, they serve as namespaces that let you group related Tasks together to keep things neat and organized.

  2. As traits, they are re-usable templates that let you replicate groups of related Tasks and sub-Modules while allowing customizations

Mill’s comes with built in modules such as mill.scalalib.ScalaModule and mill.scalalib.CrossSbtModule, but you can also define your own modules to do things that are not built-in to Mill.

Simple Modules

The path to a Mill module from the root of your build file corresponds to the path you would use to run tasks within that module from the command line. e.g. for the following build.sc:

build.sc (download, browse)
import mill._

object foo extends Module {
  def bar = T { "hello" }
  object qux extends Module {
    def baz = T { "world" }
  }
}

You would be able to run the two targets via mill foo.bar or mill foo.qux.baz. You can use mill show foo.bar or mill show foo.baz.qux to make Mill echo out the string value being returned by each Target. The two targets will store their output metadata and files at ./out/foo/bar.{json,dest} and ./out/foo/baz/qux.{json,dest} respectively.

> ./mill foo.bar
> ./mill foo.qux.baz

> ./mill show foo.bar
"hello"

> ./mill show foo.qux.baz
"world"

> cat ./out/foo/bar.json # task output path follows module hierarchy
..."value": "hello"...

> cat ./out/foo/qux/baz.json
..."value": "world"...

Trait Modules

Modules also provide a way to define and re-use common collections of tasks, via Scala traits. Module trait s support everything nornal Scala traits do: abstract defs, overrides, `super, extension with additional def``s, etc.

trait FooModule extends Module {
  def bar: T[String] // required override
  def qux = T { bar() + " world" }
}

object foo1 extends FooModule{
  def bar = "hello"
  def qux = super.qux().toUpperCase // refer to overriden value via super
}
object foo2 extends FooModule {
  def bar = "hi"
  def baz = T { qux() + " I am Cow" } // add a new `def`
}

Note that the override keyword is implicit in mill, as is T{…​} wrapper.

> ./mill show foo1.bar
"hello"

> ./mill show foo1.qux
"HELLO WORLD"

> ./mill show foo2.bar
"hi"

> ./mill show foo2.qux
"hi world"

> ./mill show foo2.baz
"hi world I am Cow"

The built-in mill.scalalib package uses this to define ScalaModule, SbtModule and TestScalaModule, etc. which contain a set of "standard" operations such as compile, jar or assembly that you may expect from a typical Scala module.

When defining your own module abstractions, you should be using traits and not classes due to implementation limitations

millSourcePath

Each Module has a millSourcePath field that corresponds to the path that module expects its input files to be on disk.

trait MyModule extends Module{
  def sources = T.source(millSourcePath / "sources")
  def target = T { "hello " + os.list(sources().path).map(os.read(_)).mkString(" ") }
}

object outer extends MyModule {
  object inner extends MyModule
}
  • The outer module has a millSourcePath of outer/, and thus a outer.sources referencing outer/sources/

  • The inner module has a millSourcePath of outer/inner/, and thus a outer.inner.sources referencing outer/inner/sources/

> ./mill show outer.target
"hello contents of file inside outer/sources/"

> ./mill show outer.inner.target
"hello contents of file inside outer/inner/sources/"

You can use millSourcePath to automatically set the source folders of your modules to match the build structure. You are not forced to rigidly use millSourcePath to define the source folders of all your code, but it can simplify the common case where you probably want your build-layout and on-disk-layout to be the same.

E.g. for mill.scalalib.ScalaModule, the Scala source code is assumed by default to be in millSourcePath / "src" while resources are automatically assumed to be in millSourcePath / "resources".

You can override millSourcePath:

object outer2 extends MyModule {
  def millSourcePath = super.millSourcePath / "nested"
  object inner extends MyModule
}
> ./mill show outer2.target
"hello contents of file inside outer2/nested/sources/"

> ./mill show outer2.inner.target
"hello contents of file inside outer2/nested/inner/sources/"

Any overrides propagate down to the module’s children: in the above example, outer2 would have its millSourcePath be outer2/nested/ while outer.inner would have its millSourcePath be outer2/nested/inner/.

Note that millSourcePath is meant to be used for a module’s input source files: source code, config files, library binaries, etc. Output is always in the out/ folder and cannot be changed, e.g. even with the overridden millSourcePath the output paths are still the default ./out/outer2 and ./out/outer2/inner folders:

> cat ./out/outer2/target.json
..."value": "hello contents of file inside outer2/nested/sources/"...

> cat ./out/outer2/inner/target.json
..."value": "hello contents of file inside outer2/nested/inner/sources/"...

Use Case: DIY Java Modules

This section puts together what we’ve learned about Tasks and Modules so far into a worked example: implementing our own minimal version of mill.scalalib.JavaModule from first principles.

build.sc (download, browse)
import mill._

trait DiyJavaModule extends Module{
  def moduleDeps: Seq[DiyJavaModule] = Nil
  def mainClass: T[Option[String]] = None

  def upstream: T[Seq[PathRef]] = T{ T.traverse(moduleDeps)(_.classPath)().flatten }
  def sources = T.source(millSourcePath / "src")

  def compile = T {
    val allSources = os.walk(sources().path)
    val cpFlag = Seq("-cp", upstream().map(_.path).mkString(":"))
    os.proc("javac", cpFlag, allSources, "-d", T.dest).call()
    PathRef(T.dest)
  }

  def classPath = T{ Seq(compile()) ++ upstream() }

  def assembly = T {
    for(cp <- classPath()) os.copy(cp.path, T.dest, mergeFolders = true)

    val mainFlags = mainClass().toSeq.flatMap(Seq("-e", _))
    os.proc("jar", "-c", mainFlags, "-f", T.dest / s"assembly.jar", ".")
      .call(cwd = T.dest)

    PathRef(T.dest / s"assembly.jar")
  }
}

object foo extends DiyJavaModule {
  def moduleDeps = Seq(bar)
  def mainClass = Some("foo.Foo")

  object bar extends DiyJavaModule
}

object qux extends DiyJavaModule {
  def moduleDeps = Seq(foo)
  def mainClass = Some("qux.Qux")
}

Some notable things to call out:

  • def moduleDeps is not a Target. This is necessary because targets cannot change the shape of the task graph during evaluation, whereas moduleDeps defines module dependencies that determine the shape of the graph.

  • Using T.traverse to recursively gather the upstream classpath. This is necessary to convert the Seq[T[V]] into a T[Seq[V]] that we can work with inside our targets

  • We use the millSourcePath together with T.workspace to infer a default name for the jar of each module. Users can override it if they want, but having a default is very convenient

  • def cpFlag is not a task or target, it’s just a normal helper method.

This simple set of DiyJavaModule can be used as follows:

> ./mill showNamed __.sources
{
  "foo.sources": ".../foo/src",
  "foo.bar.sources": ".../foo/bar/src",
  "qux.sources": ".../qux/src"
}

> ./mill show qux.assembly
".../out/qux/assembly.dest/assembly.jar"

> java -jar out/qux/assembly.dest/assembly.jar
Foo.value: 31337
Bar.value: 271828
Qux.value: 9000

> ./mill show foo.assembly
".../out/foo/assembly.dest/assembly.jar"

> java -jar out/foo/assembly.dest/assembly.jar
Foo.value: 31337
Bar.value: 271828

Like any other Targets, the compilation and packaging of the Java code is incremental: if you change a file in foo/src/ and run qux.assembly, foo.compile and qux.compile will be re-computed, but bar.compile will not as it does not transitively depend on foo.sources. We did not need to build support for this caching and invalidation ourselves, as it is automatically done by Mill based on the structure of the build graph.

Note that this is a minimal example is meant for educational purposes: the mill.scalalib.JavaModule and ScalaModule that Mill provides is more complicated to provide additional flexibility and performance. Nevertheless, this example should give you a good idea of how Mill modules can be developed, so you can define your own custom modules when the need arises.

External Modules

Libraries for use in Mill can define ExternalModules: Modules which are shared between all builds which use that library:

package foo
import mill._

object Bar extends mill.define.ExternalModule {
  def baz = T { 1 }
  def qux() = T.command { println(baz() + 1) }

  lazy val millDiscover = mill.define.Discover[this.type]
}

In the above example, Bar is an ExternalModule living within the foo Java package, containing the baz target and qux command. Those can be run from the command line via:

mill foo.Bar/baz
mill foo.Bar/qux

ExternalModules are useful for someone providing a library for use with Mill that is shared by the entire build: for example, mill.scalalib.ZincWorkerApi/zincWorker provides a shared Scala compilation service & cache that is shared between all ScalaModules, and mill.scalalib.GenIdea/idea lets you generate IntelliJ projects without needing to define your own T.command in your build.sc file

Foreign Modules

Mill can load other mill projects from external (or sub) folders, using Ammonite’s $file magic import, allowing to depend on foreign modules. This allows, for instance, to depend on other projects' sources, or split your build logic into smaller files.

For instance, assuming the following structure :

foo/
    build.sc
    bar/
        build.sc
baz/
    build.sc

you can write the following in foo/build.sc :

import $file.bar.build
import $file.^.baz.build
import mill._

def someFoo = T {

    ^.baz.build.someBaz(...)
    bar.build.someBar(...)
    ...
}

The output of the foreign tasks will be cached under foo/out/foreign-modules/.