# Yes, You Can Debug a Scala 3 Macro

Table of Contents

The State of Scala 3 Macro Docs

There is no good Scala 3 macro book. Most of what I know I picked up from forum threads, release notes or papers. This post is my attempt to collect everything I’ve figured out so far, plus the tips that aren’t written down anywhere else.

This post wouldn’t exist without my best friend Bartek, who has a knack for doing impossible things — mostly because nobody told him they were impossible.

Where to Learn From

Tutorials

I’d recommend starting with these four. They overlap in places, but each one introduces something the others skip:

Papers

Once the tutorials stop helping, the papers explain why the API looks the way it does. They’re less scary than they sound — skim the abstracts and intros first, and dip into the ones that match the question you’re currently stuck on:

Quotes.scala Is Your Best Friend

You’ll spend more time in Quotes.scala than in any tutorial. Every tree type and every available method is defined there.

Each tree kind follows the same four-part shape: the type, the module object, a TypeTest for pattern matching, and an extension methods trait. For example:

/** Tree representing an if/then/else `if (...) ... else ...` in the source code. */
type If <: Term
/** Module object of `type If`. */
val If: IfModule
/** `TypeTest` that allows testing at runtime in a pattern match if a `Tree` is an `If`. */
given IfTypeTest: TypeTest[Tree, If]
/** Makes extension methods on `If` available without any imports. */
given IfMethods: IfMethods
/** Methods of the module object `val If`. */
trait IfModule {
this: If.type =>
def apply(cond: Term, thenp: Term, elsep: Term): If
def copy(original: Tree)(cond: Term, thenp: Term, elsep: Term): If
def unapply(tree: If): (Term, Term, Term)
}
/** Extension methods of `If`. */
trait IfMethods:
extension (self: If)
def cond: Term
def thenp: Term
def elsep: Term
def isInline: Boolean

Once you spot the pattern, the rest of the reflection API stops being a guessing game. The Scaladoc itself is packed with hints about invariants, tree shapes and idiomatic usage — far more than the guides on the website.

When Quotes.scala runs out, the compiler source is the next stop. For example, I’ve learned how to synthesise an anonymous class by reading tpd.scala.

A Note on IDEs

For macro-heavy code, VS Code with Metals has been less painful than IntelliJ. Sometimes (really sometimes) navigation is better, error messages are clearer, and there are files that compile fine in Metals but IntelliJ refuses to recognise. Syntax highlighting breaks on nested quotes; the formatter mangles code around splices. I still use IntelliJ for everything else.

Measuring Where the Compiler Spends Time

Slow compiles and outright compiler hangs aren’t things you can diagnose with println. The built-in profiler shows you exactly which phase and which expansion is eating the clock.

Enable it via compiler options:

-Yprofile-enabled
-Yprofile-trace:<path to output file>

This emits a trace in the Chrome/Perfetto format. Load it in ui.perfetto.dev and you get a flamegraph of phases, macro expansions, and type-check calls:

Perfetto showing a compile-time profile trace of a Scala 3 macro

The option is documented exactly once, in a 3.6.3 release note — which is how you find most Scala tooling features, and yes, it’s as infuriating as it sounds.

Everyone says println is the only way to debug a macro, so I built myself a pile of utilities on top of it. This one dumps everything the compiler knows about a type into a single error message:

def dbg(using quotes: Quotes, printer: quotes.reflect.Printer[quotes.reflect.TypeRepr])(tpe: quotes.reflect.TypeRepr): Nothing =
quotes.reflect.errorAndAbort(
s"""
|type: ${tpe.show}
|widen: ${tpe.widen.show}
|widenTermRefByName: ${tpe.widenTermRefByName.show}
|widenByName: ${tpe.widenByName.show}
|dealias: ${tpe.dealias.show}
|dealiasKeepOpaques: ${tpe.dealiasKeepOpaques.show}
|simplified: ${tpe.simplified.show}
|classSymbol: ${tpe.classSymbol}
|typeSymbol: ${tpe.typeSymbol}
|termSymbol: ${tpe.termSymbol}
|isSingleton: ${tpe.isSingleton}
|baseClasses: ${tpe.baseClasses}
|isFunctionType: ${tpe.isFunctionType}
|isContextFunctionType: ${tpe.isContextFunctionType}
|isErasedFunctionType: ${tpe.isErasedFunctionType}
|isDependentFunctionType: ${tpe.isDependentFunctionType}
|isTupleN: ${tpe.isTupleN}
|typeArgs: ${tpe.typeArgs}
|""".stripMargin,
)

Or this one, for when you’re guessing at an AST shape:

inline def showRawAst(inline body: Any) = ${ showRawAstImpl('{ body }) }
def showRawAstImpl(body: Expr[Any])(using quotes: Quotes) =
import quotes.reflect.*
report.errorAndAbort(Printer.TreeStructure.show(body.asTerm.underlyingArgument))

showRawAst(someExpression) aborts compilation with the raw Apply(Select(Ident(...), ...), ...) shape of the expression. When you don’t know which quotes.reflect case to match on, it tells you.

Attaching a Real Debugger

My best friend, Bartek, didn’t get the memo that println was the only way to debug a macro.

He (and his LLM) treated the compiler as just another JVM process — attached a debugger, set breakpoints inside our macro implementations, and watched them fire on the next compile.

Written down like that it sounds obvious. Macros run during compilation, so the program to debug is the Scala compiler. The compile-side recipe is always the same: start the compiler’s JVM with -agentlib:jdwp=..., whether you run Mill, sbt or scala-cli. What changes is how you configure the IDE side.

VS Code attached to the Scala compiler, stopped on a breakpoint inside a macro implementation

Starting the Compile with JDWP

The flag:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005

suspend=y pauses the JVM until a debugger connects, so you can attach before anything expands. How to inject the flag depends on the build tool:

mill

Terminal window
JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005" mill -i YourModule.compile

sbt

Terminal window
sbt -jvm-debug 5005

scala-cli

Terminal window
# stop any running Bloop daemon (otherwise it'll reuse the old JVM without the agent)
scala-cli --power bloop exit
# compile, passing JDWP args to the new Bloop JVM
scala-cli compile . --bloop-java-opt "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005"

All three end up the same way: a Scala compiler sitting on port 5005, waiting.

VS Code — launch.json

Add an attach configuration pointing at port 5005:

.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "scala",
"request": "attach",
"name": "Debug Macro (Compile-time)",
"hostName": "localhost",
"port": 5005,
"buildTarget": "YourModuleName"
}
]
}

buildTarget is the BSP target for the module whose sources contain your breakpoints. Metals lists them in the status bar; it’s usually the module name from your build file.

Set breakpoints, start to compile, then Run -> Start Debugging.

IntelliJ IDEA — Remote JVM Debug

IntelliJ has no BSP-aware attach config for macros, so use the generic remote debugger:

  1. Run -> Edit Configurations -> + -> Remote JVM Debug.
  2. Host localhost, port 5005, “Attach to remote JVM”, command line args for remote JVM auto-filled.
  3. Start to compile.
  4. Run -> Debug -> your new configuration.
IntelliJ IDEA Remote JVM Debug configuration attached to the Scala compiler on port 5005

Case Closed

There is no official “how to debug a Scala 3 macro” page. Until there is, I hope this one saves someone time. See ya next time.

References

My avatar

Thanks for reading my blog post! Feel free to comment or contact me via the social links in the footer. Follow me on LinkedIn to receive notifications about new content.


More Posts

Comments