Linting Scala Projects
This page will discuss common topics around maintaining the code quality of Scala codebases using the Mill build tool
Linting and Autofixing with Scalafix
Scalafix is a tool that analyzes your Scala source code, performing intelligent analyses and code quality checks, and is often able to automatically fix the issues that it discovers. It can also perform automated refactoring.
Mill supports Scalafix through the Mill-Scalafix third party module. See the module documentation for more details:
Linting with Acyclic Files Enforcement
Acyclic is a Scala compiler plugin that detects circular dependencies between files within a module. This can be very useful for ensuring code quality at a high-level:
-
While most linters can be concern themselves at a micro-level with formatting and whitespace, acyclic is concerned at a macro-level with how your codebase is structured into different files
-
While most linters satisfied by tweaking whitespace, circular dependencies may need significant refactorings like dependency-injection or interface-implementation-separation to resolve.
As a Scala compiler plugin, Acyclic can be enabled on any ScalaModule
by
adding its compileMvnDeps,
scalacPluginMvnDeps
, and scalacOptions
as
shown below:
package build
import mill._, scalalib._
object `package` extends ScalaModule {
def scalaVersion = "2.13.11"
def compileMvnDeps = Seq(mvn"com.lihaoyi:::acyclic:0.3.15")
def scalacPluginMvnDeps = Seq(mvn"com.lihaoyi:::acyclic:0.3.15")
def scalacOptions = Seq("-P:acyclic:force")
}
package foo
object Foo {
val value = 123
def main(args: Array[String]): Unit = {
println("hello " + Bar)
}
}
package foo
object Bar {
val value = Foo + " world"
}
Here we have a single ScalaModule
with two files: Foo.scala
and Bar.scala
.
Bar
and Foo
both depend on each other, which usually indicates an issue:
> ./mill compile
error: Unwanted cyclic dependency
...src/Bar.scala...
val value = Foo + " world"
^
symbol: object Foo
...src/Foo.scala...
println("hello " + Bar)
^
symbol: object Bar
Usually the code should be refactored such that references between files
is only one way. For this example, we remove the reference to Foo
in Bar.scala
,
which allows the code to compile:
> sed -i.bak 's/Foo/Bar/g' src/Bar.scala
> ./mill compile
done compiling
Autoformatting with ScalaFmt
package build
import mill._, scalalib._
object `package` extends ScalaModule {
def scalaVersion = "2.13.11"
}
# Newer versions won't work with Java 8!
version = "3.7.15"
runner.dialect = scala213
Mill supports code formatting via scalafmt out of the box.
You can reformat your project’s code globally with mill mill.scalalib.scalafmt/
command,
specific modules via mill mill.scalalib.scalafmt/ '{foo,bar}.sources
or only check the code’s format with +mill mill.scalalib.scalafmt/checkFormatAll
.
By default, ScalaFmt checks for a .scalafmt.conf
file at the root of repository.
> cat src/Foo.scala # initial poorly formatted source code
package foo
object Foo{
def main(args:
Array[String
]
):Unit=
{println("hello world")
}
}
> ./mill mill.scalalib.scalafmt/checkFormatAll
error: ...Found 1 misformatted files
> ./mill mill.scalalib.scalafmt/
> cat src/Foo.scala
package foo
object Foo {
def main(args: Array[String]): Unit = { println("hello world") }
}
> ./mill mill.scalalib.scalafmt/checkFormatAll
Everything is formatted already
You can modify .scalafmt.conf
to adjust the formatting as desired:
> echo "maxColumn: 50" >> .scalafmt.conf
> ./mill mill.scalalib.scalafmt/
> cat src/Foo.scala
package foo
object Foo {
def main(args: Array[String]): Unit = {
println("hello world")
}
}
If entering the long fully-qualified module name mill.scalalib.scalafmt/
is tedious, you can add
an External Module Alias
to give it a shorter name that’s easier to type
Auto-formatting with Spotless
If your project has file groups each requiring different formatting, you may want to give Mill’s Spotless plugin a try. It supports formatting all files with a single command as opposed to using a different plugin/command for each group.
We define a module, containing cross Scala modules, that extends SpotlessModule and provide a JSON configuration file with the format specifications.
package build
import mill.Cross
import mill.scalalib.CrossScalaModule
import mill.scalalib.spotless.SpotlessModule
object `package` extends SpotlessModule {
object lib extends Cross[LibModule]("2.13.15", "3.7.0")
trait LibModule extends CrossScalaModule
}
import mylib.Unused;
import mylib.UsedB;
import mylib.UsedA;
public class A {
/**
* Some javadoc.
*/
public static void main(String[] args) {
System.out.println("A very very very very very very very very very very very very very very very very very very very very very long string that goes beyond the 100-character line length.");
UsedB.someMethod();
UsedA.someMethod();
}
}
sealed trait B
object B{
case object F extends B
case object T extends B
}
enum B:
case F,T
version = "3.8.5"
runner.dialect = scala213
version = "3.8.5"
runner.dialect = scala3
[
{
"includes": [
"glob:**.java"
],
"steps": [
{
"$type": "PalantirJavaFormat"
}
]
},
{
"includes": [
"glob:**.scala"
],
"excludes": [
"glob:**/src-3**"
],
"steps": [
{
"$type": "ScalaFmt",
"version": "3.8.5"
}
]
},
{
"includes": [
"glob:**/src-3**"
],
"steps": [
{
"$type": "ScalaFmt",
"version": "3.8.5",
"configFile": ".scalafmt3.conf"
}
]
}
]
As per the specifications:
-
All Java files are to be formatted using
PalantirFormatJava
step. -
All Scala 2 files are to be formatted using
Scalafmt
step with the configuration in.scalafmt.conf
. -
All Scala 3 files are to be formatted using
Scalafmt
step with the configuration in.scalafmt3.conf
.
Most fields have default values and can be omitted in the JSON file.
An example is the ScalaFmt.configFile field that references the .scalafmt.conf file.
|
If no .scalafmt.conf file is provided, the default configuration from scalafmt is used.
Otherwise, you must also specify the ScalaFmt.version field in the JSON file.
|
Next, we run the inherited spotless
command to check/apply the format specifications.
> ./mill spotless --check # check fails initially
checking format in 1 java files
format errors in lib/src/A.java
checking format in 1 scala files
format errors in lib/src-2/B.scala
checking format in 1 scala files
format errors in lib/src-3/B.scala
format errors in 3 files
error: ...format check failed for 3 files
> ./mill spotless # auto-fix format
formatting lib/src/A.java
formatting lib/src-2/B.scala
formatting lib/src-3/B.scala
formatted 3 files
> ./mill spotless # fast incremental format
1 java files are already formatted
1 scala files are already formatted
1 scala files are already formatted
This demonstrates how different file groups can be formatted with a single command.
For the full list of format steps and configuration options, please refer to the API documentation.
As shown, for a multi-module project, it is sufficient to extend SpotlessModule in your
build root module and define a format specification for each use-case.
|
You can also run spotless
globally
if you prefer not to have to extend SpotlessModule
.
> ./mill mill.scalalib.spotless.SpotlessModule/ --check
checking format in 1 java files
checking format in 1 scala files
checking format in 1 scala files
format check completed
Ratchet
Similar to the Spotless Gradle and Maven plugins, Mill provides the ability to enforce formatting gradually aka ratchet.
We define a module with an incorrectly formatted file.
package build
import mill.javalib.JavaModule
import mill.scalalib.spotless.SpotlessModule
object `package` extends JavaModule with SpotlessModule
import mylib.Unused;
import mylib.UsedB;
import mylib.UsedA;
public class A {
/**
* Some javadoc.
*/
public static void main(String[] args) {
System.out.println("A very very very very very very very very very very very very very very very very very very very very very long string that goes beyond the 100-character line length.");
UsedB.someMethod();
UsedA.someMethod();
}
}
A Git repository is initialized and a base commit is created with the incorrectly formatted file.
Since no .spotless-formats.json file is present, a default list of format specifications is used.
The default is meant for use cases where some basic formatting is sufficient.
|
> git init . -b main
> git add .gitignore build.mill src/A.java
> git commit -a -m "1"
Next, we create a new file with format errors.
> echo " module hello {}" > src/module-info.java # content has leading space at start
> ./mill spotless --check
format errors in build.mill
format errors in src/A.java
format errors in src/module-info.java
error: ...format check failed for 3 files
The spotless
command finds format errors in all files.
But we do not want to fix the formatting in files that were committed previously.
Instead, we use the ratchet
command to identify and format files that differ between Git trees.
> ./mill ratchet --check # format changes in working tree since HEAD commit
ratchet found changes in 1 files
format errors in src/module-info.java
error: ...format check failed for 1 files
> ./mill ratchet # auto-fix formatting in changeset
ratchet found changes in 1 files
formatting src/module-info.java
formatted 1 files
This demonstrates how to introduce formatting incrementally into your project.
You can also set up actions on CI systems that compare and check/fix formatting between 2 revisions.
> git add src/module-info.java # stage and
> git commit -a -m "2" # commit changes
> ./mill ratchet --check HEAD^ HEAD # format changes between last 2 commits
ratchet found changes in 1 files
1 java files are already formatted
CI actions may require additional setup. |
The ratchet
command is also available globally.
> ./mill mill.scalalib.spotless.SpotlessModule/ratchet --check HEAD^ HEAD
ratchet found changes in 1 files
Code Coverage with Scoverage
//| mvnDeps:
//| - com.lihaoyi::mill-contrib-scoverage:$MILL_VERSION
package build
import mill._, scalalib._
import mill.contrib.scoverage._
object `package` extends ScoverageModule {
def scoverageVersion = "2.1.0"
def scalaVersion = "2.13.11"
def mvnDeps = Seq(
mvn"com.lihaoyi::scalatags:0.13.1",
mvn"com.lihaoyi::mainargs:0.6.2"
)
object test extends ScoverageTests /*with TestModule.Utest */ {
def mvnDeps = Seq(mvn"com.lihaoyi::utest:0.8.5")
def testFramework = "utest.runner.Framework"
}
}
This is a basic Mill build for a single ScalaModule
, enhanced with
Scoverage plugin. The root module extends the ScoverageModule
and
specifies the version of scoverage version to use here: 2.1.0
. This
version can be changed if there is a newer one. Now you can call the
scoverage tasks to produce coverage reports.
The sub test module extends ScoverageTests
to transform the
execution of the various testXXX tasks to use scoverage and produce
coverage data.
This lets us perform the coverage operations but before that you
must first run the test.
./mill test
then ./mill scoverage.consoleReport
and get your
coverage into your console output.
> ./mill test # Run the tests and produce the coverage data
...
+ foo.FooTests...simple ... <h1>hello</h1>
+ foo.FooTests...escaping ... <h1><hello></h1>
> ./mill resolve scoverage._ # List what tasks are available to run from scoverage
...
scoverage.consoleReport
...
scoverage.htmlReport
...
scoverage.xmlCoberturaReport
...
scoverage.xmlReport
...
> ./mill scoverage.consoleReport
...
Statement coverage.: 16.67%
Branch coverage....: 100.00%
Mill supports Scala code coverage analysis via the Scoverage contrib plugin. See the contrib plugin documentation for more details:
Binary Compatibility Enforcement
If you want to lint against binary compatibility breakages, e.g. when developing an upstream library that downstream libraries may compile against, you can use the Lightbend Migration Manager (MiMa) tool via the mill-mima plugin. See the mill-mima documentation for more details: