Scruff

Scruff is an experimental, lightweight linter and formatter for Scala, built on top of Tree-Sitter Scala, and written in Rust.

Scruff formatter

Scruff formatter is a fast and normalizing Scala code formatter. Inspired by Python's code formatters like Black and Ruff, Scruff removes all configurations related to styling. See scruff fmt for more details.

Scruff linter

Scruff linter is an experimental syntactic Scala linter, that can process 400k sloc/s. See scruff check for more details.

License

Scruff is provided under MIT License.

Setup

The native binaries are published to GitHub Releases https://github.com/eed3si9n/scruff/releases/tag/v0.3.3.

.scruff.toml

.scruff.toml is used by the scruff CLI for its configuration.

version = "0.3.3"
dialect = "scala3"

exclude-paths = [
  "glob:**/src/sbt-test/**",
]

[lint]
# default rules
# select = [
#   "untyped_implicit_val",
#   "no_return",
#   "no_null",
#   "no_xml",
#   "procedure_syntax",
# ]

Both GitHub Action and sbt-scruff also use .scruff.toml for the scruff version. In other words, it would follow the version specified in .scruff.toml (0.3.3) even if the sbt-scruff version is 0.2.0.

Setting up GitHub Actions

Instead of calling scruffFmtCheck, you can run the scruff CLI on GitHub Actions as follows:

- uses: actions/checkout@v7
# add this immediately after actions/checkout
- uses: eed3si9n/scruff@v0.3.3
  with:
    format: true
    lint: true
- uses: actions/setup-java@v6
  with:
    distribution: "$"
    java-version: "$"
    cache: "sbt"

By default the action runs both scruff fmt --check and scruff check. This action typically finishes in 1 to 2 seconds, faster than setting up JDK.

sbt-scruff

For convenience, We've also created an sbt plugin that adds scruff, scruffFmt, scruffFmtCheck, scruffFmtOnCompile, and scruffCheckOnCompile.

// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-scruff" % "0.3.0")

The first time you call scruffFmt, it will download the binary:

sbt:scruff-root> scruffFmt
[info] downloading scruff 0.3.3 for universal-apple-darwin
[info] 4 files left unchanged
[success] elapsed time: 1 s

scruff runs the scruff CLI in the build's root with the arguments you give it, and fails if scruff exits with an error. For example, to check the build against the lint rules (scruff 0.2.2 or later):

sbt:scruff-root> scruff check

To enable formatting on-compile:

ThisBuild / scruffFmtOnCompile := true

To check the lint rules on-compile, after formatting, fixing what they can (as scruff check --fix) and failing the compile if a rule flags anything left:

ThisBuild / scruffCheckOnCompile := true

scruff fmt

Synopsis

scruff fmt [ options ] [ file | dir... | - ]

Formats the given files and directories in place, or the current directory when none are given. - reads standard input and prints the result. scruff format is the same command.

OptionEffect
--checkWrite nothing; show how each file would change, and exit with 1 if any would
--diffWrite nothing; print a unified diff of each change, and exit with 1 if any would
-q, --quietPrint only the diffs: no file count, no warnings, errors only with --check
-s, --silentPrint nothing but the formatted text or the diff; errors show only in the exit code
--color whenauto (default), always or never; auto colors on a terminal unless NO_COLOR is set

Scruff formatter

Scruff formatter is a fast and normalizing Scala code formatter, written in Rust. Inspired by Python's code formatters like Black and Ruff, Scruff removes all configurations related to styling.

Scruff formatter is intended to be normalizing, which means that the scruffed code looks the same regardless of how it started.

Philosophy

Philosophy: normalize

Inspired by Python code formatters Black and Ruff, Scruff is a normalizing code formatter. This means that the scruffed up code looks the same regardless of how it started. Here's an example:

// original input
object A:
  val a = List(1, 2)
  val b = List(
    1,
    2
  )
  val c =
    List(1, 2)

Using Scalafmt 3.11.5, which is the defacto standard formatter for Scala, the above code is kept as-is after formatting. Scruff ignores previous formatting, and applies uniform whitespace as follows:

