Decoding the stack frames in the debugger (Scala 3 only)

A Scala stack trace is often hard to interpret because it contains compiler-generated methods with encoded and mangled names, and erased types.

The Binary Decoder

In the latest version of the Scala Debug Adapter (the Scala debugger used by Metals) we developed a binary decoder based on TASTy Query. The binary decoder can take any binary method from a Scala 3 class file and decode it to a tagged TASTy symbol, or TASTy tree (if there is no corresponding symbol, e.g. lifted by-name argument). The tag indicates the nature of the binary symbol: a declared val or def, an accessor, a bridge, a forwarder, a lifted by-name argument, a default argument, an adapted method etc.

The TASTy symbol or tree is fully typed and it contains source names. Thus the debugger can format the method of a stack frame to a typed symbol, with names and types as they appear in the source, and a qualifier if the method is generated. The list of qualifiers are: <init>, <lazy init>, <mixin forwarder>, <static forwarder>, <outer>, <super arg>, <try>, <anon fun>, <by-name arg>, <bridge>, <setter>, <super>, <specialized>, <adapted> and <inline accessor>.

Additionally the debugger can skip and hide the methods that are pure artifacts of the compiler, like the forwarders, bridges and accessors, as they don’t contain any user-written code.


Decoded names

package example

object && :
  private def && = "&&" // breakpoint here

  object || :
    def || = "||" + &&

@main def mangling: Unit =

Binary stack trace:

$amp$amp$.example$$amp$amp$$$$amp$amp(): String
$amp$amp$$bar$bar$.$bar$bar(): String
Main$package$.mangling(): void

Decoded stack trace:

&&.&&: String
&&.||.||: String
example.mangling: Unit

Erasure, Anonymous Functions and Bridges

package example

def combine(xs: Seq[String], ys: Seq[String]): Seq[String] =
    x <- xs
    y <- ys
    x + y // breakpoint here

@main def combine: Unit =
  combine(Seq("a", "b"), Seq("1", "2"))

Binary stack trace:

Main$package$.combine$$anonfun$1(Seq,String): IterableOnce
List.flatMap(Function1): List
List.flatMap(Function1): Object
Main$package$.combine(Seq,Seq): Seq

Decoded stack trace:

example.combine.<anon fun>(x: String): IterableOnce[String]
List.flatMap[B](f: A => IterableOnce[B]): List[B]
List.flatMap.<bridge>[B](f: A => IterableOnce[B]): List[B]
example.combine(xs: Seq[String], ys: Seq[String]): Seq[String]
example.combine: Unit

Filtered stack trace (we can hide the bridge method):

example.combine.<anon fun>(x: String): IterableOnce[String]
List.flatMap[B](f: A => IterableOnce[B]): List[B]
example.combine(xs: Seq[String], ys: Seq[String]): Seq[String]
example.combine: Unit


package example

trait Show:
  def show: String = toString // breakpoint here

case class Position(line: Int, column: Int) extends Show

@main def showPos: Unit =
  val pos = Position(5, 12)

Binary stack trace: String$(Show): String String
Main$package$.showPos(): void

Decoded stack trace: String<static forwarder>: String<mixin forwarder>: String
example.showPos: Unit

Filtered stack trace: String
example.showPos: Unit

A Real-world stack trace

Binary stack trace:$Context): String$(Showable,Contexts$Context): String
Types$$Context): String
Decorators$.tryToShow(Object,Contexts$Context): String
Formatting$StringFormatter.showArg(Object,Contexts$Context): String
Formatting$StringFormatter.treatArg(Object,String,Contexts$Context): Tuple2
Formatting$StringFormatter.$anonfun$3(Contexts$Context,Object,String): Tuple2
Formatting$StringFormatter$$Lambda$652/0x0000000800f9c1e8.apply(Object,Object): Object
LazyZip2$$anon$1$$anon$ Object
Growable.addAll(IterableOnce): Growable
Growable.addAll$(Growable,IterableOnce): Growable
ArrayBuilder.addAll(IterableOnce): ArrayBuilder
IterableOnceOps.toArray(ClassTag): Object
IterableOnceOps.toArray$(IterableOnceOps,ClassTag): Object
AbstractIterable.toArray(ClassTag): Object
Array$.from(IterableOnce,ClassTag): Object
ArraySeq$.from(IterableOnce,ClassTag): ArraySeq
ArraySeq$.from(IterableOnce,Object): Object
ClassTagIterableFactory$AnyIterableDelegate.from(IterableOnce): Object
BuildFromLowPriority2$$anon$11.fromSpecific(Iterable,IterableOnce): Iterable

