Skip to content

Commit

Permalink
improvement: add debug adapter for running main class to metals (#6383)
Browse files Browse the repository at this point in the history
* improvement: add debug adapter for running main class to metals

* add tests

* wee cleanup

* clean up

* fix: use javacOptions classpath if scalacOptions are empty

connected to: com-lihaoyi/mill#3086

* add more informative errors
  • Loading branch information
kasiaMarek authored Jun 28, 2024
1 parent 13f7ed7 commit f116e13
Show file tree
Hide file tree
Showing 20 changed files with 612 additions and 103 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ lazy val metals = project
V.lsp4j,
// for DAP
V.dap4j,
"ch.epfl.scala" %% "scala-debug-adapter" % V.debugAdapter,
// for finding paths of global log/cache directories
"dev.dirs" % "directories" % "26",
// for Java formatting
Expand Down Expand Up @@ -778,7 +779,6 @@ lazy val metalsDependencies = project
"ch.epfl.scala" % "bloop-maven-plugin" % V.mavenBloop,
"ch.epfl.scala" %% "gradle-bloop" % V.gradleBloop,
"com.sourcegraph" % "semanticdb-java" % V.javaSemanticdb,
"ch.epfl.scala" %% "scala-debug-adapter" % V.debugAdapter intransitive (),
"org.foundweekends.giter8" %% "giter8" % V.gitter8Version intransitive (),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ final class BuildTargets private (
def javaTarget(id: BuildTargetIdentifier): Option[JavaTarget] =
data.fromOptions(_.javaTarget(id))

def jvmTarget(id: BuildTargetIdentifier): Option[JvmTarget] =
data.fromOptions(_.jvmTarget(id))

def fullClasspath(
id: BuildTargetIdentifier,
cancelPromise: Promise[Unit],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ package scala.meta.internal.metals
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.io.AbsolutePath

import ch.epfl.scala.bsp4j.BuildTargetIdentifier

trait JvmTarget {

def displayName: String

def id: BuildTargetIdentifier

/**
* If the build server supports lazy classpath resolution, we will
* not get any classpath data eagerly and we should not
Expand Down
61 changes: 61 additions & 0 deletions metals/src/main/scala/scala/meta/internal/metals/ManifestJar.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package scala.meta.internal.metals

import java.nio.file.Files
import java.nio.file.Path
import java.util.jar.Attributes
import java.util.jar.JarOutputStream
import java.util.jar.Manifest

import scala.concurrent.ExecutionContext
import scala.util.Using

import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.mtags.URIEncoderDecoder
import scala.meta.internal.process.SystemProcess
import scala.meta.io.AbsolutePath

object ManifestJar {
def withTempManifestJar(
classpath: Seq[Path]
)(
op: AbsolutePath => SystemProcess
)(implicit ec: ExecutionContext): SystemProcess = {
val manifestJar =
createManifestJar(
AbsolutePath(
Files.createTempFile("jvm-forker-manifest", ".jar").toAbsolutePath
),
classpath,
)

val process = op(manifestJar)
process.complete.onComplete { case _ =>
manifestJar.delete()
}
process
}

def createManifestJar(
manifestJar: AbsolutePath,
classpath: Seq[Path],
): AbsolutePath = {
if (!manifestJar.exists) {
manifestJar.touch()
manifestJar.toNIO.toFile().deleteOnExit()
}

val classpathStr =
classpath
.map(path => URIEncoderDecoder.encode(path.toUri().toString()))
.mkString(" ")

val manifest = new Manifest()
manifest.getMainAttributes.put(Attributes.Name.MANIFEST_VERSION, "1.0")
manifest.getMainAttributes.put(Attributes.Name.CLASS_PATH, classpathStr)

val out = Files.newOutputStream(manifestJar.toNIO)
Using.resource(new JarOutputStream(out, manifest))(identity)
manifestJar
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,19 @@ object MetalsEnrichments
)
}

implicit class XtensionDebugSessionParams(params: b.DebugSessionParams) {
def asScalaMainClass(): Either[String, b.ScalaMainClass] =
params.getDataKind() match {
case b.DebugSessionParamsDataKind.SCALA_MAIN_CLASS =>
decodeJson(params.getData(), classOf[b.ScalaMainClass])
.toRight(s"Cannot decode $params as `ScalaMainClass`.")
case _ =>
Left(
s"Cannot decode params as `ScalaMainClass` incorrect data kind: ${params.getDataKind()}."
)
}
}

/**
* Strips ANSI colors.
* As long as the color codes are valid this should correctly strip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ final class TargetData {
javaTargetInfo.get(id)
def jvmTarget(id: BuildTargetIdentifier): Option[JvmTarget] =
scalaTarget(id).orElse(javaTarget(id))
def jvmTargets(id: BuildTargetIdentifier): List[JvmTarget] =
List(scalaTarget(id), javaTarget(id)).flatten

private val sourceBuildTargetsCache =
new util.concurrent.ConcurrentHashMap[AbsolutePath, Option[
Expand Down Expand Up @@ -163,7 +165,8 @@ final class TargetData {
}
} yield path

if (fromDepModules.isEmpty) jvmTarget(id).flatMap(_.jarClasspath)
if (fromDepModules.isEmpty)
jvmTargets(id).flatMap(_.jarClasspath).headOption
else Some(fromDepModules)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ final class RunTestCodeLens(
occurence: SymbolOccurrence,
textDocument: TextDocument,
target: BuildTargetIdentifier,
buildServerCanDebug: Boolean,
): Seq[l.Command] = {
if (occurence.symbol.endsWith("#main().")) {
textDocument.symbols
Expand All @@ -182,7 +181,6 @@ final class RunTestCodeLens(
Nil.asJava,
Nil.asJava,
),
buildServerCanDebug,
isJVM = true,
)
else
Expand Down Expand Up @@ -210,9 +208,9 @@ final class RunTestCodeLens(
commands = {
val main = classes.mainClasses
.get(symbol)
.map(mainCommand(target, _, buildServerCanDebug, isJVM))
.map(mainCommand(target, _, isJVM))
.getOrElse(Nil)
val tests =
lazy val tests =
// Currently tests can only be run via DAP
if (clientConfig.isDebuggingProvider() && buildServerCanDebug)
testClasses(target, classes, symbol, isJVM)
Expand All @@ -222,12 +220,12 @@ final class RunTestCodeLens(
.flatMap { symbol =>
classes.mainClasses
.get(symbol)
.map(mainCommand(target, _, buildServerCanDebug, isJVM))
.map(mainCommand(target, _, isJVM))
}
.getOrElse(Nil)
val javaMains =
if (path.isJava)
javaLenses(occurrence, textDocument, target, buildServerCanDebug)
javaLenses(occurrence, textDocument, target)
else Nil
main ++ tests ++ fromAnnot ++ javaMains
}
Expand Down Expand Up @@ -260,15 +258,15 @@ final class RunTestCodeLens(
val main =
classes.mainClasses
.get(expectedMainClass)
.map(mainCommand(target, _, buildServerCanDebug, isJVM))
.map(mainCommand(target, _, isJVM))
.getOrElse(Nil)

val fromAnnotations = textDocument.occurrences.flatMap { occ =>
for {
sym <- DebugProvider.mainFromAnnotation(occ, textDocument)
cls <- classes.mainClasses.get(sym)
range <- occurrenceRange(occ, distance)
} yield mainCommand(target, cls, buildServerCanDebug, isJVM).map { cmd =>
} yield mainCommand(target, cls, isJVM).map { cmd =>
new l.CodeLens(range, cmd, null)
}
}.flatten
Expand Down Expand Up @@ -325,7 +323,6 @@ final class RunTestCodeLens(
private def mainCommand(
target: b.BuildTargetIdentifier,
main: b.ScalaMainClass,
buildServerCanDebug: Boolean,
isJVM: Boolean,
): List[l.Command] = {
val javaBinary = buildTargets
Expand Down Expand Up @@ -353,7 +350,7 @@ final class RunTestCodeLens(
sessionParams(target, dataKind, data)
}

if (clientConfig.isDebuggingProvider() && buildServerCanDebug && isJVM)
if (clientConfig.isDebuggingProvider() && isJVM)
List(
command("run", StartRunSession, params),
command("debug", StartDebugSession, params),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import scala.collection.concurrent.TrieMap
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.concurrent.Promise
import scala.concurrent.duration.Duration
import scala.util.Failure
import scala.util.Success
import scala.util.Try
Expand Down Expand Up @@ -51,6 +52,11 @@ import scala.meta.internal.metals.clients.language.MetalsQuickPickParams
import scala.meta.internal.metals.clients.language.MetalsStatusParams
import scala.meta.internal.metals.config.RunType
import scala.meta.internal.metals.config.RunType._
import scala.meta.internal.metals.debug.server.DebugLogger
import scala.meta.internal.metals.debug.server.DebugeeParamsCreator
import scala.meta.internal.metals.debug.server.MainClassDebugAdapter
import scala.meta.internal.metals.debug.server.MetalsDebugToolsResolver
import scala.meta.internal.metals.debug.server.MetalsDebuggee
import scala.meta.internal.metals.testProvider.TestSuitesProvider
import scala.meta.internal.mtags.DefinitionAlternatives.GlobalSymbol
import scala.meta.internal.mtags.OnDemandSymbolIndex
Expand All @@ -65,6 +71,7 @@ import ch.epfl.scala.bsp4j.BuildTargetIdentifier
import ch.epfl.scala.bsp4j.DebugSessionParams
import ch.epfl.scala.bsp4j.ScalaMainClass
import ch.epfl.scala.{bsp4j => b}
import ch.epfl.scala.{debugadapter => dap}
import com.google.common.net.InetAddresses
import com.google.gson.JsonElement
import org.eclipse.lsp4j.MessageParams
Expand All @@ -91,11 +98,14 @@ class DebugProvider(
sourceMapper: SourceMapper,
userConfig: () => UserConfiguration,
testProvider: TestSuitesProvider,
) extends Cancelable
)(implicit ec: ExecutionContext)
extends Cancelable
with LogForwarder {

import DebugProvider._

private val debugConfigCreator = new DebugeeParamsCreator(buildTargets)

private val runningLocal = new ju.concurrent.atomic.AtomicBoolean(false)

private val debugSessions = new MutableCancelable()
Expand Down Expand Up @@ -251,13 +261,13 @@ class DebugProvider(
val targets = parameters.getTargets().asScala.toSeq

compilations.compilationFinished(targets).flatMap { _ =>
val conn = buildServer
.startDebugSession(parameters, cancelPromise)
.map { uri =>
val socket = connect(uri)
connectedToServer.trySuccess(())
socket
}
val conn =
startDebugSession(buildServer, parameters, cancelPromise)
.map { uri =>
val socket = connect(uri)
connectedToServer.trySuccess(())
socket
}

val startupTimeout = clientConfig.initialConfig.debugServerStartTimeout

Expand Down Expand Up @@ -314,6 +324,55 @@ class DebugProvider(
connectedToServer.future.map(_ => server)
}

private def startDebugSession(
buildServer: BuildServerConnection,
params: DebugSessionParams,
cancelPromise: Promise[Unit],
) =
if (buildServer.isDebuggingProvider || buildServer.isSbt) {
buildServer.startDebugSession(params, cancelPromise)
} else {
def getDebugee: Either[String, MetalsDebuggee] =
params.getDataKind() match {
case b.DebugSessionParamsDataKind.SCALA_MAIN_CLASS =>
for {
id <- params
.getTargets()
.asScala
.headOption
.toRight(s"Missing build target in debug params.")
projectInfo <- debugConfigCreator.create(id)
scalaMainClass <- params.asScalaMainClass()
} yield new MainClassDebugAdapter(
workspace,
scalaMainClass,
projectInfo,
userConfig().javaHome,
)
case kind =>
Left(s"Starting debug session for $kind in not supported.")
}

for {
_ <- compilations.compileTargets(params.getTargets().asScala.toSeq)
} yield {
val debuggee = getDebugee match {
case Right(debuggee) => debuggee
case Left(errorMessage) => throw new RuntimeException(errorMessage)
}
val dapLogger = new DebugLogger()
val resolver = new MetalsDebugToolsResolver()
val handler =
dap.DebugServer.run(
debuggee,
resolver,
dapLogger,
gracePeriod = Duration(5, TimeUnit.SECONDS),
)
handler.uri
}
}

/**
* Given a BuildTargetIdentifier either get the displayName of that build
* target or default to the full URI to display to the user.
Expand Down
Loading

0 comments on commit f116e13

Please sign in to comment.