Scalafix is a refactoring and linting tool for Scala from the Scala Center. It reads your code as a tree instead of as text, applies rules, and either rewrites files or reports diagnostics. That one engine covers three jobs teams usually solve separately: keeping imports tidy and dead code gone, enforcing house rules such as no nulls or no Option.get, and running one-off migrations across a large codebase when a library or language version changes.
The part that trips people up is that scalafix has two kinds of rule with very different requirements. Syntactic rules need only the parser and run in milliseconds. Semantic rules need to know what every name refers to, so they depend on compiler output called SemanticDB, and they are only as correct as that output is fresh. This article explains the model, sets scalafix up in sbt and CI, and writes, tests and ships a custom semantic rule.
The mental model: trees, symbols and patches
Scalafix is built on scalameta, a library that parses Scala 2 and Scala 3 source into trees that keep every token, including whitespace and comments. Because nothing is lost, a rule can replace one node and leave the rest of the file byte-for-byte identical. A regex cannot tell a method call from a string literal or comment; a tree walker can.
A rule does not edit files directly. It returns a Patch, a value describing edits (replace this tree, remove these tokens, add this import) and lint diagnostics. Scalafix merges the patches from all rules, applies them, and writes the result or, in check mode, reports what would change. Because patches are values, rules compose, and a rule that finds nothing returns Patch.empty.
The name get in x.get could be Option#get, Map#get or a method on your own class. Syntax alone cannot tell them apart. The compiler can, and with the SemanticDB compiler plugin enabled it writes a .semanticdb file per source file recording which symbol each name resolves to, inferred types and compiler diagnostics such as unused warnings. Semantic rules read those files.
Syntactic versus semantic rules
| Syntactic rule | Semantic rule | |
|---|---|---|
| Input | source text parsed by scalameta | source plus matching .semanticdb files |
| Needs compilation | no | yes, with SemanticDB enabled |
| Knows what a name refers to | no, only its spelling and shape | yes, fully qualified symbols and types |
| Speed | fast, can run on uncompilable code | bounded by compile time |
| Built-in examples | DisableSyntax, ProcedureSyntax, RedundantSyntax, NoValInForComprehension, LeakingImplicitClassVal | RemoveUnused, OrganizeImports (removeUnused), ExplicitResultTypes, NoAutoTupling |
| Typical use | style bans, deprecated syntax | dead code, API migrations, type-aware bans |
Pick the weakest kind that is correct: spotting a var is syntactic; knowing that a call targets a specific library method is semantic and costs a compile.
Setting it up in sbt
The sbt plugin adds the tasks and wires SemanticDB. Enable SemanticDB for the build, pin its version to the one scalafix expects, and turn on the compiler's unused warnings, because RemoveUnused and OrganizeImports' unused-import removal act on those diagnostics rather than working out usage themselves.
// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.9")
// build.sbt
ThisBuild / semanticdbEnabled := true // emit SemanticDB
ThisBuild / semanticdbVersion := scalafixSemanticdb.revision // version scalafix expects
ThisBuild / scalafixDependencies +=
"org.typelevel" %% "typelevel-scalafix" % "<version>" // third-party rules
lazy val core = project.settings(
scalacOptions ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, 12)) => Seq("-Ywarn-unused")
case Some((2, 13)) => Seq("-Wunused")
case _ => Seq("-Wunused:all") // Scala 3.3.4+
}
}
)On Scala 3, unused-import removal needs 3.3.4 or later because earlier compilers did not export unused diagnostics to SemanticDB, and removing unused parameters needs 3.7.0 or later. If your build uses -Xfatal-warnings, the unused warnings will fail compilation before scalafix can fix them; either demote them with -Wconf:cat=unused:info on versions that support it or drop fatal warnings for the scalafix run. Mill runs scalafix through a plugin with the same SemanticDB requirement; see the Mill build tool and, for how sbt settings and scopes compose, sbt in depth.
If you only want to try scalafix without committing build changes, scalafixEnable turns SemanticDB on for the current sbt shell session.
The built-in rules worth enabling
- OrganizeImports: sorts, groups and de-duplicates imports, and with
removeUnused = trueremoves unused ones. Defaults group by"*"thenjava/javax/scala, explode grouped imports into one per line and sort by ASCII. It is the rule with the biggest effect on review noise. - RemoveUnused: removes unused imports, private members and locals, and replaces unused pattern variables and parameters with
_. All five switches default to on. If you run OrganizeImports, turn off itsimportsswitch so the two do not both edit import lines. - DisableSyntax: a configurable ban list for constructs such as
var,null,return,throw, XML literals andasInstanceOf. It lints rather than rewrites. - ExplicitResultTypes: inserts inferred result types on public members, which stabilises public APIs and speeds incremental compilation. Rules for which members it touches are configurable; check its Scala 3 support against the version you run.
- ProcedureSyntax, NoValInForComprehension, RedundantSyntax, NoAutoTupling, LeakingImplicitClassVal: small mechanical clean-ups, mostly useful when modernising Scala 2 code before a move to Scala 3. For the language side of that move, see Scala 3 in depth.
Configuration
Rules and their settings live in .scalafix.conf, a HOCON file at the repository root. Rules listed there run when you invoke scalafix with no arguments; naming a rule on the command line runs only that one. Keep the file in version control so every developer and CI run the same rule set.
# .scalafix.conf at the repository root (HOCON)
rules = [
OrganizeImports
RemoveUnused
DisableSyntax
ProcedureSyntax
RedundantSyntax
NoOptionGet # the custom rule written later in this article
]
OrganizeImports {
groups = ["re:javax?\\.", "scala.", "*", "com.example."]
removeUnused = true # needs Scala 2 or Scala 3.3.4+
}
RemoveUnused {
imports = false # OrganizeImports already handles imports
}
DisableSyntax {
noVars = true
noNulls = true
noReturns = true
noXml = true
}Order the import groups to match what your formatter and IDE expect, or the three will fight. Scalafmt formats but does not reorder imports semantically, and scalafix does not format, so run scalafix first and scalafmt second: rewrites can leave awkward spacing that the formatter then normalises.
Running it: locally and in CI
# Developer loop: rewrite in place, main + test sources
sbt "scalafixAll"
# One rule, one module, main sources only
sbt "core/scalafix OrganizeImports"
# CI: fail if any rule would change a file or reports a lint error
sbt "scalafixAll --check"
# Run a rule published as a library without adding it to the build
sbt "scalafixAll dependency:SomeRule@com.example::example-rules:1.2.3"
# Standalone CLI (Coursier)
cs install scalafix
scalafix --rules ProcedureSyntax src/main/scalascalafix covers one configuration (main sources by default); scalafixAll covers all configurations, including tests. --check is the CI mode: it rewrites nothing and exits non-zero if any file would change or any lint error is reported, printing a diff for the rewrites. Put it in the same CI job as compilation so SemanticDB is already fresh.
Add rules to a mature codebase in two steps: run the rewrite once and commit the mechanical diff on its own (listed in .git-blame-ignore-revs), then enable --check so the code cannot drift back. For lint rules with many existing findings, suppress or fix them module by module first, or the first CI run is a wall of red nobody owns.
Third-party rules
Rules are ordinary JVM libraries. scalafixDependencies adds them to the scalafix classpath without touching your application's dependencies, and the rule names become available in .scalafix.conf. Library authors publish migration rules alongside breaking releases, and ecosystem collections add lints such as type-aware bans. The dependency: prefix on the command line runs a published rule once, which suits one-off migrations you never want in the permanent rule set.
Treat third-party rules like code with write access to your repository: pin versions and run them on a branch first.
Writing a custom rule: ban Option.get
A worked example makes the Patch API concrete. The goal is to fail CI whenever code calls Option.get, which throws on None, without flagging Map.get, which returns an Option and is fine. The text .get is identical in both, so this must be a semantic rule that checks the resolved symbol.
// rules/src/main/scala/fix/NoOptionGet.scala
package fix
import scalafix.v1._
import scala.meta._
final case class OptionGetUsed(t: Term) extends Diagnostic {
override def position = t.pos
override def message =
"Option.get throws on None; use getOrElse, fold or a pattern match"
}
class NoOptionGet extends SemanticRule("NoOptionGet") {
// SemanticDB symbol for scala.Option#get, not the text ".get"
private val optionGet =
SymbolMatcher.normalized("scala/Option#get().", "scala/Some#get().")
override def fix(implicit doc: SemanticDocument): Patch =
doc.tree.collect {
case sel @ Term.Select(_, name @ Term.Name("get")) if optionGet.matches(name.symbol) =>
Patch.lint(OptionGetUsed(sel))
}.asPatch
}
// rules/src/main/resources/META-INF/services/scalafix.v1.Rule
// fix.NoOptionGetThe rule walks the tree, finds every selection of a name get, and asks SemanticDB whether that name resolves to scala/Option#get()., the SemanticDB spelling of the method. Matches produce lint diagnostics, which scalafix reports as errors by default. SymbolMatcher.normalized matches the symbol regardless of overload, but it does not match subtypes: on Scala 2.13 Some overrides get, so add scala/Some#get(). to the matcher too. The service file under META-INF/services lets scalafix discover the rule by name.
Keep such a rule in its own sbt subproject that depends on scalafix-core, and point the build at it: sbt-scalafix supports local rules through a rules project added with dependsOn(rules % ScalafixConfig). For a company-wide rule, publish the subproject as a library and consume it through scalafixDependencies in every repository.
Rewrites use the same shape. A syntactic rule can replace a deprecated helper with its successor:
// A syntactic rewrite: replace a deprecated helper call with its successor.
class RenameLegacyClient extends SyntacticRule("RenameLegacyClient") {
override def fix(implicit doc: SyntacticDocument): Patch =
doc.tree.collect {
case t @ Term.Apply(Term.Name("legacyClient"), args) =>
Patch.replaceTree(t, s"HttpClient.default(${args.mkString(", ")})")
}.asPatch
}Other patch builders add and remove imports (Patch.addGlobalImport, Patch.removeImportee) and tokens, and patches combine with +. If the rewrite must depend on types, make it a SemanticRule.
Testing rules with scalafix-testkit
Rules deserve tests because a buggy rewrite runs across every file in the codebase. The testkit convention uses three source sets: input files carry a comment header naming the rule and, for lints, // assert: markers on lines that must produce a diagnostic; output files hold the expected rewritten source; and a tests project runs each input through the rule and diffs the result.
// input/src/main/scala/fix/NoOptionGetTest.scala
/*
rule = NoOptionGet
*/
package fix
object NoOptionGetTest {
val port: Option[Int] = sys.env.get("PORT").map(_.toInt)
val p = port.get // assert: NoOptionGet
val m = Map("a" -> 1).get("a") // Map#get returns Option: no finding
}The Map#get line is the important one: a test that only proves the rule fires does not prove it stays quiet on the look-alike. Add cases for method references, infix calls and code inside string interpolation, which is where tree-based rules most often surprise their authors. For general testing patterns in Scala, see testing Scala code.
Suppressing findings
// The pool guarantees Some here
val conn = pool.borrow().get // scalafix:ok NoOptionGet
// scalafix:off DisableSyntax.noNulls
val jni: Pointer = null // interop with a C library
// scalafix:on DisableSyntax.noNulls// scalafix:ok suppresses one line for a named rule; scalafix:off and scalafix:on bracket a region. Always name the rule so the suppression does not silently cover rules added later, and count suppressions over time: a rule with hundreds of them is the wrong rule for your codebase.
Failure modes
| Failure | Symptom | Fix |
|---|---|---|
| Stale SemanticDB | semantic rule edits the wrong span or reports 'stale semanticdb' for a file | recompile before running; never edit files between compile and scalafix |
| Code does not compile | semantic rules skip files or fail | run syntactic rules first, fix compile errors, then semantic rules |
| -Xfatal-warnings on | unused warnings stop the compile, so RemoveUnused never runs | demote unused warnings with -Wconf or relax fatal warnings for the run |
| SemanticDB version mismatch | rules see no symbols or crash | set semanticdbVersion := scalafixSemanticdb.revision |
| Rules and formatter disagree | every run churns import order or spacing | align OrganizeImports groups with scalafmt and IDE settings; run scalafix before scalafmt |
| Two rules edit the same lines | conflicting patch errors or odd output | disable overlapping switches (RemoveUnused.imports with OrganizeImports) |
| Rule assumes Scala 2 trees | no findings on Scala 3 code, or parse errors | check the rule's Scala 3 support; test with cross-built inputs |
Trade-offs
Scalafix adds a SemanticDB compile, a configuration file and occasional rule conflicts to the build. In return you get tree-accurate rewrites and lints a formatter cannot express. Compiler-plugin linters such as WartRemover run inside compilation with no separate step but only report; scalafix can also rewrite and run one-off migrations. A common split: compiler warnings for the basics, scalafix for imports, bans and migrations, scalafmt for layout. Implicit and given resolution is exactly the kind of information SemanticDB exposes to rules; implicit resolution in depth explains what the compiler records.
What to do next
- Add sbt-scalafix, enable SemanticDB with
scalafixSemanticdb.revisionand turn on the unused warnings for your Scala version. - Create
.scalafix.confwith OrganizeImports and RemoveUnused (imports off), runscalafixAll, and commit the result as one mechanical commit listed in.git-blame-ignore-revs. - Add
scalafixAll --checkto CI in the job that already compiles. - Enable DisableSyntax with the two or three bans your team actually agrees on; suppress legacy findings by module, then fix them.
- Write one custom semantic rule for a bug your team has shipped more than once, with testkit inputs that include a look-alike that must not fire.
- Before your next library upgrade, check whether the library ships a scalafix migration rule and run it with
dependency:on a branch.