// scruffed
object A:
  val a = List(1, 2)
  val b = List(1, 2)
  val c = List(1, 2)

Philosophy: fast

Built on top of Tree-Sitter Scala using Rust, Scruff parses and formats the Scala code in parallel, in native code. When formatting unformatted 207k sloc scala/scala3 repo, Scruff runs in 1.50s:

  • Scruff 0.1.0 (with 12 core): 1.50s (138.6 ksloc/s)

So out-of-box, Scruff is about 6x faster than Scalafmt 3.11.5 to format code mostly due to the default parallelism. Interestingly, on code that's already formatted, Scruff finishes in 0.60s. This is because when a file's output matches its input, Scruff skips re-parsing it and the equivalence check, so each file is parsed only once:

  • Scruff 0.1.0 (with 12 core): 0.60s (339.0 ksloc/s)
  • Scalafmt 3.11.5 (with 1 core): 8.09s (25.2 ksloc/s)
  • Scalafmt 3.11.5 (scalafmtParallelism := 12): 1.81s (112.7 ksloc/s)

This means that Scruff would be 3 ~ 10x faster on CI, depending on how Scalafmt is configured.

Philosophy: lunch A or lunch B

scruff fmt offers minimal configurations:

# .scruff.toml

version = "0.3.3"
max-column = 100
dialect = "scala3"

exclude-paths = [
  "glob:**/src/sbt-test/**",
]
  • dialect = "scala3" rewrites to maximalist Scala 3, with significant spaces, fewer braces, and then control.
  • dialect = "scala213" or dialect = "scala213source3" rewrites to curly-braces.

There are no other knobs.

Format suppression

Scruff formatter supports // format: off and // format: on comments. For example, here's how we can suppress the formatter for the entire file:

// format: off

ThisBuild / version := "0.4.0-SNAPSHOT"

The Scruff style

Scruff aims for consistency, generality, readability, and reducing git diffs.

This document describes the current formatting style.

How Scruff wraps lines

Scruff ignores previous formatting, and applies uniform horizontal whitespace to your code based on max-column (default 100). Scruff uses 2 whitespaces for indentation, like most Scala styles.

Short expressions

Scruff tries to print one full expression or a statement per line. If this fits the allotted max-column, it will flatten the lines together.

// original input
object A:
  val a = List(1, 2)
  val b = List(
    1,
    2
  )
  val c =
    List(1, 2)

becomes:

// scruffed
object A:
  val a = List(1, 2)
  val b = List(1, 2)
  val c = List(1, 2)

Expressions that can fold in half

When a definition doesn't fit on one line, Scruff first tries folding it in half at the =:

def compileIncremental(setup: Setup, previous: PreviousResult): CompileResult = compileWith(setup, previous, inputs, reporter)

becomes

// scruffed
def compileIncremental(setup: Setup, previous: PreviousResult): CompileResult =
  compileWith(setup, previous, inputs, reporter)

Long expressions

If a function call still doesn't fit after folding, Scruff puts one argument per line, with a trailing comma:

val result = doSomething(compileInputs, logger, reporter, progress, cacheDirectory, extraOptions, previousResult)

becomes

// scruffed
val result = doSomething(
  compileInputs,
  logger,
  reporter,
  progress,
  cacheDirectory,
  extraOptions,
  previousResult,
)

Note

Scruff will add trailing commas to long function calls.

Named arguments

A function call with named arguments, or with a DSL containing = signs, puts one argument per line:

val conn = connect(host = "localhost", port = 8080)

lazy val action = project
  .settings(name := "action", Test / fork := true)

becomes

// scruffed
val conn = connect(
  host = "localhost",
  port = 8080,
)

lazy val action = project
  .settings(
    name := "action",
    Test / fork := true,
  )

Call chain (fluent interface)

A chain of two or more method calls and field selections stays on one line if it fits. Otherwise, Scruff breaks before each ., starting from the first call. Field selection, like xs.dependencies, stay with the receiver:

