Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CFR class file viewer #3247

Merged
merged 3 commits into from
Nov 8, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ lazy val V = new {
val sbtBloop = bloop
val gradleBloop = bloop
val mavenBloop = bloop
val cfr = "0.151"
val mdoc = "2.2.24"
val scalafmt = "3.0.5"
val munit = "0.7.29"
Expand Down Expand Up @@ -435,6 +436,8 @@ lazy val metals = project
V.dap4j,
// for producing SemanticDB from Java source files
"com.thoughtworks.qdox" % "qdox" % "2.0.0",
// for decompiling class files
"org.benf" % "cfr" % V.cfr,
// for finding paths of global log/cache directories
"dev.dirs" % "directories" % "26",
// ==================
Expand Down Expand Up @@ -475,6 +478,7 @@ lazy val metals = project
"sbtBloopVersion" -> V.sbtBloop,
"gradleBloopVersion" -> V.gradleBloop,
"mavenBloopVersion" -> V.mavenBloop,
"cfrVersion" -> V.cfr,
"scalametaVersion" -> V.scalameta,
"semanticdbVersion" -> V.semanticdb,
"scalafmtVersion" -> V.scalafmt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.util.Collection
import java.{util => ju}
import javax.annotation.Nullable

import scala.collection.mutable.ListBuffer
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.util.Failure
Expand All @@ -29,6 +32,9 @@ import scala.meta.metap.Format
import scala.meta.metap.Settings

import ch.epfl.scala.bsp4j.BuildTargetIdentifier
import org.benf.cfr.reader.api.CfrDriver
import org.benf.cfr.reader.api.OutputSinkFactory
import org.benf.cfr.reader.api.SinkReturns

/* Response which is sent to the lsp client. Because of java serialization we cannot use
* sealed hierarchy to model union type of success and error.
Expand Down Expand Up @@ -93,6 +99,11 @@ final class FileDecoderProvider(
* metalsDecode:file:///somePath/someFile.scala.javap-verbose
* metalsDecode:file:///somePath/someFile.class.javap-verbose
*
* CFR:
* metalsDecode:file:///somePath/someFile.java.cfr
* metalsDecode:file:///somePath/someFile.scala.cfr
* metalsDecode:file:///somePath/someFile.class.cfr
*
* semanticdb:
* metalsDecode:file:///somePath/someFile.java.semanticdb-compact
* metalsDecode:file:///somePath/someFile.java.semanticdb-detailed
Expand Down Expand Up @@ -155,16 +166,19 @@ final class FileDecoderProvider(
uri: URI
): Future[DecoderResponse] = {
val supportedExtensions = Set("javap", "javap-verbose", "tasty-decoded",
"semanticdb-compact", "semanticdb-detailed", "semanticdb-proto")
"semanticdb-compact", "semanticdb-detailed", "semanticdb-proto", "cfr")
val additionalExtension = uri.toString().split('.').toList.last
if (supportedExtensions(additionalExtension)) {
val stripped = toFile(uri, s".$additionalExtension")
stripped match {
case Left(value) => Future.successful(value)
case Right(path) =>
additionalExtension match {
case "javap" => decodeJavap(path, false)
case "javap-verbose" => decodeJavap(path, true)
case "javap" =>
decodeJavaOrScalaOrClass(path, decodeJavapFromClassFile(false))
case "javap-verbose" =>
decodeJavaOrScalaOrClass(path, decodeJavapFromClassFile(true))
case "cfr" => decodeJavaOrScalaOrClass(path, decodeCFRFromClassFile)
case "tasty-decoded" => decodeTasty(path)
case "semanticdb-compact" =>
Future.successful(
Expand All @@ -187,27 +201,32 @@ final class FileDecoderProvider(
private def toFile(
uri: URI,
suffixToRemove: String
): Either[DecoderResponse, AbsolutePath] = Try {
uri.toString.stripSuffix(suffixToRemove).toAbsolutePath
}.filter(_.exists)
.toOption
.toRight(DecoderResponse.failed(uri, s"File $uri doesn't exist"))
): Either[DecoderResponse, AbsolutePath] = {
val strippedURI = uri.toString.stripSuffix(suffixToRemove)
Try {
strippedURI.toAbsolutePath
}.filter(_.exists)
.toOption
.toRight(
DecoderResponse.failed(uri, s"File $strippedURI doesn't exist")
)
}

private def decodeJavap(
private def decodeJavaOrScalaOrClass(
path: AbsolutePath,
isVerbose: Boolean
decode: AbsolutePath => Future[DecoderResponse]
): Future[DecoderResponse] = {
if (path.isClassfile) decodeJavapFromClassFile(path, isVerbose)
if (path.isClassfile) decode(path)
else if (path.isJava) {
findPathInfoFromSource(path, ".class")
.map(p => decodeJavapFromClassFile(p.path, isVerbose)) match {
findPathInfoFromJavaSource(path, "class")
.map(p => decode(p.path)) match {
case Left(err) =>
Future.successful(DecoderResponse.failed(path.toURI, err))
case Right(response) => response
}
} else if (path.isScala)
selectClassFromScalaFileAndDecode(path.toURI, path, true)(p =>
decodeJavapFromClassFile(p.path, isVerbose)
decode(p.path)
)
else
Future.successful(DecoderResponse.failed(path.toURI, "Invalid extension"))
Expand Down Expand Up @@ -248,12 +267,12 @@ final class FileDecoderProvider(
Future.successful(DecoderResponse.failed(path.toURI, "Invalid extension"))
}

private def findPathInfoFromSource(
private def findPathInfoFromJavaSource(
sourceFile: AbsolutePath,
newExtension: String
): Either[String, PathInfo] =
findBuildTargetMetadata(sourceFile)
.map { case (targetId, target, sourceRoot) =>
.map { case (targetId, target, _, sourceRoot) =>
val classDir = target.classDirectory.toAbsolutePath
val oldExtension = sourceFile.extension
val relativePath = sourceFile
Expand Down Expand Up @@ -299,7 +318,7 @@ final class FileDecoderProvider(
DecoderResponse.failed(requestedURI, _)
)
} yield {
val (targetId, target, _) = buildMetadata
val (targetId, target, _, _) = buildMetadata
val classDir = target.classDirectory.toAbsolutePath
val pathToResource = classDir.resolve(resourcePath)
PathInfo(targetId, pathToResource)
Expand Down Expand Up @@ -339,11 +358,11 @@ final class FileDecoderProvider(
): Either[String, AbsolutePath] =
for {
metadata <- findBuildTargetMetadata(sourceFile)
(targetId, target, sourceRoot) = metadata
(targetId, target, workspaceDirectory, _) = metadata
foundSemanticDbPath <- {
val targetRoot = target.targetroot
val relativePath = SemanticdbClasspath.fromScala(
sourceFile.toRelative(sourceRoot.dealias)
sourceFile.toRelative(workspaceDirectory.dealias)
)
fileSystemSemanticdbs
.findSemanticDb(
Expand All @@ -362,22 +381,22 @@ final class FileDecoderProvider(
sourceFile: AbsolutePath
): Either[
String,
(BuildTargetIdentifier, ScalaTarget, AbsolutePath)
(BuildTargetIdentifier, ScalaTarget, AbsolutePath, AbsolutePath)
] = {
val metadata = for {
targetId <- buildTargets.inverseSources(sourceFile)
target <- buildTargets.scalaTarget(targetId)
sourceRoot <- buildTargets.workspaceDirectory(targetId)
} yield (targetId, target, sourceRoot)
workspaceDirectory <- buildTargets.workspaceDirectory(targetId)
sourceRoot <- buildTargets.inverseSourceItem(sourceFile)
} yield (targetId, target, workspaceDirectory, sourceRoot)
metadata.toRight(
s"Cannot find build's metadata for ${sourceFile.toURI.toString()}"
)
}

private def decodeJavapFromClassFile(
path: AbsolutePath,
verbose: Boolean
): Future[DecoderResponse] = {
)(path: AbsolutePath): Future[DecoderResponse] = {
try {
val args = if (verbose) List("-verbose") else Nil
val sb = new StringBuilder()
Expand Down Expand Up @@ -406,6 +425,70 @@ final class FileDecoderProvider(
}
}

private def decodeCFRFromClassFile(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we run CFR using ShellRunner and download the dependency on demand? This would mean we wouldn't need to add the additional dependency to Metals.

I see there is a main that can be used https://github.com/leibnitz27/cfr/blob/9f5a97cf76d94d3ac695887abc6bbfb740c70a9c/src/org/benf/cfr/reader/Main.java

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could do. It's only 2MB but I guess it all adds up. Is there someplace where this is already done - where I can nick the code?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it all adds up and especially if it's a tool used by more advanced developers this might not be needed to download at all

That should be for example in the NewProjectProvider, it uses gitter8 by downloading it and running with the current java home.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - ShellRunner#runJava made this very easy to change.

path: AbsolutePath
): Future[DecoderResponse] = {
Future {
Try {
val out = new ByteArrayOutputStream()
val err = new ByteArrayOutputStream()
val exceptions = new ListBuffer[Exception]()
val psOut = new PrintStream(out)
val psErr = new PrintStream(err)
try {
val sink = new OutputSinkFactory() {

override def getSupportedSinks(
sinkType: OutputSinkFactory.SinkType,
sinkClasses: Collection[OutputSinkFactory.SinkClass]
): ju.List[OutputSinkFactory.SinkClass] =
List(
OutputSinkFactory.SinkClass.EXCEPTION_MESSAGE,
OutputSinkFactory.SinkClass.STRING
).asJava

override def getSink[T](
sinkType: OutputSinkFactory.SinkType,
sinkClass: OutputSinkFactory.SinkClass
): OutputSinkFactory.Sink[T] =
sinkType match {
case OutputSinkFactory.SinkType.JAVA => psOut.print(_)
Arthurm1 marked this conversation as resolved.
Show resolved Hide resolved
case OutputSinkFactory.SinkType.EXCEPTION =>
_ match {
case msg: SinkReturns.ExceptionMessage =>
exceptions += msg.getThrownException()
case f => psErr.print(f)
Arthurm1 marked this conversation as resolved.
Show resolved Hide resolved
}
case _ => f => {}
}
}
val options = Map("analyseas" -> "CLASS")
val driver = new CfrDriver.Builder()
.withOptions(options.asJava)
.withOutputSink(sink)
.build()
// must be a mutable java list as it gets sorted
driver.analyse(ju.Collections.singletonList(path.toNIO.toString))
if (exceptions.nonEmpty)
throw exceptions.head
val output = new String(out.toByteArray)
val error = new String(err.toByteArray)
if (error.isEmpty)
output
else
error
} finally {
psOut.close()
psErr.close()
}
} match {
case Failure(exception) =>
DecoderResponse.failed(path.toString(), exception)
case Success(value) => DecoderResponse.success(path.toURI, value)
}
}
}

private def decodeFromSemanticDBFile(
path: AbsolutePath,
format: Format
Expand All @@ -424,8 +507,8 @@ final class FileDecoderProvider(
.withFormat(format)
val main = new Main(settings, reporter)
main.process()
val output = new String(out.toByteArray);
val error = new String(err.toByteArray);
val output = new String(out.toByteArray)
val error = new String(err.toByteArray)
if (error.isEmpty)
output
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ class FileDecoderProviderLspSuite extends BaseLspSuite("fileDecoderProvider") {
Right(FileDecoderProviderLspSuite.tastyToplevel)
)

check(
"cfr",
s"""|/metals.json
|{
| "app": {
| "scalaVersion": "${V.scala3}"
| }
|}
|/app/src/main/scala/Main.scala
|package foo.bar.example
|class Foo
|class Bar
|""".stripMargin,
"app/src/main/scala/Main.scala",
Some("foo/bar/example/Foo.class"),
"cfr",
Right(FileDecoderProviderLspSuite.cfr)
)

check(
"cfr-toplevel",
s"""|/metals.json
|{
| "app": {
| "scalaVersion": "${V.scala3}"
| }
|}
|/app/src/main/scala/Main.scala
|package foo.bar.example
|class Foo
|class Bar
|def foo(): Unit = ()
|""".stripMargin,
"app/src/main/scala/Main.scala",
Some("foo/bar/example/Main$package.class"),
"cfr",
Right(FileDecoderProviderLspSuite.cfrToplevel)
)

check(
"javap",
s"""|/metals.json
Expand Down Expand Up @@ -545,6 +584,31 @@ object FileDecoderProviderLspSuite {
| 0 comment bytes:
|""".stripMargin

private val cfr =
s"""|/*
| * Decompiled with CFR ${V.cfrVersion}.
| */
|package foo.bar.example;
|
|public class Foo {
|}
|""".stripMargin

private val cfrToplevel =
s"""|/*
| * Decompiled with CFR ${V.cfrVersion}.
| */
|package foo.bar.example;
|
|import foo.bar.example.Main$$package$$;
|
|public final class Main$$package {
| public static void foo() {
| Main$$package$$.MODULE$$.foo();
| }
|}
|""".stripMargin

private val javap =
s"""|Compiled from "Main.scala"
|public class foo.bar.example.Foo {
Expand Down