A compiler plugin is code that the Scala compiler loads and runs as one more step in its own pipeline. It sees every file in the compilation, after names and types have been resolved, and it can report errors or change the program before bytecode is written. That is more power than a macro, which only sees its own call site, and more than a source linter, which runs separately from the build. It is also the least stable extension point in the Scala toolchain, because it programs against compiler internals.

This article explains the phase pipeline and where a plugin phase belongs, builds one real plugin for Scala 3 and Scala 2, packages and tests it, and ends with when to use a macro or Scalafix rule instead. The running example is a check that rejects Await.result in production code, a common source of thread starvation in services built on Futures.

Advertisement

What a plugin is, and what it is not

The compiler reads source text, builds syntax trees, resolves every name to a symbol, assigns every expression a type, and then lowers the tree step by step until it can emit JVM bytecode (or JavaScript, or native IR). Each step is a phase. A plugin contributes one or more extra phases, each told which built-in phases it must run after and before. Inside its phase the plugin walks the trees for each compilation unit and can do three things: read them, report diagnostics through the compiler's reporter, and return modified trees.

Three neighbouring tools are often confused with plugins. A macro is user code the compiler runs when it type-checks a specific call; it can only produce the expansion of that call. A Scalafix rule runs after compilation, reads SemanticDB files, and edits source text; it never changes what the compiler produces. A Java annotation processor runs in javac, not scalac. A plugin is the only one of the four that sees the whole typed program inside the compiler and can alter the emitted code for code that never mentions it.

Well-known examples: WartRemover (lint checks), better-monadic-for (changes for-comprehension desugaring in Scala 2) and kind-projector (type-lambda syntax for Scala 2, which Scala 3 builds in behind -Ykind-projector).

The compiler as a pipeline of phases

Both compilers have a fixed phase list, and you can print it: scalac -Xshow-phases works on Scala 3, and Scala 2.13 prints the same list with -Vphases. The phases that matter for plugin authors are few. parser produces untyped trees. typer resolves names, infers types and inserts implicit or given arguments; after it, every tree has a symbol and a type. In Scala 3, pickler serialises the typed trees into TASTy, the format that downstream compilers, IDEs and tools read instead of re-parsing your source. Later phases lower the program: inlining, pattern-match translation, erasure of generic types, and finally code generation.

Scala 3 organises most of its lowering phases as mini-phases that are fused into groups, so that one traversal of the tree runs many small transformations at once. A Scala 3 plugin phase extends PluginPhase, which is itself a mini-phase: you override hooks such as transformApply or transformDefDef, and the compiler calls them for each matching node during the fused traversal. Scala 2 plugins instead receive the whole compilation unit and walk it themselves with a Traverser or Transformer.

A compiler run is a fixed list of phases; a plugin adds one phase at a chosen pointparsertext to treestypernames + typespicklerwrites TASTylater phasesinlining ... erasurebackendJVM bytecodeyour phasecheck or rewriterunsAfter = typer, runsBefore = picklerLinter phasereads typed trees, reports errorstree returned unchangedRewrite before picklerchanged trees go into TASTydownstream compilers see the changeRewrite after picklerTASTy keeps the originalbytecode and TASTy disagreeInputs every phase can seeall compilation units of this run, symbols, types, positions, the reporterOnly the files Zinc chose to recompile are in the run: a cross-file check sees a partial picture.
Where a plugin phase sits in the pipeline, and the three placements that change what downstream tools see.
Advertisement

Choosing where your phase runs

Placement is the most consequential design decision, and the rule of thumb is simple: run as early as the information you need allows. A lint check needs symbols and types, so it runs after typer. Before that, Await.result is only a name; after typer it is a reference to one specific method, and you can tell it apart from a user method with the same name.

For rewrites in Scala 3, the pickler is the dividing line. A change made before pickler is written into TASTy, so every downstream consumer, including projects that depend on your library and inline your methods, sees the rewritten program. A change made after pickler only affects this compilation's bytecode, while the TASTy still describes the original code; tools that read TASTy and the code that actually runs now disagree. The documented example in the Scala 3 reference, a divide-by-zero check, declares runsAfter = Set(Pickler.name) and runsBefore = Set(Staging.name), which is fine for a check that changes nothing.

Late phases see a lowered program: after erasure, List[Int] and List[String] are the same type. Source-level checks belong early; instrumentation that must see the final shape can go later.

A complete Scala 3 plugin

Our plugin, noAwait, reports an error for every call to scala.concurrent.Await.result or Await.ready, except inside packages the build lists as allowed. It is a pure check: every hook returns the tree it was given. The plugin class exposes a name and creates its phase in initialize, which receives the plugin options. Earlier Scala 3 releases named this method init; use the name your compiler version defines.

package noawait

import dotty.tools.dotc.ast.tpd
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.core.Symbols.Symbol
import dotty.tools.dotc.plugins.{PluginPhase, StandardPlugin}
import dotty.tools.dotc.report


class NoAwait extends StandardPlugin:
  val name: String = "noAwait"
  override val description: String = "forbid Await.result/ready outside allowed packages"

  override def initialize(options: List[String])(using Context): List[PluginPhase] =
    // options arrive from -P:noAwait:allow=com.acme.tests
    val allowed = options.collect { case s"allow=$pkg" => pkg }
    NoAwaitPhase(allowed) :: Nil

class NoAwaitPhase(allowed: List[String]) extends PluginPhase:
  import tpd.*

  val phaseName = "noAwait"
  override val runsAfter = Set("typer")
  override val runsBefore = Set("pickler")

  private val banned = Set("result", "ready")

  private def isAwait(sym: Symbol)(using Context): Boolean =
    banned.contains(sym.name.toString) &&
      sym.owner.fullName.toString.stripSuffix("$") == "scala.concurrent.Await"

  private def isAllowed(using ctx: Context): Boolean =
    val pkg = ctx.owner.enclosingPackageClass.fullName.toString
    allowed.exists(a => pkg == a || pkg.startsWith(a + "."))

  override def transformApply(tree: Apply)(using Context): Tree =
    if isAwait(tree.fun.symbol) && !isAllowed then
      report.error(
        "Await blocks a thread; compose the Future instead (map, flatMap, for)",
        tree.srcPos)
    tree

tree.fun.symbol works with or without explicit type arguments, because a type application reports the symbol of the function it applies. Stripping the $ suffix avoids depending on how the compiler names the module class of object Await. The package check relies on ctx.owner being the definition under traversal; cover it with a test. If an internal API has moved in your compiler version, the build tells you, which is the normal cost of plugin maintenance.

The jar must contain a plugin.properties file at its root naming the plugin class:

# src/main/resources/plugin.properties
pluginClass=noawait.NoAwait

The same plugin for Scala 2

Scala 2 plugins receive the compiler instance, Global, and declare phases as components that walk each unit's tree themselves. The option hook, init, returns false to abort on bad options.

package noawait

import scala.tools.nsc.{Global, Phase}
import scala.tools.nsc.plugins.{Plugin, PluginComponent}

class NoAwait(val global: Global) extends Plugin {
  import global._

  val name = "noAwait"
  val description = "forbid Await.result/ready outside allowed packages"
  val components: List[PluginComponent] = List(Component)

  private var allowed: List[String] = Nil

  override def init(options: List[String], error: String => Unit): Boolean = {
    allowed = options.collect { case o if o.startsWith("allow=") => o.drop(6) }
    true
  }

  private object Component extends PluginComponent {
    val global: NoAwait.this.global.type = NoAwait.this.global
    val runsAfter = List("typer")
    val phaseName = "noAwait"

    def newPhase(prev: Phase): Phase = new StdPhase(prev) {
      def apply(unit: CompilationUnit): Unit = {
        val pkg = unit.body match {
          case PackageDef(pid, _) => pid.toString
          case _                  => ""
        }
        if (!allowed.exists(a => pkg == a || pkg.startsWith(a + ".")))
          checker.traverse(unit.body)
      }
    }

    private object checker extends Traverser {
      override def traverse(tree: Tree): Unit = {
        tree match {
          case Apply(fun, _) if fun.symbol != null &&
              (fun.symbol.name.toString == "result" || fun.symbol.name.toString == "ready") &&
              fun.symbol.owner.fullName.stripSuffix("$") == "scala.concurrent.Await" =>
            reporter.error(tree.pos, "Await blocks a thread; compose the Future instead")
          case _ =>
        }
        super.traverse(tree)
      }
    }
  }
}

The Scala 2 descriptor is XML rather than a properties file:

<!-- src/main/resources/scalac-plugin.xml -->
<plugin>
  <name>noAwait</name>
  <classname>noawait.NoAwait</classname>
</plugin>

The Scala 2 version checks the package once per file, which is adequate when packages map to directories. Scala 3 has no equivalent of Scala 2's analyzer plugins, which could hook into type checking itself. If a Scala 2 plugin changes how code type-checks, it cannot be ported as a plugin; the feature has to become a macro, a library, or a language change.

Packaging and wiring it into a build

Plugins depend on the compiler, and the compiler's internal API is not binary-compatible between releases, even patch releases. Publish the plugin for each full Scala version with CrossVersion.full, and depend on the compiler as Provided so it is not dragged onto anyone's classpath.

// build.sbt of the plugin project
lazy val noAwait = project
  .settings(
    name := "noawait",
    crossVersion := CrossVersion.full,
    libraryDependencies += (
      if (scalaVersion.value.startsWith("3."))
        "org.scala-lang" %% "scala3-compiler" % scalaVersion.value % Provided
      else
        "org.scala-lang" % "scala-compiler" % scalaVersion.value % Provided
    )
  )

// build.sbt of a consuming project (published plugin)
addCompilerPlugin("com.acme" % "noawait" % "<version>" cross CrossVersion.full)
scalacOptions += "-P:noAwait:allow=com.acme.tests"

// or, inside the same multi-project build, without publishing
lazy val service = project
  .settings(
    scalacOptions += s"-Xplugin:${(noAwait / Compile / packageBin).value.getAbsolutePath}"
  )

On the command line the equivalent is scalac -Xplugin:noawait.jar -P:noAwait:allow=com.acme.tests Main.scala. Enable it only where needed: a ban on Await usually applies to Compile, not Test.

Testing a plugin

Compile small snippets with the plugin loaded and assert on the diagnostics. The sketch uses the Scala 3 driver, whose process method returns the reporter in current releases; check the signature for your version. Keep positive cases too: a check that fires on everything passes every negative test.

import dotty.tools.dotc.Main
import java.nio.file.{Files, Path}

def compile(src: String, pluginJar: Path): Int =
  val dir = Files.createTempDirectory("noawait")
  val file = dir.resolve("T.scala")
  Files.writeString(file, src)
  val reporter = Main.process(Array(
    "-usejavacp", s"-Xplugin:$pluginJar", "-d", dir.toString, file.toString))
  reporter.errorCount

test("flags Await.result in main code") {
  val src = "package app\nimport scala.concurrent.*, duration.*\n" +
            "object A { def f(x: Future[Int]) = Await.result(x, 1.second) }"
  assert(compile(src, jar) == 1)
}

test("allows the allow-listed package") { /* same source under package com.acme.tests */ }
test("ignores a user method named result") { /* object Await in another package */ }

Worked example: removing blocking calls from a service

Consider a payments service of about 900 source files built on Futures (the figures here are assumptions for illustration). Under load it stalls because request handlers call Await.result on the same thread pool that should complete the awaited Futures. Code review has not stopped new occurrences, so the team adds the plugin.

Week one: an added plugin option switches it to report.warning, and CI counts 64 call sites. Week two: sites in startup code and command-line tools move into an allow-listed package or are rewritten; handlers are rewritten to compose Futures. Week three: the option flips to error mode, so any new Await.result in handler code fails compilation with a message saying what to do instead. No runtime artefact changes, which is why a pure check is the safest first plugin to own.

Incremental compilation and other traps

  • Partial views under Zinc. sbt recompiles only changed files and their dependents. A per-call check is fine, but a cross-file check, such as unique event names, sees only the recompiled subset and misses conflicts. Cross-file invariants belong in a separate full-build step or in a Scalafix rule.
  • Tree rewrites that break separate compilation. If a rewrite adds members or changes signatures, Zinc's API extraction and downstream TASTy readers may disagree with the bytecode. Keep rewrites local to method bodies unless you fully understand the pickler placement.
  • Version lock-in. Every compiler upgrade needs a new plugin release, and a missing one blocks the whole organisation's upgrade.
  • Research plugins. Scala 3 also has ResearchPlugin, which can rearrange the whole phase plan, but it is only enabled in nightly and snapshot compilers. Do not build production tooling on it.

Plugin, macro or Scalafix: choosing the tool

NeedBest toolWhy
Generate code where the user asks for itMacro or inlineLocal, explicit, uses the public metaprogramming API
Ban or flag a pattern across the codebasePlugin, or Scalafix lint rulePlugin if it must fail the build on every compile; Scalafix if a CI step is enough
Automatically fix source codeScalafixEdits text and leaves a reviewable diff
Change semantics of code that does not mention youPluginOnly a phase can rewrite arbitrary trees
Cross-file invariantsScalafix or a build stepSees the whole program, unaffected by incremental compilation

Maintenance cost runs the other way: macros and Scalafix use documented APIs, plugins use compiler internals. Write a plugin only when a check must be impossible to skip or code must be transformed globally.

What to do next

  1. Run scalac -Xshow-phases (or -Vphases on 2.13) for your compiler and note where typer and pickler sit.
  2. Write down whether your idea is a check or a rewrite, and whether it needs cross-file information; if it does, prefer Scalafix.
  3. Start from the noAwait skeleton: one PluginPhase after typer that only reports.
  4. Add snippet tests with positive, negative and near-miss cases, run for every Scala version you publish.
  5. Publish with CrossVersion.full, depend on the compiler as Provided, and ship warning mode before error mode.
  6. Add a release step to your compiler-upgrade checklist so the plugin never blocks an upgrade.
  7. Keep learning: Scala 3 macros, compile-time derivation in practice, Scalafix and SemanticDB and how sbt and Zinc compile incrementally.
Key takeaway: A compiler plugin adds a phase to the compiler pipeline and sees every typed tree in the run, which makes it the right tool for checks that must never be skipped and for global rewrites. Place the phase as early as its information allows: after typer for checks, and before the pickler for any rewrite that downstream TASTy readers must see. Scala 3 plugins are PluginPhase mini-phases described by plugin.properties, while Scala 2 plugins are components described by scalac-plugin.xml. Publish them per full compiler version, test them with positive and negative snippets, remember that Zinc hides the rest of the program, and use a macro or Scalafix rule whenever one of them can do the job.