val names = xs.dependencies.filter(_.isTransitive).map(_.moduleName).filterNot(excluded).distinct.sorted.take(limit)

becomes

// scruffed
val names = xs.dependencies
  .filter(_.isTransitive)
  .map(_.moduleName)
  .filterNot(excluded)
  .distinct
  .sorted
  .take(limit)

Chained infix expression

A chain of infix expressions stays on one line if it fits. Otherwise, Scruff breaks before each && or || operator, so the operators start the lines:

val skip = isCi && isPullRequest || offline && cache.isEmpty || dryRun && warningCount + errorCount == 0

becomes

// scruffed
val skip = isCi && isPullRequest
  || offline && cache.isEmpty
  || dryRun && warningCount + errorCount == 0

Scala 3 dialect

Scala 3 is the default dialect of Scruff formatter:

dialect = "scala3"

Scala 3: Indentation syntax

"scala3" diealect replaces braces with indentation where Scala 3 allows it. This covers the bodies of classes, objects, and definitions:

object A {
  def foo(x: Int): String =
    x match {
      case 1 => "one"
      case _ => "many"
    }
}

becomes

// scruffed
object A:
  def foo(x: Int): String = x match
    case 1 => "one"
    case _ => "many"

Scala 3: Fewer braces (colon argument) syntax

"scala3" dialect replaces a braced lambda argument with a colon argument (SIP-44):

val sizes = files.map { file =>
  val bytes = Files.size(file)
  bytes / 1024
}

becomes

// scruffed
val sizes = files.map: file =>
  val bytes = Files.size(file)
  bytes / 1024

Scala 3: Control syntax

"scala3" dialect rewrites to Scala 3 control syntax:

val label = if (count > 1) "many" else "one"
val names = for (file <- files) yield file.getName

becomes

// scruffed
val label = if count > 1 then "many" else "one"
val names = for file <- files yield file.getName

Scala 3: Miscellaneous rewrites

  • * for wildcard imports, and as for renames in imports and exports
  • ? for type wildcards
  • xs* for vararg splices
  • using for implicit parameter clauses
  • & for compound types, and commas between parents after extends
  • End markers added to definitions spanning 20 or more lines, and removed from shorter ones

Scala 2 dialects

Scruff also supports the following Scala 2 dialects:

  • scala213source3, scala212source3, and sbt1 (Scala 2 with -Xsource:3): Scruff rewrites imports, type wildcards, and vararg splices to the Scala 3 syntax, and keeps braces, parentheses, implicit, and with.
  • scala213: Scruff rewrites Scala 3 syntax back to Scala 2 syntax, such as braces, parentheses, _ wildcard imports, => renames, and xs: _* splices.

In both, a Boolean chain broken across lines is wrapped in parentheses, since a Scala 2 line can't start with an operator:

// scruffed
val shouldPublish = (isRelease
  && !isSnapshot
  && credentials.nonEmpty
  && repository.isDefined
  && testsPassed)

Comments

Scruff does not format comment contents.

Scaladoc

Scruff will, however, unify the Scaladoc comments with gutter asterisks aligned in column two:

// scruffed
/** Compiles the sources.
 *
 *  @param sources the files to compile
 */
def compile(sources: Seq[File]): Unit = ()

format: off

Code in between // format: off and // format: on are not formatted:

// format: off

object AnythingGoes {}

// format: on

scruff check

Synopsis

scruff check [ options ] [ file | dir... | - ]

Scruff linter (check) is an experimental syntactic Scala linter. It checks the given files and directories against the lint rules, or the current directory when none are given. - reads standard input. Files are found as scruff fmt finds them, following the same include and exclude paths. Exits with 1 if a rule flags anything, or with --fix if anything is left, and with 2 if a file can't be checked because it can't be read or written or has syntax errors.

