Today, I gave the Mill build tool a try and I am very enthusiastic to see it work very quickly for our Elm project Sketch-n-Sketch. Below, I describe the old Makefile and then the Mill equivalent.
As for many other build tools, Mill keeps tracks of dependencies between tasks. Except that tasks can be simply defined as pure functions, and that's the power of Mill.
Furthermore, Mill can watch sources and provides an easy access to the type-proof Ammonite shell commands, make it suitable for a general-purpose build tool.
The original Makefile whose absolute path was ./src/Makefile
looked like this:
ELMMAKE=elm-make all: $(ELMMAKE) Main.elm --output ../build/out/sns.js html: all $(ELMMAKE) Main.elm --output ../build/out/sns.js cp Native/aceCodeBox.js ../build/out/ cp Native/aceTooltips.js ../build/out/ cp Native/animationLoop.js ../build/out/ cp Native/fileHandler.js ../build/out/ cp Native/deucePopupPanelInfo.js ../build/out/ cp Native/proseScroller.js ../build/out/ cp Native/dotGraph.js ../build/out/ cp Native/colorScheme.js ../build/out/ cp Native/keyBlocker.js ../build/out/ cp ../ace-builds/src/ace.js ../build/out/ cp ../ace-builds/src/mode-little.js ../build/out/ cp ../ace-builds/src/theme-chrome.js ../build/out/ cp ../viz.js/viz.js ../build/out/ mkdir -p ../build/out/img cp ../img/sketch-n-sketch-logo.png ../build/out/img/ cp ../img/light_logo.svg ../build/out/img/ cp ../img/*.png ../build/out/img/
A few notes about this makefile. There are two targets, all
and html
. html
is complete in the sense that it not only compiles the Elm files, but it also copies the javascript files necessary to the final application, as well as other files. Also, we wanted to copy these files only when needed, mostly after a successful compilation.
Following the excellent Mill's documentation, I converted the above Makefile to a top-level ./build.sc
containing :
import mill._, ammonite.ops._ val ELM_MAKE = "elm-make" object SNS extends Module { def millSourcePath = pwd implicit def src: Path = pwd / 'src def sourceRoot = T.sources { src } def nativeRoot = T.sources { src / "Native" } def allSources = T { sourceRoot() ++ nativeRoot() } val outDir = pwd/'build/'out def all = T{ allSources() stderr( %%(ELM_MAKE,"Main.elm", "--output", outDir/"sns.js")) } def copyNative = T{ nativeRoot() all() match { List("aceCodeBox.js", "aceTooltips.js", "animationLoop.js", "fileHandler.js", "deucePopupPanelInfo.js", "proseScroller.js", "dotGraph.js", "colorScheme.js", "keyBlocker.js" ).map(src/'Native/_).foreach(copy(_, outDir)) List("ace.js", "mode-little.js", "theme-chrome.js" ).map(pwd/"ace-builds"/'src/_).foreach(copy(_, outDir)) copy(pwd/"viz.js"/"viz.js", outDir) mkdir ! pwd/'build/'out/'img copy(pwd/'img/"light_logo.svg", outDir/'img) ls ! pwd/'img |? (_.ext == "png") |! (copy(_, outDir / 'img)) } def html = T{ copyNative() all() match { case Left(msg) => System.out.print("\033[H\033[2J"+msg) false case Right(ok) => true } } def copy(file: Path, outDir: Path) = { val out = outDir/file.last if (exists! out) rm(out) mkdir! outDir cp(file, out) } def stderr(commandResult: =>CommandResult): Either[String, String] = { try { Right(commandResult.err.string) } catch { case ammonite.ops.ShelloutException(commandResult) => Left(commandResult.err.string) } } } def html = T{ SNS.html() }
It's not completely straightforward to do this transformation, but I was able to do it in less than 2 hours. Not bad for a first-time usage of Mill I hope 🙂
I changed the order of the build file, so that html
now depends on the files being copied.
Here were some necessary tweaks to make this magic happen:
%%
requires an implicit path in scope, which I define at the beginning of the object.%%
launches a process but throws an exception if the exit code is not zero. I prefer to catch this exception using anEither
type and return the standard error instead (after of course cleaning the screen using theSystem.out.print("\033[H\033[2J")
command)- I needed to specify a return value for the task html. If I omitted the
true
/false
, the type checker might infer the return type "any" for which there is no pickler, i.e. a way to nicely format the data. It is likely to be useful to return a boolean if other future tasks depend onhtml
. - Ammonite's default methods to copy files are not sufficient for what we need here, which is to overwrite a file into a folder. I thus created a wrapper for that, to make the syntax nice.
- I wrapped all the tasks into a module, but this was completely optional, I could have had a flat file instead. To make the task
html
appear top-level, I just creates a reference to it instead.
The magic happens:
- Thanks to Mill's task dependency feature, if we don't change the native files, they are not copied anyway, which saves ~1s of build compared to the original Makefile. Excellent!
- It's very easy to refactor common variables (e.g. outDir)
- Tasks can be much more easily composed than with other build tools. For example, the allSources is a task that is executed only when one of the two source directories change.
- By executing
./mill -watch html
Mill can recompile the sources as soon as they are modified. That's great!
I conclude that Mill 0.2.2 passed the test of replacing Makefile and is not only useful for Scala projects, but for general-purpose projects. Mill seems to be the right way of what a build file should be.