Scala: A “Hello, world” example with the Mill build tool

I’m currently working on a small Scala project, so I thought I’d try Li Haoyi’s Mill build tool – currently at version 0.6.1 — for about a week and see how things go.

In this tutorial I’ll share an example of how to use Mill on a little “Hello, world” example. Once I get through the basics of that project, I’ll also show how to use ScalaTest with Mill.

Installing Mill

To install Mill, see this page.

I initially installed Mill with Homebrew, but that involved Homebrew installing OpenJDK 13, which seemed like overkill for what I want to do, so I uninstalled that and then reinstalled Mill with the “Manual” approach.

My Github project

If you want to follow along with the discussion that follows, I put a Github project here:

Creating a Mill project

I’m used to working with the common/standard directory structures that Maven and SBT use, but Mill is a little different. Mill seems to promote the concept of potentially having multiple modules or sub-projects inside a main project, so its directory structure is different.

For a little Scala project, creating a Mill directory structure looks like this:

mkdir MillTest1
cd MillTest1

mkdir HelloWorld
mkdir -p HelloWorld/src/main/scala
touch build.sc

Visually the directory structure looks like this:

> tree .
.
├── HelloWorld
│   └── src
│       └── main
│           └── scala
└── build.sc

If you want to use Java classes in your project and also have a resources directory, use these commands as well:

mkdir -p HelloWorld/src/main/java
mkdir -p HelloWorld/src/main/resources

For the rest of this tutorial I’ll assume that you’re in the MillTest1 directory, with build.sc in the current directory and HelloWorld as a subdirectory.

Mill’s build.sc file

Where SBT uses a build.sbt build file, Mill uses a build.sc file. With our project going under the HelloWorld directory, the simplest possible build.sc file looks like this:

import mill._, scalalib._

object HelloWorld extends ScalaModule {
    def scalaVersion = "2.12.11"
}

Notice that the name of the object here — HelloWorld — must match your subdirectory name. This makes sense when you think about having multiple modules. For instance, if you had modules named Foo and Bar in directories named Foo and Bar, your build file would look like this:

import mill._, scalalib._

object Foo extends ScalaModule {
    def scalaVersion = "2.12.11"
}

object Bar extends ScalaModule {
    def scalaVersion = "2.12.11"
}

Creating a HelloWorld example

To create a “Hello, world” example, I want to put my source code in a package named hello, so I first create a subdirectory in the src tree:

mkdir HelloWorld/src/main/scala/hello

Then I put these contents in a file at HelloWorld/src/main/scala/hello/Hello.scala:

package hello

object Hello extends App {
    println("Hello, world")
}

Running the example

To run this example, run this mill command at your command line:

mill HelloWorld

The output of that command looks like this:

[27/37] HelloWorld.compile 
Compiling compiler interface...
warning: there were three deprecation warnings (since 2.12.0); re-run with -deprecation for details
warning: there were three feature warnings; re-run with -feature for details
two warnings found
[info] Compiling 1 Scala source to MillTest1/out/HelloWorld/compile/dest/classes ...
[info] Done compiling.
[37/37] HelloWorld.run 
Hello, world

If that worked, congratulations, you just compiled and ran your first Scala project with Mill.

It’s cool to see the “Hello, world” output, but ...

Deprecation and feature warnings?

It’s a little weird to see deprecation and feature warnings on a one-class project like this, so let’s see if we can figure out what’s going on.

From this example it looks like I should be able to specify the scalac deprecation and feature options like this:

import mill._, scalalib._

// HelloWorld must match the subdirectory name
object HelloWorld extends ScalaModule {
    def scalaVersion = "2.12.11"

    def scalacOptions = Seq(
        "-deprecation",
        "-feature"
    )
}

However, after running these commands:

mill clean
mill HelloWorld

I still see the same output. I also tried this and it didn’t work:

def scalacOptions = super.scalacOptions ++ Seq( "-deprecation", "-feature" )

so ... this item is TBD.

scalac works fine

Note: Just in case I missed something obvious, I came back and compiled my class manually using scalac 2.13.1, and did not see any warnings:

scalac HelloWorld/src/main/scala/hello/Hello.scala