OptionEffect
--output-format howfull (default) shows each problem with the lines around it; concise prints one line each, as file:line:col: code rule message
--fixfixes what the rules can, overwriting each file, and reports what's left; on -, prints the fixed text and reports to stderr
--select rulesruns these rules in place of what [lint] selects, ignore included; names, codes or starts of codes, separated by commas
--ignore rulesturns these rules off, on top of --select or what [lint] selects; separated by commas
--color whenauto (default), always or never; auto colors on a terminal unless NO_COLOR is set

Micro benchmark

Using Scalafix 0.14.9 as a reference, Scruff linter runs about 10 to 25x faster, depending on the codebase. For example, here's wall-clock time to check for null and return, including startup, on a 12-core arm64 machine:

CodebaseScruff 0.2.2Scalafix 0.14.9
Cats (52k sloc)0.12 s (433k sloc/s)3.1 s (17k sloc/s)
scala/scala3 (207k sloc)0.37 s (555k sloc/s)6.2 s (34k sloc/s)

Here's wall-clock time to organize imports, also on a 12-core arm64 machine:

CodebaseScruff 0.2.2 --fixScalafix 0.14.9
Cats (52k sloc)0.12 s (433k sloc/s)2.7 s (19k sloc/s)
scala/scala3 (207k sloc)0.54 s (383k sloc/s)5.4 s (38k sloc/s)

Scalafix's time doesn't include compiling with SemanticDB first, which its OrganizeImports needs. Similar to Scalafmt, the majority of the difference might be due to parallelism.

Configuration

The set of enabled rules is controlled via the lint.select, lint.extend-select, and lint.ignore settings.

# .scruff.toml

version = "0.2.2"

[lint]
# default rules
# select = [
#   "untyped_implicit_val",
#   "no_return",
#   "no_null",
#   "no_xml",
#   "procedure_syntax",
# ]

Scruff currently selects untyped_implicit_val, no_return, no_null, no_xml, and procedure_syntax rules by default; see Rules.

To extend the default, use exend-select, and to opt out from defaults use ignore as follows:

# .scruff.toml

version = "0.2.2"

[lint]
extend-select = ["no_default_param"]

ignore = ["no_null"]

To try other rules without editing the configuration, --select on the command line replaces what [lint] selects, as in Ruff. It takes the same entries, separated by commas, and the configuration's ignore doesn't apply. --ignore then turns rules off, on top of --select or of the configuration:

$ scruff check --select no_var,S004
$ scruff check --select ALL --ignore S003,S006
$ scruff check --ignore no_null

Usage

The main usage pattern is to run the scruff CLI either from CI or during the build. See the Setup page.

Because it typically takes 1 ~ 2 second to run the GitHub Action, including the time it takes to download the CLI, we recommend running that ahead of the normal build process.

In the local environment, sbt-scruff provides scruff task, which can call scruff check:

sbt:vimquit-root> scruff check
[info] All checks passed!
[success] elapsed time: 0 s

Here's what an error looks like:

sbt:sbt-vimquit-root> scruff check
[info] S004 no_null `null` is not allowed; use an `Option`
[info]   --> plugin/src/main/scala/sbtvimquit/VimquitPlugin.scala:11:11
[info]    |
[info]  9 |   override def trigger = allRequirements
[info] 10 |
[info] 11 |   var x = null
[info]    |           ^^^^
[info] 12 |
[info] 13 |   lazy val quitParser: Parser[Unit] = (":q": Parser[String]) ^^^ (())
[info]    |
[info] Found 1 problem.
[error] scruff exited with 1
[error] (ThisBuild / scruff) scruff exited with 1
[error] elapsed time: 0 s

Fixes

Some rules can fix what they flag. Without --fix, the count says how many:

$ scruff check
...
Found 3 problems.
[*] 2 fixable with the `--fix` option.

--fix makes those edits, writes the files, and reports the problems left:

$ scruff check --fix
...
Found 3 problems (2 fixed, 1 remaining).

To opt out of rules for a line or a whole file, see Error suppression.

To migrate a build from sbt 0.13's key in Scope to the slash syntax, see S009 sbt_0_13_syntax.

Error suppression

Line-level

To suppress the lint error, write a comment next to or above the use site in the format of scruff: ignore(no_null):

