Skip to content

Commit 4883a45

Browse files
committed
Add :asmp to the REPL
Provides bytecode disassembly using the ASM library bundled with the Scala compiler.
1 parent 96ba466 commit 4883a45

File tree

5 files changed

+270
-0
lines changed

5 files changed

+270
-0
lines changed

compiler/src/dotty/tools/repl/Disassembler.scala

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package repl
33

44
import scala.annotation.internal.sharable
55
import scala.util.{Failure, Success, Try}
6+
import scala.util.control.NonFatal
67
import scala.util.matching.Regex
78

89
import dotc.core.StdNames.*
@@ -627,3 +628,208 @@ object JavapTask:
627628
// introduced in JDK7 as internal API
628629
val taskClassName = "com.sun.tools.javap.JavapTask"
629630
end JavapTask
631+
632+
/** A disassembler implemented using the ASM library (a dependency of the backend)
633+
* Supports flags similar to javap, with some additions and omissions.
634+
*/
635+
object Asmp extends Disassembler:
636+
import Disassembler.*
637+
638+
def apply(opts: DisassemblerOptions)(using repl: DisassemblerRepl): List[DisResult] =
639+
val tool = AsmpTool()
640+
val clazz = DisassemblyClass(repl.classLoader)
641+
tool(opts.flags)(opts.targets.map(clazz.bytes(_)))
642+
643+
// The flags are intended to resemble those used by javap
644+
val helps = List(
645+
"usage" -> ":asmp [opts] [path or class or -]...",
646+
"-help" -> "Prints this help message",
647+
"-verbose/-v" -> "Stack size, number of locals, method args",
648+
"-private/-p" -> "Private classes and members",
649+
"-package" -> "Package-private classes and members",
650+
"-protected" -> "Protected classes and members",
651+
"-public" -> "Public classes and members",
652+
"-c" -> "Disassembled code",
653+
"-s" -> "Internal type signatures",
654+
"-filter" -> "Filter REPL machinery from output",
655+
"-raw" -> "Don't post-process output from ASM", // TODO for debugging
656+
"-decls" -> "Declarations",
657+
"-bridges" -> "Bridges",
658+
"-synthetics" -> "Synthetics",
659+
)
660+
661+
override def filters(target: String, opts: DisassemblerOptions): List[String => String] =
662+
val commonFilters = super.filters(target, opts)
663+
if opts.flags.contains("-decls") then filterCommentsBlankLines :: commonFilters
664+
else squashConsectiveBlankLines :: commonFilters // default filters
665+
666+
// A filter to compress consecutive blank lines into a single blank line
667+
private def squashConsectiveBlankLines(s: String) = s.replaceAll("\n{3,}", "\n\n").nn
668+
669+
// A filter to remove all blank lines and lines beginning with "//"
670+
private def filterCommentsBlankLines(s: String): String =
671+
val comment = raw"\s*// .*".r
672+
def isBlankLine(s: String) = s.trim == ""
673+
def isComment(s: String) = comment.matches(s)
674+
filteredLines(s, t => !isComment(t) && !isBlankLine(t))
675+
end Asmp
676+
677+
object AsmpOptions extends DisassemblerOptionParser(Asmp.helps):
678+
val defaultToolOptions = List("-protected", "-verbose")
679+
680+
/** Implementation of the ASM-based disassembly tool. */
681+
class AsmpTool extends DisassemblyTool:
682+
import DisassemblyTool.*
683+
import Disassembler.splitHashMember
684+
import java.io.{PrintWriter, StringWriter}
685+
import scala.tools.asm.{Attribute, ClassReader, Label, Opcodes}
686+
import scala.tools.asm.util.{Textifier, TraceClassVisitor}
687+
import dotty.tools.backend.jvm.ClassNode1
688+
689+
enum Mode:
690+
case Verbose, Code, Signatures
691+
692+
/** A Textifier subclass to control the disassembly output based on flags.
693+
* The visitor methods overriden here conditionally suppress their output
694+
* based on the flags and targets supplied to the disassembly tool.
695+
*
696+
* The filtering performed falls into three categories:
697+
* - operating mode: -verbose, -c, -s, etc.
698+
* - access flags: -protected, -private, -public, etc.
699+
* - member name: e.g. a target given as Klass#method
700+
*
701+
* This is all bypassed if the `-raw` flag is given.
702+
*/
703+
class FilteringTextifier(mode: Mode, accessFilter: Int => Boolean, nameFilter: Option[String])
704+
extends Textifier(Opcodes.ASM9):
705+
private def keep(access: Int, name: String): Boolean =
706+
accessFilter(access) && nameFilter.map(_ == name).getOrElse(true)
707+
708+
override def visitField(access: Int, name: String, descriptor: String, signature: String, value: Any): Textifier =
709+
if keep(access, name) then
710+
super.visitField(access, name, descriptor, signature, value)
711+
addNewTextifier(discard = (mode == Mode.Signatures))
712+
else
713+
addNewTextifier(discard = true)
714+
715+
override def visitMethod(access:Int, name: String, descriptor: String, signature: String, exceptions: Array[String | Null]): Textifier =
716+
if keep(access, name) then
717+
super.visitMethod(access, name, descriptor, signature, exceptions)
718+
addNewTextifier(discard = (mode == Mode.Signatures))
719+
else
720+
addNewTextifier(discard = true)
721+
722+
override def visitInnerClass(name: String, outerName: String, innerName: String, access: Int): Unit =
723+
if mode == Mode.Verbose && keep(access, name) then
724+
super.visitInnerClass(name, outerName, innerName, access)
725+
726+
override def visitClassAttribute(attribute: Attribute): Unit =
727+
if mode == Mode.Verbose && nameFilter.isEmpty then
728+
super.visitClassAttribute(attribute)
729+
730+
override def visitClassAnnotation(descriptor: String, visible: Boolean): Textifier | Null =
731+
// suppress ScalaSignature unless -raw given. Should we? TODO
732+
if mode == Mode.Verbose && nameFilter.isEmpty && descriptor != "Lscala/reflect/ScalaSignature;" then
733+
super.visitClassAnnotation(descriptor, visible)
734+
else
735+
addNewTextifier(discard = true)
736+
737+
override def visitSource(file: String, debug: String): Unit =
738+
if mode == Mode.Verbose && nameFilter.isEmpty then
739+
super.visitSource(file, debug)
740+
741+
override def visitAnnotation(descriptor: String, visible: Boolean): Textifier | Null =
742+
if mode == Mode.Verbose then
743+
super.visitAnnotation(descriptor, visible)
744+
else
745+
addNewTextifier(discard = true)
746+
747+
override def visitLineNumber(line: Int, start: Label): Unit =
748+
if mode == Mode.Verbose then
749+
super.visitLineNumber(line, start)
750+
751+
override def visitMaxs(maxStack: Int, maxLocals: Int): Unit =
752+
if mode == Mode.Verbose then
753+
super.visitMaxs(maxStack, maxLocals)
754+
755+
override def visitLocalVariable(name: String, descriptor: String, signature: String, start: Label, end: Label, index: Int): Unit =
756+
if mode == Mode.Verbose then
757+
super.visitLocalVariable(name, descriptor, signature, start, end, index)
758+
759+
private def isLabel(s: String) = raw"\s*L\d+\s*".r.matches(s)
760+
761+
// ugly hack to prevent orphaned label when local vars, max stack not displayed (e.g. in -c mode)
762+
override def visitMethodEnd(): Unit = if text != null then text.size match
763+
case 0 =>
764+
case n =>
765+
if isLabel(text.get(n - 1).toString) then
766+
try text.remove(n - 1)
767+
catch case _: UnsupportedOperationException => ()
768+
769+
private def addNewTextifier(discard: Boolean = false): Textifier =
770+
val tx = FilteringTextifier(mode, accessFilter, nameFilter)
771+
if !discard then text.nn.add(tx.getText())
772+
tx
773+
end FilteringTextifier
774+
775+
override def apply(options: Seq[String])(inputs: Seq[Input]): List[DisResult] =
776+
def parseMode(opts: Seq[String]): Mode =
777+
if opts.contains("-c") then Mode.Code
778+
else if opts.contains("-s") || opts.contains("-decls") then Mode.Signatures
779+
else Mode.Verbose // default
780+
781+
def parseAccessLevel(opts: Seq[String]): Int =
782+
if opts.contains("-public") then Opcodes.ACC_PUBLIC
783+
else if opts.contains("-protected") then Opcodes.ACC_PROTECTED
784+
else if opts.contains("-private") || opts.contains("-p") then Opcodes.ACC_PRIVATE
785+
else 0
786+
787+
def accessFilter(mode: Mode, accessLevel: Int, opts: Seq[String]): Int => Boolean =
788+
inline def contains(mask: Int) = (a: Int) => (a & mask) != 0
789+
inline def excludes(mask: Int) = (a: Int) => (a & mask) == 0
790+
val showSynthetics = opts.contains("-synthetics")
791+
val showBridges = opts.contains("-bridges")
792+
def accessible: Int => Boolean = accessLevel match
793+
case Opcodes.ACC_PUBLIC => contains(Opcodes.ACC_PUBLIC)
794+
case Opcodes.ACC_PROTECTED => contains(Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)
795+
case Opcodes.ACC_PRIVATE => _ => true
796+
case _ /* package */ => excludes(Opcodes.ACC_PRIVATE)
797+
def included(access: Int): Boolean = mode match
798+
case Mode.Verbose => true
799+
case _ =>
800+
val isBridge = contains(Opcodes.ACC_BRIDGE)(access)
801+
val isSynthetic = contains(Opcodes.ACC_SYNTHETIC)(access)
802+
if isSynthetic && showSynthetics then true // TODO do we have tests for -synthetics?
803+
else if isBridge && showBridges then true // TODO do we have tests for -bridges?
804+
else if isSynthetic || isBridge then false
805+
else true
806+
a => accessible(a) && included(a)
807+
808+
def runInput(input: Input): DisResult = input match
809+
case Input(target, _, Success(bytes)) =>
810+
val sw = StringWriter()
811+
val pw = PrintWriter(sw)
812+
val node = ClassNode1()
813+
814+
val tx =
815+
if options.contains("-raw") then
816+
Textifier()
817+
else
818+
val mode = parseMode(options)
819+
val accessLevel = parseAccessLevel(options)
820+
val nameFilter = splitHashMember(target).map(s => if s.isEmpty then "apply" else s)
821+
FilteringTextifier(mode, accessFilter(mode, accessLevel, options), nameFilter)
822+
823+
try
824+
ClassReader(bytes).accept(node, 0)
825+
node.accept(TraceClassVisitor(null, tx, pw))
826+
pw.flush()
827+
DisSuccess(target, sw.toString)
828+
catch case NonFatal(e) => DisError(e.getMessage)
829+
case Input(_, _, Failure(e)) =>
830+
DisError(e.getMessage)
831+
end runInput
832+
833+
inputs.map(runInput).toList
834+
end apply
835+
end AsmpTool