So whatever those feature and deprecation warnings are about, they have nothing to do with my class.

Update

Up until today (April 20, 2020), I’ve been working on my project with a plain text editor (TextMate), but today I started using VS Code. An interesting side effect of doing this is that VS Code recognizes that I’m working in a Mill workspace, and fires up the Metals and Bloop tooling, and some part of this eliminates the warning messages I write about here (and later in this tutorial).

This is what you see when you start VS Code inside a Mill project:

VS Code recognizes Mill build workspace

I’m not sure why this makes those warning messages go away, but I’ll try to look into it this week.

Other commands

Skipping over that problem, it’s worth mentioning that there are several other Mill commands you can run. For instance, you compile your project like this:

mill HelloWorld.compile

You can also “continuously compile” your project — recompiling the project any time a file changes — with either of these “watch” commands:

mill --watch HelloWorld.compile
mill -w HelloWorld.compile

You can run your project with either of these commands:

mill HelloWorld
mill HelloWorld.run

I’ll show test-related commands in a few moments.

One other note: You can also run similar commands from inside the Mill REPL. The commands are a little different there, and I’ll show those in a future tutorial.

Mill output

Whenever you compile or run your project, Mill writes its output to the out directory inside your project. A potentially nice thing about this is that it writes a meta.json file to most of the directories inside that directory with information about the build. For instance, if you run a command like this on the “Hello, world” project:

find out | grep meta.json

you’ll see that there are 69 meta.json files under the out directory. I looked at a few of those files and didn’t see anything interesting, but the Mill documentation mentions that these files can be helpful when debugging problems.

Adding ScalaTest unit tests to a Mill project

Next up, let’s add a unit test or two to see what that’s like in Mill.

Directory structure

Because Mill’s directories are a little different, the first thing to do is to create a directory structure for our tests. The way to do this is to create a test directory under the HelloWorld directory. To create the entire structure with one command, including a directory for our hello package, use this command:

mkdir -p HelloWorld/test/src/test/hello

I’ll also create the test file in that directory to be clear about what I’m doing:

touch HelloWorld/test/src/test/hello/HelloTests.scala

In a few moments I’ll add some tests to that HelloTests.scala file.

Here’s what the directory structure looks like now:

$ tree HelloWorld

HelloWorld
├── src
│   └── main
│       ├── resources
│       └── scala
│           └── hello
│               └── Hello.scala
└── test
    └── src
        └── test
            └── hello
                └── HelloTests.scala

Updating build.sc

With that test directory structure in place, the next thing to do is to update our build.sc file to add the ScalaTest dependencies, and also reflect that test directory. Here’s the updated file:

import mill._, scalalib._

object HelloWorld extends ScalaModule {
    def scalaVersion = "2.12.11"

    object test extends Tests {
        def ivyDeps = Agg(
            ivy"org.scalactic::scalactic:3.1.1",
            ivy"org.scalatest::scalatest:3.1.1"
        )
        def testFrameworks = Seq("org.scalatest.tools.Framework")
    }
}

Notice a few things:

  • I create the test object inside the HelloWorld object
  • Dependencies are added with the ivyDeps parameter
  • Mill uses an ivy string interpolator (similar to other interpolators like s, raw, f) and the syntax shown for Scala resources
  • I haven’t looked into the Agg class yet, but it looks like a Seq, so that’s good enough for now

A thing I appreciate at this point is that the build.sc file is just plain Scala code. I’m not a huge fan of most DSLs, so that’s a nice win for me.

Writing the tests

To create the tests, first edit the Hello.scala file so we have something to test. Its new contents look like this:

package hello

object Hello extends App {
    println(Constants.hello)
}

object Constants {
    val hello = "Hello, world"
}

Now lets edit our test file. As a reminder, this is its name and path:

HelloWorld/test/src/test/hello/HelloTests.scala

Let’s put these contents in that file:

package hello
  
import org.scalatest.funsuite.AnyFunSuite

class HelloSuite extends AnyFunSuite {

    test("Test that ‘Hello’ string is correct") {
        assert(Constants.hello == "Hello, world")
    }

    test ("Another test ...") (pending)

}

Running the tests

To run the tests, use this command:

mill HelloWorld.test

Its output looks like this:

Compiling MillTest1/build.sc
[50/56] HelloWorld.test.compile 
[info] Compiling 1 Scala source to MillTest1/out/HelloWorld/test/compile/dest/classes ...
[info] Done compiling.
[56/56] HelloWorld.test.test 
HelloSuite:
- Test that ‘Hello’ string is correct
- Another test ... (pending)

Cool, the tests work.

If you want to continually run the tests while you’re working, use Mill’s “watch” option:

$ mill --watch HelloWorld.test

[56/56] HelloWorld.test.test 
HelloSuite:
- Test that ‘Hello’ string is correct
- Another test ... (pending)
Watching for changes to 2 dirs and 4 files... (Ctrl-C to exit)

Real-world: A few things to investigate

As I’ve been trying to use Mill on a real-world project, I’ve run into a couple of rough spots:

  • I get even more warning messages when using Scala 2.13.1
  • When I put a file like application.conf in the src/main/resources directory, Mill doesn’t add it to the classpath when I run my project, and it doesn’t add those files to the Jar file it creates when I create a “fat jar” with its assembly command

Scala 2.13.1

The Scala 2.13.1 output looks like this on the “Hello, world” project:

$ mill HelloWorld

Compiling MillTest1/build.sc
[27/37] HelloWorld.compile 
Compiling compiler interface...
MillTest1/out/mill/scalalib/ZincWorkerModule/worker/dest/2.13.1/unpacked/xsbt/DelegatingReporter.scala:166: warning: match may not be exhaustive.
It would fail on the following inputs: ERROR, INFO, Severity(), WARNING
    sev match {
    ^
warning: there were four deprecation warnings (since 2.12.0)
warning: there were four deprecation warnings (since 2.12.5)
warning: there were two deprecation warnings (since 2.13)
warning: there were 6 deprecation warnings (since 2.13.0)
warning: there were 16 deprecation warnings in total; re-run with -deprecation for details
warning: there were three feature warnings; re-run with -feature for details
7 warnings found
[info] Compiling 1 Scala source to MillTest1/out/HelloWorld/compile/dest/classes ...
[info] Done compiling.
[37/37] HelloWorld.run 
Hello, world

I’ve also found ways to work around the “resources” problem. At the time of this writing I don’t know if this is a bug, missing feature, or something else. My situation is that I have an application.conf HOCON file in that directory, and with SBT, when I run my application, that file is put on the classpath so everything works as expected. And when I use sbt-assembly that file is packaged into the fat jar it creates.

But neither of these things work by default in Mill. There are workarounds for both situations, but this is something that’s easier in SBT.

I haven’t made notes of all the issues I’ve run into, but in another situation I thought I’d make a copy of an existing Mill project to create a new Mill project, and when I ran my cp command it said it could not copy a socket that was in the out directory. That was an unusual quirk that stands out in my memory.

... this post is sponsored by my books ...
 

Summary: Mill “Hello, world”

I’ve only been using Mill for three days, but the things I like are:

  • The build.sc file is plain Scala.
  • The documentation is decent.
  • The Mill commands are logically named and easy to find.
  • The Mill project places an emphasis on several common tasks, including easily creating fat jars and publishing libraries to Maven Central
  • When I’ve run into problems I’ve been able to run some Google queries and find some source code examples.
  • Mill starts and runs pretty quick from the Mac/Unix command line. I’m used to starting the SBT shell and doing everything from inside of it, but the Mill documentation seems to stress commands at the command line, so I’ve been using it that way.

The biggest complaints I have about Mill are (a) the warning messages that are unrelated to my project, (b) not picking up my resources files during the run and assembly tasks, and (c) not printing the name of the “fat jar” file the assembly task creates.

In regards to all of these issues, I haven’t reached out to anyone for assistance yet. When I first start working with a new tool I like to try to figure things out myself, so until now I haven’t shared what I’ve seen. Mill is currently at version 0.6.1, so I went into this expecting to find a few rough spots.

I haven’t hit any show-stoppers yet, so I expect to keep using Mill for at least a week, and if people are interested, I can write more tutorials about it.

All the best,
Al