Skip to Content
Course content

201: Compiler Plugins in Scala

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a recurring pattern when developers first dive into the "dark arts" of the Scala compiler: they assume that a compiler plugin is just a macro that you've told to run on every file in the project. It's a logical leap, but it's fundamentally wrong. If you treat a plugin like a global macro, you're going to hit a wall the moment you need to perform a transformation that depends on information the macro system simply can't see.

Plugins aren't just "Global Macros"

Here is the concrete difference. A macro is triggered by a specific call site—an annotation or a method call. The compiler says, "Oh, I see a macro here, let me expand this specific piece of code." It's local and surgical. A compiler plugin, however, is a hook into the scalac pipeline itself. It doesn't wait to be called; it owns a Phase.

Imagine you want to enforce a rule that every var in your project must be prefixed with the word mutable (e.g., mutableUserAge). If you tried to do this with macros, you'd have to annotate every single class in your codebase with something like @EnforceNaming. That's tedious and defeats the purpose. A compiler plugin doesn't care about annotations. It can simply walk the entire Abstract Syntax Tree (AST) of every single file during the typer phase and throw a compile-time error if it finds a var that doesn't follow your rule. I've used this approach in large-scale migrations to force teams to adhere to new patterns without having to manually review a thousand pull requests.

Hooking into the Compiler Phase Pipeline

To write a plugin, you aren't writing a function; you're defining a CompilerPlugin trait. The heart of the plugin is the Phase. The Scala compiler is essentially a series of transformations: it takes source code, turns it into an AST, types it, optimizes it, and finally generates bytecode. Each of these steps is a phase.

When you define your own phase, you decide where it fits. Do you want to run before the typer phase (working with "raw" untyped trees) or after it? Most of the time, you'll want to run after typer because you'll want to know the actual types of the expressions you're modifying. If you try to check if a variable is a String before the typer phase has run, you're just guessing.

import scala.compiler.plugins.*
import scala.compiler.scala.nodes.*

class NamingEnforcerPlugin extends Plugin {
  override def name: String = "MutableNamingEnforcer"

  override def install(settings: Settings): PluginComponents = {
    new PluginComponents(settings) {
      override def phaseNames: Seq[String] = Seq("namingEnforcer")

      override def execute(args: Seq[String]): Unit = {
        // This is where the magic happens. 
        // We add a new phase to the compiler's pipeline.
        addPhase(new Phase {
          override def name: String = "namingEnforcer"
          override def run(tree: Tree): Tree = {
            tree.transform {
              case v: ValDef if v.isVar && !v.name.startsWith("mutable") =>
                reporter.report(v.pos, "All vars must start with 'mutable'!")
                v
              case other => other
            }
          }
        })
      }
    }
  }
}

I'll be honest with you: the Tree API is dense. You'll spend a lot of time digging through the Scala source code to figure out if you're looking at a ValDef, a Select, or an Apply. But once you get the hang of the transform method, you realize you have total control over the program's structure before it ever hits the JVM.

Wiring the Plugin into the Build

You can't just "import" a compiler plugin into your source code. Since the plugin modifies the compiler itself, it has to be loaded by the build tool (sbt) before the compilation of your main code begins. In your build.sbt, you'll typically add it as a compiler option.

It looks something like server.optimizations = true or using the addCompilerPlugin helper. If you're distributing the plugin, you'll package it as a separate JAR. The compiler then loads this JAR, instantiates your Plugin class, and merges your Phase into the pipeline. It's a bit of a dance, but it's the only way to achieve truly transparent, project-wide transformations.




📋 Practical Task

Exercise: The "Forbidden API" Guardian

Your task is to build a compiler plugin that prevents developers from using a specific "forbidden" method across the entire project. In this case, let's pretend java.lang.System.out.println is banned in favor of a corporate logging library.

Requirements:

  • Create a Scala compiler plugin that hooks into the pipeline after the typer phase.
  • The plugin must scan the AST for any method call to println on the System.out object.
  • When such a call is found, the plugin should use the reporter to issue a compile-time error: "Direct use of System.out.println is forbidden. Please use the Logger class instead."
  • The plugin should not crash the compiler when encountering other method calls.

Hint: You will need to pattern match on Apply nodes and check if the function being applied is a Select that points to the println method of System.out.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.