Decoded and filtered stack trace: Contexts.Context): String
Decorators.tryToShow[T](x: T)(using Contexts.Context): String
Formatting.StringFormatter.showArg(arg: Any)(using Contexts.Context): String
Formatting.StringFormatter.treatArg(arg: Shown, suffix: String)(using Contexts.Context): (String, String)
Formatting.StringFormatter.assemble.<anon fun>(Shown, String): (String, String)
LazyZip2$$anon$1$$anon$ Object
Growable.addAll(xs: IterableOnce[A]): Growable.this.type
ArrayBuilder.addAll(xs: IterableOnce[T]): ArrayBuilder.this.type
IterableOnceOps.toArray[B](implicit ClassTag[B]): Array[B]
AbstractIterable.toArray(ClassTag): Object
Array.from[A](it: IterableOnce[A])(implicit ClassTag[A]): Array[A]
ArraySeq.from[A](it: IterableOnce[A])(implicit tag: ClassTag[A]): ArraySeq[A]
ClassTagIterableFactory.AnyIterableDelegate.from[A](it: IterableOnce[A]): CC[A]
BuildFromLowPriority2$$anon$11.fromSpecific(Iterable,IterableOnce): Iterable

Note that some methods cannot be decoded because they come from Scala 2.

Parametrization of the Debugger

The Step Filters

In the next version of the debugger we will have 3 step-filter parameters:

  • skipForwardersAndAccessors: to skip the forwarders and accessors
  • skipClassLoading: to skip the implicit class loading, on a new or the initialization of an object
  • skipScalaRuntime: to skip all the classes in the scala.runtime package

These 3 step filters are enabled by default and they can be disabled independently.

The Scala Decoder

We will add a enableScalaDecoder (default is true) to enable or disable the Scala decoder.

If the decoder is disabled, the debugger will fall back to printing the binary methods of the stack traces.

Stack Trace Formatting

The Debug Adapter Protocol defines the StackFrameFormat object to describe how to display a stack frame: with parameter names, parameter types etc.

It is not yet implemented in the Scala Debug Adapter


Instead of completely hiding the bridges, forwarders and accessors we can use the presentationHint field in StackFrame to deemphasize a frame.

On VSCode, deemphasized frames appear grayed out. Also VSCode collapses consecutive deemphasized frames to shorten the stack trace.

Limitations of the Decoder

  • The binary decoder cannot decode private Scala 2 symbols.

  • It cannot decode methods that are generated by the expansion of a macro.

  • It can fail decoding some symbols because of ambiguity: if they have the same source names, binary signatures and debug lines

def foo(xs: Seq[String]): Seq[String] =
   // the decoder cannot disambiguate the two anonfuns
  xs.filter(x => x.size < 2) ++ xs.filter(x => x.size >= 3)

Next Steps

The binary decoder is part of the latest release of the Scala Debug Adapter. The features described above will be available in the upcoming version of Metals.

Here are the next steps of this project:

  • Publishing the scala3-binary-decoder as an independent library that can be used in other tools of the ecosystem: Scala CLI, Scastie and potentially the IntelliJ Scala debugger.

  • Extending the scala3-binary-decoder with the ability to decode local variables, for instance to decode captured variables.

  • Using the position (line and column) of a decoded symbol to automatically disambiguate the evaluation scope of a debug expression (watch or debug console).


Open for debate. Would you prefer the debugger to completely hide the forwarders or to collapse them?

  1. hidden

  2. collapsed


I prefer collapsed, as it still has all the information. This can be helpful in understanding what’s going on under the hood and help mentally model odd behaviors, while being out of the way for day-to-day stuff.


I’d prefer to drop them completely, and never be surprised by odd behavior!

In the case it is sometimes needed to still look at them, it’s of course better to keep them


I too would prefer to keep them. I’ve had to dig into what happens under the hood a couple times :slight_smile: