Linting Kotlin Projects

This page will discuss common topics around enforcing the code quality of Kotlin codebases using the Mill build tool

Linting with Detekt

build.mill (download, browse)
package build

import mill._
import kotlinlib.KotlinModule
import kotlinlib.detekt.DetektModule

object `package` extends KotlinModule with DetektModule {
  def kotlinVersion = "1.9.24"
}

This example shows how to use the Detekt static code analyzer for linting a KotlinModule, by mixing in the trait DetektModule and calling the detekt task:

src/example/Foo.kt (browse)
package example

import java.util.Random

fun main() {
    if (Random.nextBoolean()) {
        if (Random.nextBoolean()) {
            if (Random.nextBoolean()) {
                if (Random.nextBoolean()) {
                    if (Random.nextBoolean()) {
                        if (Random.nextBoolean()) {
                            println("Hello World")
                        }
                    }
                }
            }
        }
    }
}
> ./mill detekt
error: ...Foo.kt:5:5: Function main is nested too deeply. [NestedBlockDepth]

> ./mill detekt --check false
...Foo.kt:5:5: Function main is nested too deeply. [NestedBlockDepth]

Linting with KtLint

build.mill (download, browse)
package build

import mill._
import mill.util.Jvm

import kotlinlib.KotlinModule
import kotlinlib.ktlint.KtlintModule
import mill.define.BuildCtx

object `package` extends KotlinModule with KtlintModule {
  def kotlinVersion = "1.9.24"

  def ktlintConfig0 = Task.Source(BuildCtx.workspaceRoot / ".editorconfig")
  def ktlintConfig = Some(ktlintConfig0())
}

This example shows how to use the KtLint linter on a KotlinModule, by mixing in the trait KtlintModule and calling the ktlint task. ktlint also supports autoformatting to automatically resolve code formatting violations, via the --format flag shown below:

> ./mill ktlint --check true # run ktlint to produce a report, defaults to warning without error
error: ...src/example/FooWrong.kt:6:28: Missing newline before ")" (standard:parameter-list-wrapping)...
...src/example/FooWrong.kt:6:28: Newline expected before closing parenthesis (standard:function-signature)...
...src/example/FooWrong.kt:6:28: Missing trailing comma before ")" (standard:trailing-comma-on-declaration-site)...

> ./mill ktlint --format true

> ./mill ktlint # after fixing the violations, ktlint no longer errors

> ./mill mill.kotlinlib.ktlint/ # alternatively, use external module to check/format

Autoformatting with KtFmt

build.mill (download, browse)
package build

import mill._
import mill.util.Jvm

import kotlinlib.KotlinModule
import kotlinlib.ktfmt.KtfmtModule

object `package` extends KotlinModule with KtfmtModule {
  def kotlinVersion = "1.9.24"
}

This example demonstrates how to use the KtFmt autoformatter from Facebook both to enforce and apply formatting to your KotlinModule source files. You can configure a non-default version of KtFmt by overriding def ktfmtVersion

> ./mill ktfmt --format=false # run ktfmt to produce a list of files which should be formatter
error: ...src/example/FooWrong.kt...

> ./mill ktfmt # running without arguments will format all files
Done formatting ...src/example/FooWrong.kt

> ./mill ktfmt # after fixing the violations, ktfmt no longer prints any file

> ./mill mill.kotlinlib.ktfmt/ __.sources   # alternatively, use external module to check/format

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 that extends SpotlessModule and provide a JSON configuration file with the format specifications.

build.mill (download, browse)
package build

import mill.kotlinlib.KotlinModule
import mill.scalalib.spotless.SpotlessModule

object `package` extends KotlinModule with SpotlessModule {
  def kotlinVersion = "2.1.20"
}
src/A.kt (browse)
import a.*

import kotlinx.android.synthetic.main.layout_name.*

import a.b.c.*
import a.b

