Modules
mill.Module
serves two main purposes:
-
As
object
s, they serve as namespaces that let you group relatedTask
s together to keep things neat and organized. -
As
trait
s, they are re-usable templates that let you replicate groups of relatedTask
s and sub-Module
s 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
:
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 trait
s. Module trait
s support everything normal Scala
trait
s do: abstract def
s, 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`
}
This generates the following module tree and task graph, with the dotted boxes and arrows representing the module tree, and the solid boxes and arrows representing the task graph
Note that the override
keyword is optional 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 trait
s
and not class
es 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 amillSourcePath
ofouter/
, and thus aouter.sources
referencingouter/sources/
-
The
inner
module has amillSourcePath
ofouter/inner/
, and thus aouter.inner.sources
referencingouter/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 Task
s and Module
s
so far into a worked example: implementing our own minimal version of
mill.scalalib.JavaModule
from first principles.
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")
}
}
This defines the following build graph for DiyJavaModule
. Note that some of the
edges (dashed) are not connected; that is because DiyJavaModule
is abstract, and
needs to be inherited by a concrete object
before it can be used.
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, whereasmoduleDeps
defines module dependencies that determine the shape of the graph. -
Using
T.traverse
to recursively gather the upstream classpath. This is necessary to convert theSeq[T[V]]
into aT[Seq[V]]
that we can work with inside our targets -
We use the
millSourcePath
together withT.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.
Below, the inherit DiyJavaModule
in three object
s: foo
, bar
, and qux
:
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")
}
This results in the following build graph, with the build graph for DiyJavaModule
duplicated three times - once per module - with the tasks wired up between the modules
according to our overrides for moduleDeps
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 Target
s, 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 module
s can be
developed, so you can define your own custom modules when the need arises.
Backticked Names
import mill._
import mill.scalalib._
object `hyphenated-module` extends Module {
def `hyphenated-target` = T{
println("hyphenated target in a hyphenated module.")
}
}
object unhyphenatedModule extends Module {
def unhyphenated_target = T{
println("unhyphenated target in an unhyphenated module.")
}
}
Mill modules and tasks may be composed of the following character types:
-
Alphanumeric (A-Z, a-z, and 0-9)
-
Underscore (
_
) -
Hyphen (
-
)
Due to Scala naming restrictions, module and task names with hyphens must be
surrounded by back-ticks (`
).
Using hyphenated names at the command line is unaffected by these restrictions.
> ./mill hyphenated-module.hyphenated-target
hyphenated target in a hyphenated module.
> ./mill unhyphenatedModule.unhyphenated_target
unhyphenated target in an unhyphenated module.
External Modules
Libraries for use in Mill can define ExternalModule
s: Module
s 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
ExternalModule
s 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 ScalaModule
s, 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/
.