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.
| Option | Effect |
|---|---|
--check | Write nothing; show how each file would change, and exit with 1 if any would |
--diff | Write nothing; print a unified diff of each change, and exit with 1 if any would |
-q, --quiet | Print only the diffs: no file count, no warnings, errors only with --check |
-s, --silent | Print nothing but the formatted text or the diff; errors show only in the exit code |
--color when | auto (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, andthencontrol.dialect = "scala213"ordialect = "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,
)
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, andasfor renames in imports and exports?for type wildcardsxs*for vararg splicesusingforimplicitparameter clauses&for compound types, and commas between parents afterextends- 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, andsbt1(Scala 2 with-Xsource:3): Scruff rewrites imports, type wildcards, and vararg splices to the Scala 3 syntax, and keeps braces, parentheses,implicit, andwith.scala213: Scruff rewrites Scala 3 syntax back to Scala 2 syntax, such as braces, parentheses,_wildcard imports,=>renames, andxs: _*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.
| Option | Effect |
|---|---|
--output-format how | full (default) shows each problem with the lines around it; concise prints one line each, as file:line:col: code rule message |
--fix | fixes what the rules can, overwriting each file, and reports what's left; on -, prints the fixed text and reports to stderr |
--select rules | runs these rules in place of what [lint] selects, ignore included; names, codes or starts of codes, separated by commas |
--ignore rules | turns these rules off, on top of --select or what [lint] selects; separated by commas |
--color when | auto (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:
| Codebase | Scruff 0.2.2 | Scalafix 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:
| Codebase | Scruff 0.2.2 --fix | Scalafix 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.
| Code | Rule | Flags | Default | Fix |
|---|---|---|---|---|
| C001 | untyped_implicit_val | implicit val members without a type | ✓ | |
| S001 | no_return | return statements | ✓ | |
| S002 | no_var | var definitions, declarations and class parameters | ||
| S003 | no_default_param | default values of parameters | ||
| S004 | no_null | null | ✓ | |
| S005 | no_while | while and do … while loops | ||
| S006 | no_asinstanceof | casts with asInstanceOf | ||
| S007 | no_xml | XML literals and patterns | ✓ | |
| S008 | procedure_syntax | def f() { ... } and def f() without a result type | ✓ | ✓ |
| S009 | sbt_0_13_syntax | sbt 0.13's key in Scope | ✓ | |
| S010 | organize_imports | imports out of order | ✓ | |
| S011 | finally_close | try { ... } 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].
--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].
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.