compiler/src/dotty/tools/repl/ParseResult.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ object Load {
5252
val command: String = ":load"
5353
}
5454

55+
/** Run the ASM based disassembler on the given target(s) */
56+
case class AsmpOf(args: String) extends Command
57+
object AsmpOf:
58+
val command: String = ":asmp"
5559

5660
/** Run the javap disassembler on the given target(s) */
5761
case class JavapOf(args: String) extends Command
@@ -119,6 +123,7 @@ case object Help extends Command {
119123
|:imports show import history
120124
|:reset [options] reset the repl to its initial state, forgetting all session entries
121125
|:settings <options> update compiler options, if possible
126+
|:asmp <path|class> disassemble a file or class name (experimental)
122127
|:javap <path|class> disassemble a file or class name
123128
""".stripMargin
124129
}
@@ -144,6 +149,7 @@ object ParseResult {
144149
TypeOf.command -> (arg => TypeOf(arg)),
145150
DocOf.command -> (arg => DocOf(arg)),
146151
Settings.command -> (arg => Settings(arg)),
152+
AsmpOf.command -> (arg => AsmpOf(arg)),
147153
JavapOf.command -> (arg => JavapOf(arg))
148154
)
149155

compiler/src/dotty/tools/repl/ReplDriver.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,12 @@ class ReplDriver(settings: Array[String],
462462
state
463463
}
464464

465+
case AsmpOf(line) =>
466+
given DisassemblerRepl(this, state)
467+
val opts = AsmpOptions.parse(ReplStrings.words(line))
468+
disassemble(Asmp, opts)
469+
state
470+
465471
case JavapOf(line) =>
466472
given DisassemblerRepl(this, state)
467473
val opts = JavapOptions.parse(ReplStrings.words(line))

compiler/test/dotty/tools/repl/DisassemblerTests.scala

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,54 @@ class JavapFilterSelectionTests:
488488
|""".stripMargin,
489489
Javap.filterSelection("List#sorted")(listC))
490490
end JavapFilterSelectionTests
491+
492+
// Test disassembly using `:asmp`
493+
class AsmpTests extends DisassemblerTest:
494+
override val packageSeparator = "/"
495+
496+
@Test def `simple end-to-end` =
497+
eval("class Foo1").andThen {
498+
run(":asmp -c Foo1")
499+
assertDisassemblyIncludes(List(
500+
s"public class ${line(1, "Foo1")} {",
501+
"public <init>()V",
502+
"INVOKESPECIAL java/lang/Object.<init> ()V",
503+
))
504+
}
505+
506+
@Test def `multiple classes in prev entry` =
507+
eval {
508+
"""class Foo2
509+
|trait Bar2
510+
|""".stripMargin
511+
} andThen {
512+
run(":asmp -c -")
513+
assertDisassemblyIncludes(List(
514+
s"public class ${line(1, "Foo2")} {",
515+
s"public abstract interface ${line(1, "Bar2")} {",
516+
))
517+
}
518+
519+
@Test def `private selected method` =
520+
eval {
521+
"""class Baz1:
522+
| private def one = 1
523+
| private def two = 2
524+
|""".stripMargin
525+
} andThen {
526+
run(":asmp -p -c Baz1#one")
527+
val out = storedOutput()
528+
assertDisassemblyIncludes("private one()I", out)
529+
assertDisassemblyExcludes("private two()I", out)
530+
}
531+
532+
@Test def `java.lang.String signatures` =
533+
initially {
534+
run(":asmp -s java.lang.String")
535+
val out = storedOutput()
536+
assertDisassemblyIncludes("public static varargs format(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;", out)
537+
assertDisassemblyIncludes("public static join(Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String;", out)
538+
assertDisassemblyIncludes("public concat(Ljava/lang/String;)Ljava/lang/String;", out)
539+
assertDisassemblyIncludes("public trim()Ljava/lang/String;", out)
540+
}
541+
end AsmpTests

compiler/test/dotty/tools/repl/TabcompleteTests.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ class TabcompleteTests extends ReplTest {
207207
@Test def commands = initially {
208208
assertEquals(
209209
List(
210+
":asmp",
210211
":doc",
211212
":exit",
212213
":help",

0 commit comments

Comments
 (0)