diff --git a/build.sbt b/build.sbt index aad1f564ffc..82aefdc2b2c 100644 --- a/build.sbt +++ b/build.sbt @@ -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 @@ -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 (), ), ) diff --git a/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala b/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala index a70b00e99dd..98b1eeaa927 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala @@ -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], diff --git a/metals/src/main/scala/scala/meta/internal/metals/JvmTarget.scala b/metals/src/main/scala/scala/meta/internal/metals/JvmTarget.scala index 60746501f4d..36c1c19d871 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/JvmTarget.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/JvmTarget.scala @@ -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 diff --git a/metals/src/main/scala/scala/meta/internal/metals/ManifestJar.scala b/metals/src/main/scala/scala/meta/internal/metals/ManifestJar.scala new file mode 100644 index 00000000000..89b238222c3 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/ManifestJar.scala @@ -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 + } + +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala index 4f2d0386869..4062c6d3dc9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala @@ -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 diff --git a/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala b/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala index 5029720f789..b3ee4f757f5 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala @@ -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[ @@ -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) } diff --git a/metals/src/main/scala/scala/meta/internal/metals/codelenses/RunTestCodeLens.scala b/metals/src/main/scala/scala/meta/internal/metals/codelenses/RunTestCodeLens.scala index b8513241d28..f754325a0a2 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/codelenses/RunTestCodeLens.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/codelenses/RunTestCodeLens.scala @@ -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 @@ -182,7 +181,6 @@ final class RunTestCodeLens( Nil.asJava, Nil.asJava, ), - buildServerCanDebug, isJVM = true, ) else @@ -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) @@ -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 } @@ -260,7 +258,7 @@ 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 => @@ -268,7 +266,7 @@ final class RunTestCodeLens( 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 @@ -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 @@ -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), diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProvider.scala index eb1838e3f0e..060a35c7d50 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProvider.scala @@ -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 @@ -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 @@ -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 @@ -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() @@ -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 @@ -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. diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/ExtendedScalaMainClass.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/ExtendedScalaMainClass.scala index a5183fa5d24..4e187c4acab 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/ExtendedScalaMainClass.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/ExtendedScalaMainClass.scala @@ -1,18 +1,15 @@ package scala.meta.internal.metals.debug import java.io.File -import java.nio.file.Files import java.nio.file.Paths -import java.util.jar import scala.util.Properties import scala.util.Try -import scala.util.Using import scala.meta.internal.metals.Directories +import scala.meta.internal.metals.ManifestJar import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.mtags.MD5 -import scala.meta.internal.mtags.URIEncoderDecoder import scala.meta.io.AbsolutePath import ch.epfl.scala.bsp4j.ScalaMainClass @@ -87,7 +84,6 @@ object ExtendedScalaMainClass { classpath: List[String], workspace: AbsolutePath, ): String = { - val classpathDigest = MD5.compute(classpath.mkString) val manifestJar = workspace @@ -95,31 +91,9 @@ object ExtendedScalaMainClass { .resolve(s"classpath_${classpathDigest}.jar") if (!manifestJar.exists) { - - manifestJar.touch() - manifestJar.toNIO.toFile().deleteOnExit() - - val classpathStr = - classpath - .map(path => - URIEncoderDecoder.encode(Paths.get(path).toUri().toString()) - ) - .mkString(" ") - - val manifest = new jar.Manifest() - manifest.getMainAttributes.put( - jar.Attributes.Name.MANIFEST_VERSION, - "1.0", - ) - manifest.getMainAttributes.put( - jar.Attributes.Name.CLASS_PATH, - classpathStr, - ) - - val out = Files.newOutputStream(manifestJar.toNIO) - Using.resource(new jar.JarOutputStream(out, manifest))(identity) - + ManifestJar.createManifestJar(manifestJar, classpath.map(Paths.get(_))) } + manifestJar.toString() } diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugLogger.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugLogger.scala new file mode 100644 index 00000000000..4de462d7161 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugLogger.scala @@ -0,0 +1,18 @@ +package scala.meta.internal.metals.debug.server + +import ch.epfl.scala.debugadapter.Logger + +class DebugLogger extends Logger { + + override def debug(msg: => String): Unit = scribe.debug(msg) + + override def info(msg: => String): Unit = scribe.info(msg) + + override def warn(msg: => String): Unit = scribe.warn(msg) + + override def error(msg: => String): Unit = scribe.error(msg) + + override def trace(t: => Throwable): Unit = + scribe.trace(s"$t: ${t.getStackTrace().mkString("\n\t")}") + +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugeeParamsCreator.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugeeParamsCreator.scala new file mode 100644 index 00000000000..325b1fc8a66 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/server/DebugeeParamsCreator.scala @@ -0,0 +1,117 @@ +package scala.meta.internal.metals.debug.server + +import scala.meta.internal.metals.BuildTargets +import scala.meta.internal.metals.JvmTarget +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.ScalaTarget + +import ch.epfl.scala.bsp4j.BuildTargetIdentifier +import ch.epfl.scala.bsp4j.MavenDependencyModule +import ch.epfl.scala.debugadapter.Library +import ch.epfl.scala.debugadapter.Module +import ch.epfl.scala.debugadapter.ScalaVersion +import ch.epfl.scala.debugadapter.SourceDirectory +import ch.epfl.scala.debugadapter.SourceJar +import ch.epfl.scala.debugadapter.StandaloneSourceFile +import ch.epfl.scala.debugadapter.UnmanagedEntry + +class DebugeeParamsCreator(buildTargets: BuildTargets) { + def create(id: BuildTargetIdentifier): Either[String, DebugeeProject] = { + for { + target <- buildTargets + .jvmTarget(id) + .toRight(s"No build target $id found.") + data <- buildTargets + .targetData(id) + .toRight(s"No data for build target $id found.") + } yield { + + val libraries = data.buildTargetDependencyModules + .get(id) + .filter(_.nonEmpty) + .getOrElse(Nil) + val debugLibs = libraries.flatMap(createLibrary(_)) + val includedInLibs = debugLibs.map(_.absolutePath).toSet + + val classpath = buildTargets.targetJarClasspath(id).getOrElse(Nil) + + val filteredClassPath = classpath.collect { + case path if !includedInLibs(path.toNIO) => UnmanagedEntry(path.toNIO) + }.toList + + val modules = buildTargets + .allInverseDependencies(id) + .flatMap(buildTargets.jvmTarget) + .map(createModule(_)) + .toSeq + + val scalaVersion = buildTargets.scalaTarget(id).map(_.scalaVersion) + + new DebugeeProject( + scalaVersion, + target.displayName, + modules, + debugLibs, + filteredClassPath, + ) + } + } + + def createLibrary(lib: MavenDependencyModule): Option[Library] = { + def getWithClassifier(s: String) = + Option(lib.getArtifacts()) + .flatMap(_.asScala.find(_.getClassifier() == s)) + .flatMap(_.getUri().toAbsolutePathSafe) + for { + sources <- getWithClassifier("sources") + jar <- getWithClassifier(null) + } yield new Library( + lib.getName(), + lib.getVersion(), + jar.toNIO, + Seq(SourceJar(sources.toNIO)), + ) + } + + def createModule(target: JvmTarget): Module = { + val (scalaVersion, scalacOptions) = + target match { + case scalaTarget: ScalaTarget => + ( + Some(ScalaVersion(scalaTarget.scalaVersion)), + scalaTarget.scalac.getOptions().asScala.toSeq, + ) + case _ => (None, Nil) + } + new Module( + target.displayName, + scalaVersion, + scalacOptions, + target.classDirectory.toAbsolutePath.toNIO, + sources(target.id), + ) + } + + private def sources(id: BuildTargetIdentifier) = + buildTargets.sourceItemsToBuildTargets + .filter(_._2.iterator.asScala.contains(id)) + .collect { case (sourcePath, _) => + if (sourcePath.isDirectory) { + SourceDirectory(sourcePath.toNIO) + } else { + StandaloneSourceFile( + sourcePath.toNIO, + sourcePath.toNIO.getFileName.toString, + ) + } + } + .toSeq +} + +case class DebugeeProject( + scalaVersion: Option[String], + name: String, + modules: Seq[Module], + libraries: Seq[Library], + unmanagedEntries: Seq[UnmanagedEntry], +) diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/server/MainClassDebugAdapter.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MainClassDebugAdapter.scala new file mode 100644 index 00000000000..e3df1b4b406 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MainClassDebugAdapter.scala @@ -0,0 +1,135 @@ +package scala.meta.internal.metals.debug.server + +import java.io.File +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicBoolean + +import scala.concurrent.ExecutionContext + +import scala.meta.internal.metals.JavaBinary +import scala.meta.internal.metals.JdkSources +import scala.meta.internal.metals.ManifestJar +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.process.SystemProcess +import scala.meta.io.AbsolutePath + +import ch.epfl.scala.bsp4j.ScalaMainClass +import ch.epfl.scala.debugadapter.CancelableFuture +import ch.epfl.scala.debugadapter.DebuggeeListener +import ch.epfl.scala.debugadapter.JavaRuntime +import ch.epfl.scala.debugadapter.Library +import ch.epfl.scala.debugadapter.Module +import ch.epfl.scala.debugadapter.UnmanagedEntry + +class MainClassDebugAdapter( + root: AbsolutePath, + mainClass: ScalaMainClass, + project: DebugeeProject, + userJavaHome: Option[String], +)(implicit ec: ExecutionContext) + extends MetalsDebuggee() { + + private val initialized = new AtomicBoolean(false) + + override def modules: Seq[Module] = project.modules + + override def libraries: Seq[Library] = project.libraries + + override def unmanagedEntries: Seq[UnmanagedEntry] = project.unmanagedEntries + + private final val JDINotificationPrefix = + "Listening for transport dt_socket at address: " + + protected def scalaVersionOpt: Option[String] = project.scalaVersion + + val javaRuntime: Option[JavaRuntime] = + JdkSources + .defaultJavaHome(userJavaHome) + .flatMap(path => JavaRuntime(path.toNIO)) + .headOption + + def name: String = + s"${getClass.getSimpleName}(${project.name}, ${mainClass.getClassName()})" + def run(listener: DebuggeeListener): CancelableFuture[Unit] = { + val jvmOptions = + mainClass.getJvmOptions.asScala.toList :+ enableDebugInterface + val fullClasspathStr = + classPath.map(_.toString()).mkString(File.pathSeparator) + val java = JavaBinary(userJavaHome).toString() + val classpathOption = "-cp" :: fullClasspathStr :: Nil + val appOptions = + mainClass.getClassName :: mainClass.getArguments().asScala.toList + val cmd = java :: jvmOptions ::: classpathOption ::: appOptions + val cmdLength = cmd.foldLeft(0)(_ + _.length) + val envOptions = + mainClass + .getEnvironmentVariables() + .asScala + .flatMap { line => + val eqIdx = line.indexOf("=") + if (eqIdx > 0 && eqIdx != line.length - 1) { + val key = line.substring(0, eqIdx) + val value = line.substring(eqIdx + 1) + Some(key -> value) + } else None + } + .toMap + + def logError(errorMessage: String) = { + listener.err(errorMessage) + scribe.error(errorMessage) + } + + def logOutput(msg: String) = { + if (msg.startsWith(JDINotificationPrefix)) { + if (initialized.compareAndSet(false, true)) { + val port = Integer.parseInt(msg.drop(JDINotificationPrefix.length)) + val address = new InetSocketAddress("127.0.0.1", port) + listener.onListening(address) + } + } else { + listener.out(msg) + } + } + + // Note that we current only shorten the classpath portion and not other options + // Thus we do not yet *guarantee* that the command will not exceed OS limits + val process = + if (cmdLength <= SystemProcess.processCmdCharLimit) { + SystemProcess.run( + cmd, + root, + redirectErrorOutput = false, + envOptions, + processErr = Some(logError), + processOut = Some(logOutput), + ) + } else { + ManifestJar.withTempManifestJar(classPath) { manifestJar => + val shortClasspathOption = "-cp" :: manifestJar.syntax :: Nil + val shortCmd = + java :: jvmOptions ::: shortClasspathOption ::: appOptions + SystemProcess.run( + shortCmd, + root, + redirectErrorOutput = false, + envOptions, + processErr = Some(logError), + processOut = Some(logOutput), + ) + } + } + + new CancelableFuture[Unit] { + def future = process.complete.map { code => + if (code != 0) + throw new Exception(s"debuggee failed with error code $code") + } + def cancel(): Unit = process.cancel + } + } + + private def enableDebugInterface: String = { + s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,quiet=n" + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebugToolsResolver.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebugToolsResolver.scala new file mode 100644 index 00000000000..c478f006392 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebugToolsResolver.scala @@ -0,0 +1,64 @@ +package scala.meta.internal.metals.debug.server + +import java.net.URLClassLoader + +import scala.collection.mutable +import scala.util.Success +import scala.util.Try + +import scala.meta.internal.metals.Embedded + +import ch.epfl.scala.debugadapter.BuildInfo +import ch.epfl.scala.debugadapter.DebugToolsResolver +import ch.epfl.scala.debugadapter.ScalaVersion +import coursierapi.Dependency + +class MetalsDebugToolsResolver extends DebugToolsResolver { + override def resolveExpressionCompiler( + scalaVersion: ScalaVersion + ): Try[ClassLoader] = { + val module = s"${BuildInfo.expressionCompilerName}_$scalaVersion" + val dependency = + Dependency.of(BuildInfo.organization, module, BuildInfo.version) + getOrTryDownload( + MetalsDebugToolsResolver.expressionCompilerCache, + scalaVersion, + dependency, + ) + } + + override def resolveDecoder(scalaVersion: ScalaVersion): Try[ClassLoader] = { + val module = s"${BuildInfo.decoderName}_${scalaVersion.binaryVersion}" + val dependency = + Dependency.of(BuildInfo.organization, module, BuildInfo.version) + getOrTryDownload( + MetalsDebugToolsResolver.decoderCache, + scalaVersion, + dependency, + ) + } + + private def getOrTryDownload( + cache: mutable.Map[ScalaVersion, ClassLoader], + scalaVersion: ScalaVersion, + dependency: Dependency, + ): Try[ClassLoader] = { + if (cache.contains(scalaVersion)) Success(cache(scalaVersion)) + else + Try { + val downloaded = + Embedded.downloadDependency(dependency, Some(scalaVersion.value)) + val classLoader = + new URLClassLoader(downloaded.map(_.toUri.toURL).toArray, null) + cache.put(scalaVersion, classLoader) + classLoader + } + } +} + +object MetalsDebugToolsResolver { + private val expressionCompilerCache: mutable.Map[ScalaVersion, ClassLoader] = + mutable.Map.empty + private val decoderCache: mutable.Map[ScalaVersion, ClassLoader] = + mutable.Map.empty +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebuggee.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebuggee.scala new file mode 100644 index 00000000000..1f8a1f3b771 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/server/MetalsDebuggee.scala @@ -0,0 +1,23 @@ +package scala.meta.internal.metals.debug.server + +import java.io.Closeable + +import scala.meta.internal.BuildInfo + +import ch.epfl.scala.debugadapter.Debuggee +import ch.epfl.scala.debugadapter.ScalaVersion + +abstract class MetalsDebuggee extends Debuggee { + protected def scalaVersionOpt: Option[String] + override val scalaVersion: ScalaVersion = ScalaVersion( + scalaVersionOpt.getOrElse(BuildInfo.version) + ) + + override def observeClassUpdates( + onClassUpdate: Seq[String] => Unit + ): Closeable = { + // Hot code reload is not supported in Metals + () => {} + } + +} diff --git a/metals/src/main/scala/scala/meta/internal/process/SystemProcess.scala b/metals/src/main/scala/scala/meta/internal/process/SystemProcess.scala index 3381172467d..c61436a6e9e 100644 --- a/metals/src/main/scala/scala/meta/internal/process/SystemProcess.scala +++ b/metals/src/main/scala/scala/meta/internal/process/SystemProcess.scala @@ -22,6 +22,8 @@ trait SystemProcess { } object SystemProcess { + // Windows max cmd line length is 32767, which seems to be the least of the common shells. + val processCmdCharLimit = 30000 def run( cmd: List[String], diff --git a/tests/slow/src/test/scala/tests/mill/MillBreakpointDapSuite.scala b/tests/slow/src/test/scala/tests/mill/MillBreakpointDapSuite.scala new file mode 100644 index 00000000000..e39cb88e83d --- /dev/null +++ b/tests/slow/src/test/scala/tests/mill/MillBreakpointDapSuite.scala @@ -0,0 +1,12 @@ +package tests.mill + +import tests.MillBuildLayout +import tests.MillServerInitializer +import tests.debug.BaseBreakpointDapSuite + +class MillBreakpointDapSuite + extends BaseBreakpointDapSuite( + "mill-debug-breakpoint", + MillServerInitializer, + MillBuildLayout, + ) diff --git a/tests/slow/src/test/scala/tests/mill/MillServerCodeLensSuite.scala b/tests/slow/src/test/scala/tests/mill/MillServerCodeLensSuite.scala index db9a6b9a7a3..92dbd61cf88 100644 --- a/tests/slow/src/test/scala/tests/mill/MillServerCodeLensSuite.scala +++ b/tests/slow/src/test/scala/tests/mill/MillServerCodeLensSuite.scala @@ -18,7 +18,7 @@ class MillServerCodeLensSuite cleanWorkspace() writeLayout( MillBuildLayout( - """|/MillMinimal/src/Main.scala + """|/a/src/Main.scala |package foo | |object Main { @@ -26,7 +26,7 @@ class MillServerCodeLensSuite | println("Hello java!") | } |} - |/MillMinimal/test/src/Foo.scala + |/a/test/src/Foo.scala |// no test lense as debug is not supported |class Foo extends munit.FunSuite {} |""".stripMargin, @@ -40,14 +40,14 @@ class MillServerCodeLensSuite _ <- server.initialize() _ <- server.initialized() _ <- server.executeCommand(ServerCommands.GenerateBspConfig) - _ <- server.didOpen("MillMinimal/src/Main.scala") - _ <- server.didSave("MillMinimal/src/Main.scala")(identity) + _ <- server.didOpen("a/src/Main.scala") + _ <- server.didSave("a/src/Main.scala")(identity) _ = assertNoDiagnostics() _ <- assertCodeLenses( - "MillMinimal/src/Main.scala", + "a/src/Main.scala", """|package foo | - |<> + |<><> |object Main { | def main(args: Array[String]): Unit = { | println("Hello java!") @@ -55,12 +55,12 @@ class MillServerCodeLensSuite |}""".stripMargin, ) _ <- assertCodeLenses( - "MillMinimal/test/src/Foo.scala", + "a/test/src/Foo.scala", """|// no test lense as debug is not supported |class Foo extends munit.FunSuite {} |""".stripMargin, ) - lenses <- server.codeLenses("MillMinimal/src/Main.scala") + lenses <- server.codeLenses("a/src/Main.scala") _ = assert(lenses.size > 0, "No lenses were generated!") command = lenses.head.getCommand() _ = assertEquals(runFromCommand(command, None), Some("Hello java!")) diff --git a/tests/slow/src/test/scala/tests/mill/MillStepDapSuite.scala b/tests/slow/src/test/scala/tests/mill/MillStepDapSuite.scala new file mode 100644 index 00000000000..9c6c3b84eff --- /dev/null +++ b/tests/slow/src/test/scala/tests/mill/MillStepDapSuite.scala @@ -0,0 +1,19 @@ +package tests.mill + +import scala.meta.internal.metals.BuildInfo + +import tests.MillBuildLayout +import tests.MillServerInitializer +import tests.debug.BaseStepDapSuite + +class MillStepDapSuite + extends BaseStepDapSuite( + "mill-debug-step", + MillServerInitializer, + MillBuildLayout, + ) { + + // otherwise we get both Scala 2.12 and 2.13 dependencies, which is more tricky for the tests + override def scalaVersion: String = BuildInfo.scala212 + +} diff --git a/tests/unit/src/main/scala/tests/BuildServerLayout.scala b/tests/unit/src/main/scala/tests/BuildServerLayout.scala index a91187045fb..cdf2a1c011b 100644 --- a/tests/unit/src/main/scala/tests/BuildServerLayout.scala +++ b/tests/unit/src/main/scala/tests/BuildServerLayout.scala @@ -71,7 +71,7 @@ object MillBuildLayout extends BuildToolLayout { s"""|/build.sc |import mill._, scalalib._ | - |object MillMinimal extends ScalaModule { + |object a extends ScalaModule { | def scalaVersion = "${scalaVersion}" | $munitModule |} diff --git a/tests/unit/src/main/scala/tests/debug/BaseStepDapSuite.scala b/tests/unit/src/main/scala/tests/debug/BaseStepDapSuite.scala index 1060b6d9709..ba780498034 100644 --- a/tests/unit/src/main/scala/tests/debug/BaseStepDapSuite.scala +++ b/tests/unit/src/main/scala/tests/debug/BaseStepDapSuite.scala @@ -92,52 +92,54 @@ abstract class BaseStepDapSuite( focusFile = "a/src/main/scala/a/ScalaMain.scala", ) - assertSteps("step-into-scala-lib", withoutVirtualDocs = true)( - sources = """|/a/src/main/scala/Main.scala - |package a - | - |object Main { - | def main(args: Array[String]): Unit = { - |>> println("foo") - | System.exit(0) - | } - |} - |""".stripMargin, - main = "a.Main", - instrument = steps => { - steps - .at("a/src/main/scala/Main.scala", line = 5)(StepIn) - .atDependency( - server.toPathFromSymbol("scala.Predef", "scala/Predef.scala"), - line = if (scalaVersion.startsWith("2.13")) 427 else 405, - )(Continue) - }, - ) + if (suiteName != "mill-debug-step") { // TODO: delete condition after https://github.com/com-lihaoyi/mill/issues/3148 + assertSteps("step-into-scala-lib", withoutVirtualDocs = true)( + sources = """|/a/src/main/scala/Main.scala + |package a + | + |object Main { + | def main(args: Array[String]): Unit = { + |>> println("foo") + | System.exit(0) + | } + |} + |""".stripMargin, + main = "a.Main", + instrument = steps => { + steps + .at("a/src/main/scala/Main.scala", line = 5)(StepIn) + .atDependency( + server.toPathFromSymbol("scala.Predef", "scala/Predef.scala"), + line = if (scalaVersion.startsWith("2.13")) 427 else 405, + )(Continue) + }, + ) - assertSteps("step-into-java-lib", withoutVirtualDocs = true)( - sources = """|/a/src/main/scala/Main.scala - |package a - | - |object Main { - | def main(args: Array[String]): Unit = { - |>> System.out.println("foo") - | System.exit(0) - | } - |} - |""".stripMargin, - main = "a.Main", - instrument = steps => { - val (javaLibFile, javaLibLine) = - if (isJava17) ("java.base/java/io/PrintStream.java", 1027) - else ("java.base/java/io/PrintStream.java", 881) - steps - .at("a/src/main/scala/Main.scala", line = 5)(StepIn) - .atDependency( - server.toPathFromSymbol("java.io.PrintStream", javaLibFile), - javaLibLine, - )(Continue) - }, - ) + assertSteps("step-into-java-lib", withoutVirtualDocs = true)( + sources = """|/a/src/main/scala/Main.scala + |package a + | + |object Main { + | def main(args: Array[String]): Unit = { + |>> System.out.println("foo") + | System.exit(0) + | } + |} + |""".stripMargin, + main = "a.Main", + instrument = steps => { + val (javaLibFile, javaLibLine) = + if (isJava17) ("java.base/java/io/PrintStream.java", 1027) + else ("java.base/java/io/PrintStream.java", 881) + steps + .at("a/src/main/scala/Main.scala", line = 5)(StepIn) + .atDependency( + server.toPathFromSymbol("java.io.PrintStream", javaLibFile), + javaLibLine, + )(Continue) + }, + ) + } assertSteps("stops-on-different-class-in-same-file")( sources = """|/a/src/main/scala/a/Main.scala @@ -178,6 +180,7 @@ abstract class BaseStepDapSuite( for { _ <- initialize(workspaceLayout) + _ <- server.server.indexingPromise.future _ <- server.didFocus(focusFile) navigator = instrument(StepNavigator(workspace)) debugger <- debugMain("a", main, navigator)