Migrating Makefile to Mill

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 an Either type and return the standard error instead (after of course cleaning the screen using the System.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 on html.
  • 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.