object A:
  val a: String = null // scruff: ignore(no_null)
  val b: String = null // scruff: ignore(S004)
  var c: String = null // scruff: ignore(no_var, no_null)

  // scruff: ignore(ALL)
  var d: String = null

File-level

To suppress lint errors in the whole file, write scruff: file_ignore(...) with the rules to turn off, or scruff: noqa to turn off every rule, preferably towards the top of the file:

// scruff: file_ignore(no_null, S002)
package example

object A:
  var a: String = null
// scruff: noqa
package example.generated

Rules

The initial of a rule's code is its category: C correctness, S style.

CodeRuleFlagsDefaultFix
C001untyped_implicit_valimplicit val members without a type✓
S001no_returnreturn statements✓
S002no_varvar definitions, declarations and class parameters
S003no_default_paramdefault values of parameters
S004no_nullnull✓
S005no_whilewhile and do … while loops
S006no_asinstanceofcasts with asInstanceOf
S007no_xmlXML literals and patterns✓
S008procedure_syntaxdef f() { ... } and def f() without a result type✓✓
S009sbt_0_13_syntaxsbt 0.13's key in Scope✓
S010organize_importsimports out of order✓
S011finally_closetry { ... } finally x.close()✓

S008 procedure_syntax

On by default. Fixable with --fix.

$ scruff check --select procedure_syntax --fix

# or, alternatively
$ scruff check --select S008 --fix

Description

Flags Scala 2.x procedure syntax: def f() { ... } without =, and def f() without a result type.

Why is this bad?

The Scala 3 reference lists it under dropped features, in Dropped: Procedure Syntax: "Procedure syntax def f() { ... } has been dropped." Scala 3 accepts it only under -source:3.0-migration.

Fix

--fix writes the result type, : Unit, after the last parameter clause, with = before the body: def run() { ... } becomes def run(): Unit = { ... }, and def stop() becomes def stop(): Unit.

Example

object Main {
  def run() { println("running") }
  def stop()
}

scruff check reports both methods:

S008 procedure_syntax procedure syntax is dropped in Scala 3; write the result type, `: Unit =`
 --> Main.scala:2:7
  |
1 | object Main {
2 |   def run() { println("running") }
  |       ^^^
3 |   def stop()
4 | }
  |

Use instead:

object Main {
  def run(): Unit = { println("running") }
  def stop(): Unit
}

S009 sbt_0_13_syntax

Off by default. Fixable with --fix.

$ scruff check --select sbt_0_13_syntax --fix

# or, alternatively
$ scruff check --select S009 --fix

--select runs this rule, and only it, without changing the configuration. To keep it on, add it to extend-select under [lint].

Description

Flags sbt 0.13's scoping with in, as in scalacOptions in Compile or key.in(Compile). It points at in. Any .sbt, .scala or .sc file scruff finds is checked, project/*.scala included.

Why is this bad?

sbt 1.5 deprecated in for the slash syntax, as Migrating from sbt 0.13.x describes, and sbt 2 removes it, as Migrating from sbt 1.x describes.

Fix

--fix writes the slash syntax, with a tuple's parts in its order: key in (Compile, console) becomes Compile / console / key.

Where one in follows another, as in sources in Compile in core, syntax alone can't tell the project from the configuration. --fix rewrites the first in and reports the second as remaining, exiting with 1:

Compile / sources in core := Nil

Write core / Compile / sources there yourself.

Example: migrating from sbt 0.13 syntax

Given this build.sbt:

lazy val core = project
  .settings(
    scalacOptions in Compile += "-deprecation",
    scalacOptions in (Compile, console) := Nil,
    fork in Test := true,
  )

scruff check --select sbt_0_13_syntax --fix rewrites it to the slash syntax that sbt 2 requires:

lazy val core = project
  .settings(
    Compile / scalacOptions += "-deprecation",
    Compile / console / scalacOptions := Nil,
    Test / fork := true,
  )

and prints Found 3 problems (3 fixed, 0 remaining)., exiting with 0.