fun main() {
    fun name() { a(); return b }
    println(";")
    println();
}
src/B.java (browse)
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();
}
}
LICENSE (browse)
// GPL
.spotless-formats.json (browse)
[
  {
    "includes": [
      "glob:build.mill"
    ],
    "steps": [
      {
        "$type": "ScalaFmt"
      }
    ]
  },
  {
    "includes": [
      "glob:**.java"
    ],
    "steps": [
      {
        "$type": "PalantirJavaFormat"
      },
      {
        "$type": "LicenseHeader",
        "delimiter": "(package|import|public|class|module) "
      }
    ]
  },
  {
    "includes": [
      "glob:**.{kt,kts}"
    ],
    "steps": [
      {
        "$type": "Ktfmt"
      },
      {
        "$type": "LicenseHeader",
        "delimiter": "(package |@file|import )"
      }
    ]
  }
]

As per the specifications:

  • The build.mill is to be formatted using ScalaFmt step.

  • All Java files are to be formatted using PalantirFormatJava and LicenseHeader steps.

  • All Kotlin files are to be formatted using Ktfmt and LicenseHeader steps.

Most fields have default values and can be omitted in the JSON file. An example is the LicenseHeader.header field that references the LICENSE file.

Next, we run the inherited spotless command to check/apply the format specifications.

> ./mill spotless --check   # check fails initially
checking format in 1 mill files
format errors in build.mill
checking format in 1 java files
format errors in src/B.java
checking format in 1 kt files
format errors in src/A.kt
format errors in 3 files
error: ...format check failed for 3 files

> ./mill spotless           # auto-fix format
formatting build.mill
formatting src/B.java
formatting src/A.kt
formatted 3 files

> ./mill spotless           # fast incremental format
1 mill files are already formatted
1 java files are already formatted
1 kt 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.

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 mill files
checking format in 1 java files
checking format in 1 kt 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.

build.mill (download, browse)
package build

import mill.javalib.JavaModule
import mill.scalalib.spotless.SpotlessModule

object `package` extends JavaModule with SpotlessModule
src/A.java (browse)
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 Kover

build.mill (download, browse)
package build

import mill._, kotlinlib._
import kotlinlib.kover.KoverModule

object `package` extends KotlinModule with KoverModule {

  trait KotestTests extends TestModule.Junit5 {
    override def forkArgs: T[Seq[String]] = Task {
      super.forkArgs() ++ Seq("-Dkotest.framework.classpath.scanning.autoscan.disable=true")

    }
    override def mvnDeps = super.mvnDeps() ++ Seq(
      mvn"io.kotest:kotest-runner-junit5:5.9.1"
    )
  }

  def kotlinVersion = "1.9.24"

  override def koverVersion = "0.8.3"

  object test extends KotlinTests with KotestTests with KoverTests

  object bar extends KotlinModule with KoverModule {
    def kotlinVersion = "1.9.24"
    object test extends KotlinTests with KotestTests with KoverTests
  }
}

This is a basic Mill build for a single KotlinModule, enhanced with Kover module. The root module extends the KoverModule and specifies the version of Kover version to use here: 0.8.3. This version can be changed if there is a newer one. Now you can call the Kover tasks to produce coverage reports. The sub test module extends KoverTests to transform the execution of the various testXXX tasks to use Kover and produce coverage data. This lets us perform the coverage operations but before that you must first run the test. ./mill test then ./mill show kover.htmlReport and get your coverage in HTML format. Also reports for all modules can be collected in a single place by running ./mill show mill.kotlinlib.kover/htmlReportAll.

> ./mill test # Run the tests and produce the coverage data
...
... foo.FooTests kotlin - success started

> ./mill resolve kover._ # List what tasks are available to run from kover
...
kover.htmlReport
...
kover.xmlReport
...

> ./mill show kover.htmlReport
...
...out/kover/htmlReport.dest/kover-report...

> cat out/kover/htmlReport.dest/kover-report/index.html
...
...Kover HTML Report: Overall Coverage Summary...

> ./mill show mill.kotlinlib.kover/htmlReportAll # collect reports from all modules
...
...out/mill/kotlinlib/kover/Kover/htmlReportAll.dest/kover-report...

> cat out/mill/kotlinlib/kover/Kover/htmlReportAll.dest/kover-report/index.html
...
...Kover HTML Report: Overall Coverage Summary...