S010 organize_imports

Off by default. Fixable with --fix.

$ scruff check --select organize_imports --fix

# or, alternatively
$ scruff check --select S010 --fix

--select runs this rule, and only it, without changing the configuration. To keep it on, add it to extend-select under [lint].

Unsafe fix

--fix can change what the code means, or stop it from compiling. Scruff reads only syntax, so it can't tell which names a wildcard import such as import scala.concurrent.* brings in, and may move an import that uses one, such as import duration.*, above it. See Example: opting out for how to keep such an import below.

Description

Flags imports that aren't sorted. Imports up to the next statement sort together, blank lines or not.

Capitals sort after lowercase, and wildcards first: cats.*, cats.data.Ior, cats.Eq. An import of an imported name, as a.foo after import c.b.a, stays after it.

Fix

--fix sorts the lines and drops blank ones between imports; comments move with the import they're on or above.

Example

Given this Main.scala:

package example

import scala.util.Try
import scala.concurrent.duration.*
import cats.syntax.all.*
import cats.Eq

import example.internal.Config
import Config.{given, *}
import example.internal.Codec

object Main

scruff check --select organize_imports reports the first import out of place:

S010 organize_imports imports are not sorted alphabetically
 --> Main.scala:3:1
  |
1 | package example
2 |
3 | import scala.util.Try
  | ^^^^^^^^^^^^^^^^^^^^^
4 | import scala.concurrent.duration.*
5 | import cats.syntax.all.*
  |

Found 1 problem.
[*] 1 fixable with the `--fix` option.

scruff check --select organize_imports --fix sorts the imports as one block, keeping Config.{given, *} after the import of Config:

package example

import cats.syntax.all.*
import cats.Eq
import example.internal.Codec
import example.internal.Config
import scala.concurrent.duration.*
import scala.util.Try
import Config.{given, *}

object Main

and prints Found 1 problem (1 fixed, 0 remaining)., exiting with 0.

Example: opting out

Names a wildcard brings in aren't known, so an import using one sorts as any other. Here duration comes from scala.concurrent.*, but --fix would move import duration.* above it, which doesn't compile:

package example

import scala.concurrent.*
import duration.*
import cats.Eq

object Main:
  val timeout = 5.seconds

// scruff: ignore(organize_imports) at the end of the import's line, or on the line above, puts that import after the rest, since what it uses may come from any import above it. --fix then sorts the others:

package example

import cats.Eq
import scala.concurrent.*
import duration.* // scruff: ignore(organize_imports)

object Main:
  val timeout = 5.seconds

To keep a file's imports as written, turn the rule off for the file with // scruff: file_ignore(organize_imports). Writing the import in full, import scala.concurrent.duration.*, works too. See Error suppression.

S011 finally_close

Off by default. Fixable with --fix.

$ scruff check --select finally_close --fix

# or, alternatively
$ scruff check --select S011 --fix

--select runs this rule, and only it, without changing the configuration. To keep it on, add it to extend-select under [lint].

Note

Credit to Kenji Yoshida (xuwei-k)'s ScalaUtilUsingResource rule.

Description

Flags a try without catch whose finally only closes a resource, as in try { ... } finally a.close(). It points at try.

Why is this bad?

scala.util.Using's resource function can express this.

Fix

--fix rewrites val a = ... and the try right after it to Using.resource(...): a =>, with braces outside Scala 3, and imports scala.util.Using.

Example

Given this A.scala, in the scala3 dialect:

package example

import java.io.FileInputStream

object A:
  def f1: Int =
    val a = new FileInputStream("a")
    try
      val b = a.read()
      val c = a.read()
      b + c
    finally a.close()

scruff check --select finally_close --fix rewrites it to:

package example

import java.io.FileInputStream
import scala.util.Using

object A:
  def f1: Int =
    Using.resource(new FileInputStream("a")): a =>
      val b = a.read()
      val c = a.read()
      b + c

and prints Found 1 problem (1 fixed, 0 remaining)., exiting with 0.