diff --git a/docs/integrations/new-editor.md b/docs/integrations/new-editor.md index a7d7c016779..8e79807bd37 100644 --- a/docs/integrations/new-editor.md +++ b/docs/integrations/new-editor.md @@ -122,6 +122,7 @@ The currently available settings for `InitializationOptions` are listed below. icons?: "vscode" | "octicons" | "atom" | "unicode"; inputBoxProvider?: boolean; isVirtualDocumentSupported?: boolean; + isLibraryFileSystemSupported?: boolean; isExitOnShutdown?: boolean; isHttpEnabled?: boolean; openFilesOnRenameProvider?: boolean; @@ -327,6 +328,25 @@ Possible values: Metals tries to fallback to `window/showMessageRequest` when possible. - `on`: the `metals/inputBox` request is fully supported. +##### `isVirtualDocumentSupported` + +Possible values: + +- `off` (default): virtual documents are not supported. In this case, Metals + saves generated files to disk e.g. decompiled class files or source jar files. +- `on`: virtual documents are supported and Metals sends the content of the file + to the client rather than a URI reference to it. + It's up to the client to display that content as though it were a file. + +##### `isLibraryFileSystemSupported` + +Possible values: + +- `off` (default): library file system is not supported. +- `on`: library file system is supported. Metals sends the + `metals-library-filesystem-ready` command when the libraries have been registered + and the library filesystem is ready to navigate. + ##### `isExitOnShutdown` Possible values: diff --git a/metals-bench/src/main/scala/bench/ServerInitializeBench.scala b/metals-bench/src/main/scala/bench/ServerInitializeBench.scala index fcadeb164d6..34a7208e1b4 100644 --- a/metals-bench/src/main/scala/bench/ServerInitializeBench.scala +++ b/metals-bench/src/main/scala/bench/ServerInitializeBench.scala @@ -52,7 +52,7 @@ class ServerInitializeBench { def run(): Unit = { val path = AbsolutePath(workspace) val buffers = Buffers() - val client = new TestingClient(path, buffers) + val client = new TestingClient(path, () => null, buffers) MetalsLogger.updateDefaultFormat() val ec = ExecutionContext.fromExecutorService(ex) val server = new MetalsLanguageServer(ec, sh = sh) diff --git a/metals/src/main/scala/scala/meta/internal/metals/BuildTargetInfo.scala b/metals/src/main/scala/scala/meta/internal/metals/BuildTargetInfo.scala index 5378c6d6939..377cc973382 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/BuildTargetInfo.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/BuildTargetInfo.scala @@ -1,5 +1,6 @@ package scala.meta.internal.metals +import java.nio.file.Files import java.nio.file.Path import scala.collection.mutable.ListBuffer @@ -154,7 +155,7 @@ class BuildTargetInfo(buildTargets: BuildTargets) { val sourceJarName = jarName.replace(".jar", "-sources.jar") buildTargets .sourceJarFile(sourceJarName) - .exists(_.toFile.exists()) + .exists(path => path.exists) } private def getSingleClassPathInfo( @@ -164,7 +165,7 @@ class BuildTargetInfo(buildTargets: BuildTargets) { ): String = { val filename = shortPath.toString() val padding = " " * (maxFilenameSize - filename.size) - val status = if (path.toFile.exists) { + val status = if (Files.exists(path)) { val blankWarning = " " * 9 if (path.toFile().isDirectory() || jarHasSource(filename)) blankWarning 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 51ac915fe97..ba3a366a5bb 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/BuildTargets.scala @@ -93,6 +93,9 @@ final class BuildTargets() { def allJava: Iterator[JavaTarget] = data.fromIterators(_.allJava) + def allJDKs: Iterator[String] = + data.fromIterators(_.allJDKs).distinct + def info(id: BuildTargetIdentifier): Option[BuildTarget] = data.fromOptions(_.info(id)) diff --git a/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala b/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala index 50e51d4f818..38415c03bd7 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala @@ -249,6 +249,13 @@ object ClientCommands { |""".stripMargin, ) + val LibraryFileSystemReady = new Command( + "metals-library-filesystem-ready", + "Library FS ready", + """|Notifies the client that the library filesystem is ready to be navigated. + |""".stripMargin, + ) + val RefreshModel = new Command( "metals-model-refresh", "Refresh model", @@ -333,6 +340,7 @@ object ClientCommands { FocusDiagnostics, GotoLocation, EchoCommand, + LibraryFileSystemReady, RefreshModel, ShowStacktrace, CopyWorksheetOutput, diff --git a/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala b/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala index f9bddaaefef..7c8fce14d45 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala @@ -57,6 +57,9 @@ case class ClientConfiguration(initialConfig: MetalsServerConfig) { def isVirtualDocumentSupported(): Boolean = initializationOptions.isVirtualDocumentSupported.getOrElse(false) + def isLibraryFileSystemSupported(): Boolean = + initializationOptions.isLibraryFileSystemSupported.getOrElse(false) + def icons(): Icons = initializationOptions.icons .map(Icons.fromString) diff --git a/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala index 08cb9807200..1c8e3ebcc83 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala @@ -9,8 +9,10 @@ import java.nio.charset.StandardCharsets import javax.annotation.Nullable import scala.annotation.tailrec +import scala.concurrent.Await import scala.concurrent.ExecutionContext import scala.concurrent.Future +import scala.concurrent.duration.Duration import scala.util.Failure import scala.util.Properties import scala.util.Success @@ -73,6 +75,9 @@ object DecoderResponse { def failed(uri: String, e: Throwable): DecoderResponse = failed(uri.toString(), getAllMessages(e)) + def failed(uri: URI, errorMsg: String, e: Throwable): DecoderResponse = + failed(uri, s"$errorMsg\n${getAllMessages(e)}") + def failed(uri: URI, e: Throwable): DecoderResponse = failed(uri, getAllMessages(e)) @@ -84,6 +89,7 @@ final class FileDecoderProvider( workspace: AbsolutePath, compilers: Compilers, buildTargets: BuildTargets, + uriMapper: URIMapper, userConfig: () => UserConfiguration, shellRunner: ShellRunner, fileSystemSemanticdbs: FileSystemSemanticdbs, @@ -126,6 +132,9 @@ final class FileDecoderProvider( * metalsDecode:file:///somePath/someFile.scala.cfr * metalsDecode:file:///somePath/someFile.class.cfr * + * Auto-CFR: + * metalsfs:/metalslibraries/jar/someFile.jar/somePackage/someFile.class + * * semanticdb: * metalsDecode:file:///somePath/someFile.java.semanticdb-compact * metalsDecode:file:///somePath/someFile.java.semanticdb-detailed @@ -145,6 +154,7 @@ final class FileDecoderProvider( * * jar: * metalsDecode:jar:file:///somePath/someFile-sources.jar!/somePackage/someFile.java + * jar:file:///somePath/someFile-sources.jar!/somePackage/someFile.java.semanticdb-compact * * build target: * metalsDecode:file:///workspacePath/buildTargetName.metals-buildtarget @@ -162,11 +172,13 @@ final class FileDecoderProvider( case "file" => decodeMetalsFile(uri) case "metalsDecode" => decodedFileContents(uri.getSchemeSpecificPart()) + case "metalsfs" => + decodedFileContents(uriMapper.convertToLocal(uriAsStr)) case _ => Future.successful( DecoderResponse.failed( uri, - s"Unexpected scheme ${uri.getScheme()}", + s"Unexpected scheme ${uri.getScheme()} in $uri", ) ) } @@ -246,7 +258,10 @@ final class FileDecoderProvider( ) case _ => Future.successful( - DecoderResponse.failed(uri, "Unsupported extension") + DecoderResponse.failed( + uri, + s"Unsupported extension $additionalExtension in $uri", + ) ) } } @@ -304,7 +319,7 @@ final class FileDecoderProvider( path: AbsolutePath, format: Format, ): DecoderResponse = { - if (path.isScalaOrJava) + if (path.isScalaOrJava || path.isClassfile) interactiveSemanticdbs .textDocument(path) .documentIncludingStale @@ -316,7 +331,8 @@ final class FileDecoderProvider( .fold(identity, identity) ) else if (path.isSemanticdb) decodeFromSemanticDBFile(path, format) - else DecoderResponse.failed(path.toURI, "Unsupported extension") + else + DecoderResponse.failed(path.toURI, s"Unsupported extension ${path.toURI}") } private def isScala3(path: AbsolutePath): Boolean = { @@ -340,7 +356,7 @@ final class FileDecoderProvider( ) ) } else if (path.isTasty) { - findPathInfoForClassesPathFile(path) match { + findPathInfoForTastyPathFile(path) match { case Some(pathInfo) => decodeFromTastyFile(pathInfo) case None => Future.successful( @@ -370,15 +386,25 @@ final class FileDecoderProvider( PathInfo(metadata.targetId, metadata.classDir.resolve(relativePath)) }) - private def findPathInfoForClassesPathFile( + private def findPathInfoForTastyPathFile( path: AbsolutePath ): Option[PathInfo] = { - val pathInfos = for { - targetId <- buildTargets.allBuildTargetIds - classDir <- buildTargets.targetClassDirectories(targetId) - classPath = classDir.toAbsolutePath - if (path.isInside(classPath)) - } yield PathInfo(targetId, path) + val pathInfos = if (path.isJarFileSystem) { + // should only ever exist in workspace jars + for { + targetId <- buildTargets.allBuildTargetIds + targetJars <- buildTargets.targetJarClasspath(targetId) + jarPath <- path.jarPath + if (targetJars.contains(jarPath)) + } yield PathInfo(targetId, path) + } else { + for { + targetId <- buildTargets.allBuildTargetIds + classDir <- buildTargets.targetClassDirectories(targetId) + classPath = classDir.toAbsolutePath + if (path.isInside(classPath)) + } yield PathInfo(targetId, path) + } pathInfos.toList.headOption } @@ -511,17 +537,27 @@ final class FileDecoderProvider( verbose: Boolean )(path: AbsolutePath): Future[DecoderResponse] = { try { - val defaultArgs = List("-private") - val args = if (verbose) "-verbose" :: defaultArgs else defaultArgs val sbOut = new StringBuilder() val sbErr = new StringBuilder() + val (classpath, parent, filename) = if (path.isJarFileSystem) { + val parent = workspace + val className = path.toString.stripPrefix("/").stripSuffix(".class") + ( + path.jarPath.toList.flatMap(cp => List("-cp", cp.toString)), + parent, + className, + ) + } else + (List.empty, path.parent, path.filename) + val defaultArgs = classpath ::: List("-private") + val args = if (verbose) "-verbose" :: defaultArgs else defaultArgs shellRunner .run( "Decode using javap", JavaBinary(userConfig().javaHome, "javap") :: args ::: List( - path.filename + filename ), - path.parent, + parent, redirectErrorOutput = false, Map.empty, s => { @@ -543,30 +579,45 @@ final class FileDecoderProvider( }) } catch { case NonFatal(e) => - scribe.error(e.toString()) + scribe.error(s"$e ${e.getStackTrace.mkString("\n at ")}") Future.successful(DecoderResponse.failed(path.toURI, e)) } } - private def decodeCFRFromClassFile( + def decodeCFRAndWait(path: AbsolutePath): DecoderResponse = { + val result = decodeCFRFromClassFile(path) + Await.result(result, Duration("10min")) + } + + def decodeCFRFromClassFile( path: AbsolutePath ): Future[DecoderResponse] = { val cfrDependency = Dependency.of("org.benf", "cfr", "0.151") val cfrMain = "org.benf.cfr.reader.Main" + // find the build target so we can use the full classpath - needed for a better decompile + // class file can be in classes output dir or could be in a jar on the build's classpath val buildTarget = buildTargets .inferBuildTarget(path) .orElse( buildTargets.allScala .find(buildTarget => - path.isInside(buildTarget.classDirectory.toAbsolutePath) + path.isInside( + buildTarget.classDirectory.toAbsolutePath + ) || path.jarPath + .map(jar => buildTarget.fullClasspath.contains(jar.toNIO)) + .getOrElse(false) ) .map(_.id) ) .orElse( buildTargets.allJava .find(buildTarget => - path.isInside(buildTarget.classDirectory.toAbsolutePath) + path.isInside( + buildTarget.classDirectory.toAbsolutePath + ) || path.jarPath + .map(jar => buildTarget.fullClasspath.contains(jar.toNIO)) + .getOrElse(false) ) .map(_.id) ) @@ -597,9 +648,16 @@ final class FileDecoderProvider( (classesPath, className) }) .getOrElse({ - val parent = path.parent - val className = path.filename - (parent, className) + if (path.isJarFileSystem) { + // if the file is in a jar then use the workspace as the working dir and the fully qualified name as the class + val parent = workspace + val className = path.toString.stripPrefix("/") + (parent, className) + } else { + val parent = path.parent + val className = path.filename + (parent, className) + } }) val args = extraClassPath ::: @@ -637,7 +695,7 @@ final class FileDecoderProvider( if (sbOut.isEmpty && sbErr.nonEmpty) DecoderResponse.failed( path.toURI, - s"$cfrDependency\n$cfrMain\n$parent\n$args\n${sbErr.toString}", + s"buildTarget:$buildTarget\nCFRJar:$cfrDependency\nCFRMain:$cfrMain\nParent:$parent\nArgs:$args\nError:${sbErr.toString}", ) else DecoderResponse.success(path.toURI, sbOut.toString) @@ -645,7 +703,13 @@ final class FileDecoderProvider( } catch { case NonFatal(e) => scribe.error(e.toString()) - Future.successful(DecoderResponse.failed(path.toURI, e)) + Future.successful( + DecoderResponse.failed( + path.toURI, + s"buildTarget:$buildTarget\nCFRJar:$cfrDependency\nCFRMain:$cfrMain\nParent:$parent\nArgs:$args\nError:${sbErr.toString}", + e, + ) + ) } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala b/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala index 4728537fde2..3c2666020d1 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala @@ -43,6 +43,7 @@ import ch.epfl.scala.{bsp4j => b} final case class Indexer( workspaceReload: () => WorkspaceReload, doctor: () => Doctor, + lspFileSystemProvider: () => LSPFileSystemProvider, languageClient: DelegatingLanguageClient, bspSession: () => Option[BspSession], executionContext: ExecutionContextExecutorService, @@ -57,6 +58,7 @@ final case class Indexer( referencesProvider: () => ReferenceProvider, workspaceSymbols: () => WorkspaceSymbolProvider, buildTargets: BuildTargets, + uriMapper: URIMapper, interactiveSemanticdbs: () => InteractiveSemanticdbs, buildClient: () => ForwardingMetalsBuildClient, semanticDBIndexer: () => SemanticdbIndexer, @@ -180,6 +182,7 @@ final case class Indexer( treeView().reset() worksheetProvider().reset() symbolSearch().reset() + uriMapper.reset() } val allBuildTargetsData = buildData() for (buildTool <- allBuildTargetsData) @@ -200,6 +203,22 @@ final case class Indexer( data.addSourceItem(source, item.getTarget) } } + // add jars/jdks to metalsfs filesystem and notify client + for (buildTool <- allBuildTargetsData) { + for { + item <- buildTool.importedBuild.dependencySources.getItems.asScala + sourceUri <- Option(item.getSources).toList.flatMap(_.asScala) + } { + uriMapper.addSourceJar(sourceUri.toAbsolutePath) + } + buildTool.data.allWorkspaceJars.foreach(uriMapper.addWorkspaceJar) + } + JdkSources(userConfig().javaHome) match { + case Right(zip) => uriMapper.addJDK(zip) + case _ => {} + } + lspFileSystemProvider().sendLibraryFileSystemReady() + timerProvider.timedThunk( "post update build targets stuff", clientConfig.initialConfig.statistics.isIndex, @@ -257,6 +276,7 @@ final case class Indexer( buildTool.importedBuild.dependencySources, ) } + // Schedule removal of unused toplevel symbols from cache if (usedJars.nonEmpty) sh.schedule( diff --git a/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala b/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala index 0e595232e2e..0921b0ab372 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala @@ -33,8 +33,10 @@ import org.eclipse.{lsp4j => l} * @param isExitOnShutdown whether the client needs Metals to shut down manually on exit. * @param isHttpEnabled whether the client needs Metals to start an HTTP client interface. * @param isVirtualDocumentSupported whether the client supports VirtualDocuments. - * For opening source jars in read-only + * For showing decompiled class files, tasty files and + * showing jar files * `* https://code.visualstudio.com/api/extension-guides/virtual-documents + * @param isLibraryFileSystemSupported whether client supports the library filesystem * @param openFilesOnRenameProvider whether or not the client supports opening files on rename. * @param quickPickProvider if the client implements `metals/quickPick`. * @param renameFileThreshold amount of files that should be opened during rename if client @@ -65,6 +67,7 @@ final case class InitializationOptions( isHttpEnabled: Option[Boolean], commandInHtmlFormat: Option[CommandHTMLFormat], isVirtualDocumentSupported: Option[Boolean], + isLibraryFileSystemSupported: Option[Boolean], openFilesOnRenameProvider: Option[Boolean], quickPickProvider: Option[Boolean], renameFileThreshold: Option[Int], @@ -113,6 +116,7 @@ object InitializationOptions { None, None, None, + None, ) def from( @@ -155,6 +159,8 @@ object InitializationOptions { .flatMap(CommandHTMLFormat.fromString), isVirtualDocumentSupported = jsonObj.getBooleanOption("isVirtualDocumentSupported"), + isLibraryFileSystemSupported = + jsonObj.getBooleanOption("isLibraryFileSystemSupported"), openFilesOnRenameProvider = jsonObj.getBooleanOption("openFilesOnRenameProvider"), quickPickProvider = jsonObj.getBooleanOption("quickPickProvider"), diff --git a/metals/src/main/scala/scala/meta/internal/metals/InteractiveSemanticdbs.scala b/metals/src/main/scala/scala/meta/internal/metals/InteractiveSemanticdbs.scala index 840f6621ff1..b4671feb87f 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/InteractiveSemanticdbs.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/InteractiveSemanticdbs.scala @@ -34,6 +34,7 @@ import org.eclipse.{lsp4j => l} final class InteractiveSemanticdbs( workspace: AbsolutePath, buildTargets: BuildTargets, + uriMapper: URIMapper, charset: Charset, client: MetalsLanguageClient, tables: Tables, @@ -79,7 +80,8 @@ final class InteractiveSemanticdbs( } // anything aside from `*.scala`, `*.sbt`, `*.sc`, `*.java` file - def isExcludedFile = !source.isScalaFilename && !source.isJavaFilename + def isExcludedFile = + !source.isScalaFilename && !source.isJavaFilename && !source.isClassfile if (isExcludedFile || !shouldTryCalculateInteractiveSemanticdb) { TextDocumentLookup.NotFound(source) @@ -87,18 +89,22 @@ final class InteractiveSemanticdbs( val result = textDocumentCache.compute( source, (path, existingDoc) => { - val text = unsavedContents.getOrElse(FileIO.slurp(source, charset)) - val sha = MD5.compute(text) - if (existingDoc == null || existingDoc.md5 != sha) { - Try(compile(path, text)) match { - case Success(doc) if doc != null => - if (!source.isDependencySource(workspace)) - semanticdbIndexer().onChange(source, doc) - doc - case _ => null - } - } else + if (existingDoc != null && source.isJarFileSystem) existingDoc + else { + val text = unsavedContents.getOrElse(FileIO.slurp(source, charset)) + val sha = MD5.compute(text) + if (existingDoc == null || existingDoc.md5 != sha) { + Try(compile(path, text)) match { + case Success(doc) if doc != null => + if (!source.isDependencySource(workspace)) + semanticdbIndexer().onChange(source, doc) + doc + case _ => null + } + } else + existingDoc + } }, ) TextDocumentLookup.fromOption(source, Option(result)) @@ -130,8 +136,9 @@ final class InteractiveSemanticdbs( */ def didFocus(path: AbsolutePath): Unit = { activeDocument.get().foreach { uri => + val metalsURI = uriMapper.convertToMetalsFS(uri) client.publishDiagnostics( - new PublishDiagnosticsParams(uri, Collections.emptyList()) + new PublishDiagnosticsParams(metalsURI, Collections.emptyList()) ) } if (path.isDependencySource(workspace)) { @@ -150,8 +157,9 @@ final class InteractiveSemanticdbs( } if (diagnostics.nonEmpty) { statusBar.addMessage(partialNavigation(clientConfig.icons)) + val metalsURI = uriMapper.convertToMetalsFS(uri) client.publishDiagnostics( - new PublishDiagnosticsParams(uri, diagnostics.asJava) + new PublishDiagnosticsParams(metalsURI, diagnostics.asJava) ) } } @@ -161,7 +169,7 @@ final class InteractiveSemanticdbs( } private def compile(source: AbsolutePath, text: String): s.TextDocument = { - if (source.isJavaFilename) + if (source.isJavaFilename || source.isClassfile) javaInteractiveSemanticdb.fold(s.TextDocument())( _.textDocument(source, text) ) diff --git a/metals/src/main/scala/scala/meta/internal/metals/JavaInteractiveSemanticdb.scala b/metals/src/main/scala/scala/meta/internal/metals/JavaInteractiveSemanticdb.scala index 91b5cf2f127..ff8f89b2319 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/JavaInteractiveSemanticdb.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/JavaInteractiveSemanticdb.scala @@ -33,92 +33,131 @@ class JavaInteractiveSemanticdb( private val readonly = workspace.resolve(Directories.readonly) - def textDocument(source: AbsolutePath, text: String): s.TextDocument = { - val workDir = AbsolutePath( - Files.createTempDirectory("metals-javac-semanticdb") - ) - val targetRoot = workDir.resolve("target") - Files.createDirectory(targetRoot.toNIO) - - val localSource = - if (source.isLocalFileSystem(workspace)) { - source + def textDocument(source: AbsolutePath, text: String): s.TextDocument = + if ( + source.filename == "module-info.java" || source.filename == "module-info.class" + ) { + // can't run semanticdb on module-info files + s.TextDocument( + uri = source.toURI.toString(), + text = text, + md5 = MD5.compute(text), + ) + } else { + val workDir = AbsolutePath( + Files.createTempDirectory("metals-javac-semanticdb") + ) + val targetRoot = workDir.resolve("target") + Files.createDirectory(targetRoot.toNIO) + + val localSource = + if (source.isLocalFileSystem(workspace) && source.isJavaFilename) { + source + } else { + val sourceRoot = workDir.resolve("source") + Files.createDirectory(sourceRoot.toNIO) + // cope with this file being a de-compiled class file. Java won't compile a source file with a `.class` ext + val localSource = + sourceRoot.resolve( + s"${source.filename.stripSuffix(".class").stripSuffix(".java")}.java" + ) + + Files.write(localSource.toNIO, text.getBytes) + localSource + } + + val sourceRoot = localSource.parent + val buildTarget = buildTargets + .inferBuildTarget(source) + .orElse( + buildTargets.allScala + .find(buildTarget => + source.isInside( + buildTarget.classDirectory.toAbsolutePath + ) || source.jarPath + .map(jar => buildTarget.fullClasspath.contains(jar.toNIO)) + .getOrElse(false) + ) + .map(_.id) + ) + .orElse( + buildTargets.allJava + .find(buildTarget => + source.isInside( + buildTarget.classDirectory.toAbsolutePath + ) || source.jarPath + .map(jar => buildTarget.fullClasspath.contains(jar.toNIO)) + .getOrElse(false) + ) + .map(_.id) + ) + val targetClasspath = buildTarget + .flatMap(buildTargets.targetJarClasspath) + .getOrElse(Nil) + .map(_.toString) + + val jigsawOptions = + addExportsFlags ++ patchModuleFlags(localSource, sourceRoot, source) + val mainOptions = + List( + javac.toString, + "-cp", + (pluginJars ++ targetClasspath).mkString(File.pathSeparator), + "-d", + targetRoot.toString, + ) + val pluginOption = + s"-Xplugin:semanticdb -sourceroot:${sourceRoot} -targetroot:${targetRoot}" + val cmd = + mainOptions ::: jigsawOptions ::: pluginOption :: localSource.toString :: Nil + + val stdout = List.newBuilder[String] + val ps = SystemProcess.run( + cmd, + workDir, + false, + Map.empty, + Some(outLine => stdout += outLine), + Some(errLine => stdout += errLine), + ) + + val future = ps.complete.recover { case NonFatal(e) => + scribe.error(s"Running javac-semanticdb failed for $localSource", e) + 1 + } + + val exitCode = Await.result(future, 10.seconds) + val semanticdbFile = + targetRoot + .resolve("META-INF") + .resolve("semanticdb") + .resolve(s"${localSource.filename}.semanticdb") + + val doc = if (semanticdbFile.exists) { + FileIO + .readAllDocuments(semanticdbFile) + .headOption + .getOrElse(s.TextDocument()) } else { - val sourceRoot = workDir.resolve("source") - Files.createDirectory(sourceRoot.toNIO) - val localSource = sourceRoot.resolve(source.filename) - Files.write(localSource.toNIO, text.getBytes) - localSource + val log = stdout.result() + if (exitCode != 0 || log.nonEmpty) + scribe.warn( + s"Running javac-semanticdb failed for ${source.toURI}\n${cmd + .mkString(" ")}\nOutput:\n${log.mkString("\n")}" + ) + s.TextDocument() } - val sourceRoot = localSource.parent - val targetClasspath = buildTargets - .inferBuildTarget(source) - .flatMap(buildTargets.targetJarClasspath) - .getOrElse(Nil) - .map(_.toString) - - val jigsawOptions = - addExportsFlags ++ patchModuleFlags(localSource, sourceRoot, source) - val mainOptions = - List( - javac.toString, - "-cp", - (pluginJars ++ targetClasspath).mkString(File.pathSeparator), - "-d", - targetRoot.toString, + val out = doc.copy( + uri = source.toURI.toString(), + text = text, + md5 = MD5.compute(text), ) - val pluginOption = - s"-Xplugin:semanticdb -sourceroot:${sourceRoot} -targetroot:${targetRoot}" - val cmd = - mainOptions ::: jigsawOptions ::: pluginOption :: localSource.toString :: Nil - - val stdout = List.newBuilder[String] - val ps = SystemProcess.run( - cmd, - workDir, - false, - Map.empty, - Some(outLine => stdout += outLine), - Some(errLine => stdout += errLine), - ) - - val future = ps.complete.recover { case NonFatal(e) => - scribe.error(s"Running javac-semanticdb failed for $localSource", e) - 1 - } - val exitCode = Await.result(future, 10.seconds) - val semanticdbFile = - targetRoot - .resolve("META-INF") - .resolve("semanticdb") - .resolve(s"${localSource.filename}.semanticdb") - - val doc = if (semanticdbFile.exists) { - FileIO - .readAllDocuments(semanticdbFile) - .headOption - .getOrElse(s.TextDocument()) - } else { - val log = stdout.result() - if (exitCode != 0 || log.nonEmpty) - scribe.warn( - s"Running javac-semanticdb failed for ${source.toURI}. Output:\n${log.mkString("\n")}" - ) - s.TextDocument() + workDir.deleteRecursively() + out } - val out = doc.copy( - uri = source.toURI.toString(), - text = text, - md5 = MD5.compute(text), - ) - - workDir.deleteRecursively() - out - } - private def patchModuleFlags( source: AbsolutePath, sourceRoot: AbsolutePath, diff --git a/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala new file mode 100644 index 00000000000..e84886f8b59 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala @@ -0,0 +1,249 @@ +package scala.meta.internal.metals + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.stream.Collectors + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.util.control.NonFatal + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.clients.language.MetalsLanguageClient + +final case class FSReadDirectoryResponse( + name: String, + isFile: Boolean, +) +final case class FSReadDirectoriesResponse( + name: String, + directories: Array[FSReadDirectoryResponse], + error: String, +) + +final case class FSReadFileResponse( + name: String, + value: String, + error: String, +) + +final case class FSStatResponse( + name: String, + isFile: Boolean, + error: String, +) + +final class LSPFileSystemProvider( + languageClient: MetalsLanguageClient, + uriMapper: URIMapper, + fileDecoderProvider: FileDecoderProvider, + clientConfig: ClientConfiguration, +)(implicit ec: ExecutionContext) { + + // cache the last few decompiled class files as they're repeatedly accessed by VSCode once opened or referenced + private val cache = LRUCache[Path, String](10) + + def sendLibraryFileSystemReady(): Unit = { + if (clientConfig.isLibraryFileSystemSupported()) { + val params = + ClientCommands.LibraryFileSystemReady.toExecuteCommandParams() + languageClient.metalsExecuteClientCommand(params) + } + } + + private def getLocalPathDirectory( + uri: String, + prefix: String, + mappingFunction: String => FileSystemInfo, + ): Array[FSReadDirectoryResponse] = { + val (name, remaining) = URIMapper.getURIParts(uri, prefix) + val fs = mappingFunction(name).fs + // assume all jar paths only have `/` as root + val path = fs.getPath(remaining.getOrElse("/")) + Files + .list(path) + .collect(Collectors.toList()) + .asScala + .map(path => + FSReadDirectoryResponse( + path.getFileName.toString, + Files.isRegularFile(path), + ) + ) + .toArray + } + + private def getLocalSystemStat( + uri: String, + prefix: String, + mappingFunction: String => FileSystemInfo, + ): FSStatResponse = { + val (name, remaining) = URIMapper.getURIParts(uri, prefix) + try { + val fs = mappingFunction(name).fs + // assume all jar paths only have `/` as root + val path = fs.getPath(remaining.getOrElse("/")) + FSStatResponse(uri, Files.isRegularFile(path), null) + } catch { + case NonFatal(e) => + scribe.error(s"getLocalSystemStat $uri $name $remaining", e) + throw e + } + } + + private def getLocalFileContents( + uri: String, + prefix: String, + mappingFunction: String => FileSystemInfo, + ): FSReadFileResponse = { + val (name, remaining) = URIMapper.getURIParts(uri, prefix) + val fs = mappingFunction(name).fs + val path = fs.getPath(remaining.getOrElse("/")) + val contents = + if (path.isClassfile) { + val cacheResult = cache.get(path) + cacheResult.getOrElse({ + val response = + fileDecoderProvider.decodeCFRAndWait(path.toUri.toAbsolutePath) + val success = Option(response.value) + success.foreach(content => cache += path -> content) + success.getOrElse(response.error) + }) + } else + new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + FSReadFileResponse(uri, contents, null) + } + + def readDirectory( + uri: String + ): Future[FSReadDirectoriesResponse] = Future { + try { + val subPaths = uri match { + case URIMapper.parentURI => + Array( + FSReadDirectoryResponse(URIMapper.jdkDir, false), + FSReadDirectoryResponse(URIMapper.workspaceJarDir, false), + FSReadDirectoryResponse(URIMapper.sourceJarDir, false), + ) + case URIMapper.jdkURI => + uriMapper.getJDKs + .map(jdk => FSReadDirectoryResponse(jdk, false)) + .toArray + case URIMapper.workspaceJarURI => + uriMapper.getWorkspaceJars + .map(jar => FSReadDirectoryResponse(jar, false)) + .toArray + case URIMapper.sourceJarURI => + uriMapper.getSourceJars + .map(jar => FSReadDirectoryResponse(jar, false)) + .toArray + case jdk if jdk.startsWith(URIMapper.jdkURI) => + getLocalPathDirectory( + jdk, + URIMapper.jdkURI, + uriMapper.getJDKFileSystem, + ) + case workspaceJar + if workspaceJar.startsWith(URIMapper.workspaceJarURI) => + getLocalPathDirectory( + workspaceJar, + URIMapper.workspaceJarURI, + uriMapper.getWorkspaceJarFileSystem, + ) + case sourceJar if sourceJar.startsWith(URIMapper.sourceJarURI) => + getLocalPathDirectory( + sourceJar, + URIMapper.sourceJarURI, + uriMapper.getSourceJarFileSystem, + ) + } + FSReadDirectoriesResponse(uri, subPaths, null) + } catch { + case NonFatal(e) => FSReadDirectoriesResponse(uri, null, e.toString) + } + } + + def readFile(uri: String): Future[FSReadFileResponse] = Future { + try { + uri match { + case jdk if jdk.startsWith(URIMapper.jdkURI) => + getLocalFileContents( + jdk, + URIMapper.jdkURI, + uriMapper.getJDKFileSystem, + ) + case workspaceJar + if workspaceJar.startsWith(URIMapper.workspaceJarURI) => + getLocalFileContents( + workspaceJar, + URIMapper.workspaceJarURI, + uriMapper.getWorkspaceJarFileSystem, + ) + case sourceJar if sourceJar.startsWith(URIMapper.sourceJarURI) => + getLocalFileContents( + sourceJar, + URIMapper.sourceJarURI, + uriMapper.getSourceJarFileSystem, + ) + // VSCODE (or other clients) may ask for non-existant files (e.g. .gitignore) + case _ => FSReadFileResponse(uri, null, "doesn't exist") + } + } catch { + case NonFatal(e) => FSReadFileResponse(uri, null, e.toString) + } + } + + def getSystemStat(uri: String): Future[FSStatResponse] = Future { + try { + uri match { + case URIMapper.parentURI => + FSStatResponse(URIMapper.rootDir, false, null) + case URIMapper.jdkURI => FSStatResponse(URIMapper.jdkDir, false, null) + case URIMapper.workspaceJarURI => + FSStatResponse(URIMapper.workspaceJarDir, false, null) + case URIMapper.sourceJarURI => + FSStatResponse(URIMapper.sourceJarDir, false, null) + case jdk if jdk.startsWith(URIMapper.jdkURI) => + getLocalSystemStat( + jdk, + URIMapper.jdkURI, + uriMapper.getJDKFileSystem, + ) + case workspaceJar + if workspaceJar.startsWith(URIMapper.workspaceJarURI) => + getLocalSystemStat( + workspaceJar, + URIMapper.workspaceJarURI, + uriMapper.getWorkspaceJarFileSystem, + ) + case sourceJar if sourceJar.startsWith(URIMapper.sourceJarURI) => + getLocalSystemStat( + sourceJar, + URIMapper.sourceJarURI, + uriMapper.getSourceJarFileSystem, + ) + } + } catch { + case NonFatal(e) => + scribe.error(s"getSystemStat $uri", e) + FSStatResponse(uri, false, e.toString()) + } + } +} + +import scala.collection.mutable +// TODO this is not thread safe - do we care? +class LRUCache[K, V](maxEntries: Int) + extends java.util.LinkedHashMap[K, V](maxEntries, 1.0f, true) { + + override def removeEldestEntry(eldest: java.util.Map.Entry[K, V]): Boolean = + size > maxEntries +} + +object LRUCache { + def apply[K, V](maxEntries: Int): mutable.Map[K, V] = { + import scala.meta.internal.jdk.CollectionConverters._ + new LRUCache[K, V](maxEntries).asScala + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala index eaff1bf450b..3ef753c5bf9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala @@ -278,6 +278,8 @@ class MetalsLanguageServer( private var compilers: Compilers = _ private var scalafixProvider: ScalafixProvider = _ private var fileDecoderProvider: FileDecoderProvider = _ + private var lspFileSystemProvider: LSPFileSystemProvider = _ + private val uriMapper: URIMapper = URIMapper() private var testProvider: TestSuitesProvider = _ private var workspaceReload: WorkspaceReload = _ private var buildToolSelector: BuildToolSelector = _ @@ -411,6 +413,7 @@ class MetalsLanguageServer( new InteractiveSemanticdbs( workspace, buildTargets, + uriMapper, charset, languageClient, tables, @@ -691,6 +694,7 @@ class MetalsLanguageServer( definitionProvider, () => bspSession.map(_.mainConnection), buildTargets, + uriMapper, buildTargetClasses, compilations, languageClient, @@ -744,6 +748,7 @@ class MetalsLanguageServer( workspace, compilers, buildTargets, + uriMapper, () => userConfig, shellRunner, fileSystemSemanticdbs, @@ -752,6 +757,12 @@ class MetalsLanguageServer( clientConfig, classFinder, ) + lspFileSystemProvider = new LSPFileSystemProvider( + languageClient, + uriMapper, + fileDecoderProvider, + clientConfig, + ) popupChoiceReset = new PopupChoiceReset( workspace, tables, @@ -810,6 +821,7 @@ class MetalsLanguageServer( () => workspace, languageClient, buildTargets, + uriMapper, () => buildClient.ongoingCompilations(), definitionIndex, clientConfig.initialConfig.statistics, @@ -1058,7 +1070,8 @@ class MetalsLanguageServer( @JsonNotification("textDocument/didOpen") def didOpen(params: DidOpenTextDocumentParams): CompletableFuture[Unit] = { - val path = params.getTextDocument.getUri.toAbsolutePath + val localURI = uriMapper.convertToLocal(params.getTextDocument.getUri) + val path = localURI.toAbsolutePath // In some cases like peeking definition didOpen might be followed up by close // and we would lose the notion of the focused document focusedDocument.foreach(recentlyFocusedFiles.add) @@ -1066,7 +1079,11 @@ class MetalsLanguageServer( recentlyOpenedFiles.add(path) // Update md5 fingerprint from file contents on disk - fingerprints.add(path, FileIO.slurp(path, charset)) + fingerprints.add( + path, + if (path.isJarFileSystem) params.getTextDocument.getText + else FileIO.slurp(path, charset), + ) // Update in-memory buffer contents from LSP client buffers.put(path, params.getTextDocument.getText) @@ -1081,7 +1098,10 @@ class MetalsLanguageServer( * that we don't try to generate it for project files */ val interactive = buildServerPromise.future.map { _ => - interactiveSemanticdbs.textDocument(path) + interactiveSemanticdbs.textDocument( + path, + if (path.isJarFileSystem) Some(params.getTextDocument.getText) else None, + ) } // We need both parser and semanticdb for synthetic decorations val publishSynthetics = for { @@ -1143,7 +1163,8 @@ class MetalsLanguageServer( uriOpt match { case Some(uri) => { - val path = uri.toAbsolutePath + val localURI = uriMapper.convertToLocal(uri) + val path = localURI.toAbsolutePath focusedDocument = Some(path) buildTargets .inverseSources(path) @@ -1227,14 +1248,15 @@ class MetalsLanguageServer( @JsonNotification("textDocument/didClose") def didClose(params: DidCloseTextDocumentParams): Unit = { - val path = params.getTextDocument.getUri.toAbsolutePath - if (focusedDocument.contains(path)) { + val localPath = + uriMapper.convertToLocal(params.getTextDocument.getUri).toAbsolutePath + if (focusedDocument.contains(localPath)) { focusedDocument = recentlyFocusedFiles.pollRecent() } - buffers.remove(path) - compilers.didClose(path) - trees.didClose(path) - diagnostics.onNoSyntaxError(path) + buffers.remove(localPath) + compilers.didClose(localPath) + trees.didClose(localPath) + diagnostics.onNoSyntaxError(localPath) } @JsonNotification("textDocument/didSave") @@ -1464,22 +1486,27 @@ class MetalsLanguageServer( position: TextDocumentPositionParams ): CompletableFuture[util.List[Location]] = CancelTokens { _ => - implementationProvider.implementations(position).asJava + val localPosition = uriMapper.convertToLocal(position) + implementationProvider + .implementations(localPosition) + .map(location => uriMapper.convertToMetalsFS(location)) + .asJava } @JsonRequest("textDocument/hover") def hover(params: HoverExtParams): CompletableFuture[Hover] = { CancelTokens.future { token => + val localParams = uriMapper.convertToLocal(params) compilers - .hover(params, token) + .hover(localParams, token) .map { hover => - syntheticsDecorator.addSyntheticsHover(params, hover) + syntheticsDecorator.addSyntheticsHover(localParams, hover) } .map( _.orElse { - val path = params.textDocument.getUri.toAbsolutePath + val path = localParams.textDocument.getUri.toAbsolutePath if (path.isWorksheet) - worksheetProvider.hover(path, params.getPosition) + worksheetProvider.hover(path, localParams.getPosition) else None }.orNull @@ -1491,7 +1518,10 @@ class MetalsLanguageServer( def documentHighlights( params: TextDocumentPositionParams ): CompletableFuture[util.List[DocumentHighlight]] = - CancelTokens { _ => documentHighlightProvider.documentHighlight(params) } + CancelTokens { _ => + val mappedParams = uriMapper.convertToLocal(params) + documentHighlightProvider.documentHighlight(mappedParams) + } @JsonRequest("textDocument/documentSymbol") def documentSymbol( @@ -1500,8 +1530,13 @@ class MetalsLanguageServer( JEither[util.List[DocumentSymbol], util.List[SymbolInformation]] ] = CancelTokens { _ => + val localURI = + uriMapper.convertToLocal(params.getTextDocument().getUri()) documentSymbolProvider - .documentSymbols(params.getTextDocument().getUri().toAbsolutePath) + .documentSymbols(localURI.toAbsolutePath) + .map(symbolInfos => + symbolInfos.map(symbolInfo => uriMapper.convertToMetalsFS(symbolInfo)) + ) .asJava } @@ -1511,7 +1546,7 @@ class MetalsLanguageServer( ): CompletableFuture[util.List[TextEdit]] = CancelTokens.future { token => val path = params.getTextDocument.getUri.toAbsolutePath - if (path.isJava) + if (path.isJava || path.isClassfile) javaFormattingProvider.format(params) else formattingProvider.format(path, token) @@ -1523,7 +1558,7 @@ class MetalsLanguageServer( ): CompletableFuture[util.List[TextEdit]] = CancelTokens { _ => val path = params.getTextDocument.getUri.toAbsolutePath - if (path.isJava) + if (path.isJava || path.isClassfile) javaFormattingProvider.format() else onTypeFormattingProvider.format(params).asJava @@ -1535,7 +1570,7 @@ class MetalsLanguageServer( ): CompletableFuture[util.List[TextEdit]] = CancelTokens { _ => val path = params.getTextDocument.getUri.toAbsolutePath - if (path.isJava) + if (path.isJava || path.isClassfile) javaFormattingProvider.format(params) else rangeFormattingProvider.format(params).asJava @@ -1559,7 +1594,13 @@ class MetalsLanguageServer( def references( params: ReferenceParams ): CompletableFuture[util.List[Location]] = - CancelTokens { _ => referencesResult(params).flatMap(_.locations).asJava } + CancelTokens { _ => + referencesResult(uriMapper.convertToLocal(params)) + .flatMap( + _.locations.map(location => uriMapper.convertToMetalsFS(location)) + ) + .asJava + } // Triggers a cascade compilation and tries to find new references to a given symbol. // It's not possible to stream reference results so if we find new symbols we notify the @@ -1663,7 +1704,8 @@ class MetalsLanguageServer( params: CodeActionParams ): CompletableFuture[util.List[l.CodeAction]] = CancelTokens.future { token => - codeActionProvider.codeActions(params, token).map(_.asJava) + val localParams = uriMapper.convertToLocal(params) + codeActionProvider.codeActions(localParams, token).map(_.asJava) } @JsonRequest("textDocument/codeLens") @@ -1675,8 +1717,9 @@ class MetalsLanguageServer( "code lens generation", thresholdMillis = 1.second.toMillis, ) { - val path = params.getTextDocument.getUri.toAbsolutePath - codeLensProvider.findLenses(path).toList.asJava + val localURI = + uriMapper.convertToLocal(params.getTextDocument().getUri()) + codeLensProvider.findLenses(localURI.toAbsolutePath).toList.asJava } } @@ -1685,7 +1728,9 @@ class MetalsLanguageServer( params: FoldingRangeRequestParams ): CompletableFuture[util.List[FoldingRange]] = { CancelTokens.future { token => - val path = params.getTextDocument().getUri().toAbsolutePath + val localURI = + uriMapper.convertToLocal(params.getTextDocument().getUri()) + val path = localURI.toAbsolutePath if (path.isScala) parseTrees.currentFuture.map(_ => foldingRangeProvider.getRangedForScala(path) @@ -1713,7 +1758,10 @@ class MetalsLanguageServer( CancelTokens.future { token => indexingPromise.future.map { _ => val timer = new Timer(time) - val result = workspaceSymbols.search(params.getQuery, token).asJava + val result = workspaceSymbols + .search(params.getQuery, token) + .map(symbol => uriMapper.convertToMetalsFS(symbol)) + .asJava if (clientConfig.initialConfig.statistics.isWorkspaceSymbol) { scribe.info( s"time: found ${result.size()} results for query '${params.getQuery}' in $timer" @@ -1724,7 +1772,16 @@ class MetalsLanguageServer( } def workspaceSymbol(query: String): Seq[SymbolInformation] = { - workspaceSymbols.search(query) + val params = new WorkspaceSymbolParams(query) + workspaceSymbol(params).join.asScala.toList + } + + def convertToMetalsFS(uri: String): String = { + uriMapper.convertToMetalsFS(uri) + } + + def convertToLocal(uri: String): String = { + uriMapper.convertToLocal(uri) } @JsonRequest("workspace/executeCommand") @@ -1780,6 +1837,12 @@ class MetalsLanguageServer( params.kind == "class", ) .asJavaObject + case ServerCommands.FileSystemStat(uriAsStr) => + lspFileSystemProvider.getSystemStat(uriAsStr).asJavaObject + case ServerCommands.FileSystemReadFile(uriAsStr) => + lspFileSystemProvider.readFile(uriAsStr).asJavaObject + case ServerCommands.FileSystemReadDirectory(uriAsStr) => + lspFileSystemProvider.readDirectory(uriAsStr).asJavaObject case ServerCommands.RunDoctor() => Future { doctor.onVisibilityDidChange(true) @@ -1825,7 +1888,7 @@ class MetalsLanguageServer( languageClient.metalsExecuteClientCommand( ClientCommands.GotoLocation.toExecuteCommandParams( ClientCommands.WindowLocation( - location.getUri(), + uriMapper.convertToMetalsFS(location.getUri()), location.getRange(), ) ) @@ -1843,7 +1906,7 @@ class MetalsLanguageServer( languageClient.metalsExecuteClientCommand( ClientCommands.GotoLocation.toExecuteCommandParams( ClientCommands.WindowLocation( - location.getUri(), + uriMapper.convertToMetalsFS(location.getUri()), location.getRange(), ) ) @@ -2110,9 +2173,10 @@ class MetalsLanguageServer( params: TextDocumentPositionParams ): CompletableFuture[TreeViewNodeRevealResult] = Future { + val localURI = uriMapper.convertToLocal(params.getTextDocument().getUri()) treeView .reveal( - params.getTextDocument().getUri().toAbsolutePath, + localURI.toAbsolutePath, params.getPosition(), ) .orNull @@ -2122,7 +2186,10 @@ class MetalsLanguageServer( def findTextInDependencyJars( params: FindTextInDependencyJarsRequest ): CompletableFuture[util.List[Location]] = { - findTextInJars.find(params).map(_.asJava).asJava + findTextInJars + .find(params) + .map(_.map(location => uriMapper.convertToMetalsFS(location)).asJava) + .asJava } private def generateBspConfig(): Future[Unit] = { @@ -2417,6 +2484,7 @@ class MetalsLanguageServer( private val indexer = Indexer( () => workspaceReload, () => doctor, + () => lspFileSystemProvider, languageClient, () => bspSession, executionContext, @@ -2443,6 +2511,7 @@ class MetalsLanguageServer( () => referencesProvider, () => workspaceSymbols, buildTargets, + uriMapper, () => interactiveSemanticdbs, () => buildClient, () => semanticDBIndexer, @@ -2523,15 +2592,16 @@ class MetalsLanguageServer( token: CancelToken = EmptyCancelToken, definitionOnly: Boolean = false, ): Future[DefinitionResult] = { - val source = positionParams.getTextDocument.getUri.toAbsolutePath - if (source.isScalaFilename || source.isJavaFilename) { + val localPositionParams = uriMapper.convertToLocal(positionParams) + val source = localPositionParams.getTextDocument.getUri.toAbsolutePath + if (source.isScalaFilename || source.isJavaFilename || source.isClassfile) { val semanticDBDoc = semanticdbs.textDocument(source).documentIncludingStale - (for { + val result = (for { doc <- semanticDBDoc positionOccurrence = definitionProvider.positionOccurrence( source, - positionParams.getPosition, + localPositionParams.getPosition, doc, ) occ <- positionOccurrence.occurrence @@ -2539,8 +2609,8 @@ class MetalsLanguageServer( case Some(occ) => if (occ.role.isDefinition && !definitionOnly) { val refParams = new ReferenceParams( - positionParams.getTextDocument(), - positionParams.getPosition(), + localPositionParams.getTextDocument(), + localPositionParams.getPosition(), new ReferenceContext(false), ) val results = referencesResult(refParams) @@ -2548,7 +2618,7 @@ class MetalsLanguageServer( // Fallback again to the original behavior that returns // the definition location itself if no reference locations found, // for avoiding the confusing messages like "No definition found ..." - definitionResult(positionParams, token) + definitionResult(localPositionParams, token) } else { Future.successful( DefinitionResult( @@ -2560,7 +2630,7 @@ class MetalsLanguageServer( ) } } else { - definitionResult(positionParams, token) + definitionResult(localPositionParams, token) } case None => if (semanticDBDoc.isEmpty) { @@ -2568,8 +2638,15 @@ class MetalsLanguageServer( } // Even if it failed to retrieve the symbol occurrence from semanticdb, // try to find its definitions from presentation compiler. - definitionResult(positionParams, token) + definitionResult(localPositionParams, token) } + result.map(definition => + definition.copy(locations = + definition.locations.asScala + .map(location => uriMapper.convertToMetalsFS(location)) + .asJava + ) + ) } else { // Ignore non-scala files. Future.successful(DefinitionResult.empty) @@ -2586,7 +2663,7 @@ class MetalsLanguageServer( token: CancelToken = EmptyCancelToken, ): Future[DefinitionResult] = { val source = position.getTextDocument.getUri.toAbsolutePath - if (source.isScalaFilename || source.isJavaFilename) { + if (source.isScalaFilename || source.isJavaFilename || source.isClassfile) { val result = timerProvider.timedThunk( "definition", diff --git a/metals/src/main/scala/scala/meta/internal/metals/MutableMd5Fingerprints.scala b/metals/src/main/scala/scala/meta/internal/metals/MutableMd5Fingerprints.scala index 105687cb8f3..d76e1e0a989 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MutableMd5Fingerprints.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MutableMd5Fingerprints.scala @@ -7,6 +7,7 @@ import java.util.concurrent.ConcurrentLinkedQueue import scala.meta.internal.io.FileIO import scala.meta.internal.jdk.CollectionConverters._ +import scala.meta.internal.metals.MetalsEnrichments.XtensionAbsolutePathBuffers import scala.meta.internal.mtags.MD5 import scala.meta.internal.mtags.Md5Fingerprints import scala.meta.io.AbsolutePath @@ -77,12 +78,17 @@ final class MutableMd5Fingerprints extends Md5Fingerprints { soughtMd5: String, charset: Charset, ): Option[String] = { - val text = FileIO.slurp(path, charset) - val md5 = MD5.compute(text) - if (soughtMd5 != md5) { - lookupText(path, soughtMd5) - } else { - Some(text) + val prints = fingerprints.get(path) + if (path.isJarFileSystem && prints != null && prints.size > 0) + Option(prints.peek()).map(_.text) + else { + val text = FileIO.slurp(path, charset) + val md5 = MD5.compute(text) + if (soughtMd5 != md5) { + lookupText(path, soughtMd5) + } else { + Some(text) + } } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala index 7f35cd42057..d8d92f0f54f 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -149,6 +149,42 @@ object ServerCommands { |""".stripMargin, ) + val FileSystemStat = new ParametrizedCommand[String]( + "filesystem-stat", + "Read Directory", + """|Return information about the uri. E.g. uri is a directory + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the file or directory.", + ) + + val FileSystemReadDirectory = new ParametrizedCommand[String]( + "filesystem-read-directory", + "Read Directory", + """|Return the contents of a virtual filesystem directory. + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the directory.", + ) + + val FileSystemReadFile = new ParametrizedCommand[String]( + "filesystem-read-file", + "Read File", + """|Return the contents of a virtual filesystem file. + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the file.", + ) + val RunDoctor = new Command( "doctor-run", "Run doctor", 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 811fd37a87f..c6d3fa4475c 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala @@ -81,7 +81,8 @@ final class TargetData { } def all: Iterator[BuildTarget] = buildTargetInfo.values.toIterator - + def allJDKs: Iterator[String] = + scalaTargetInfo.flatMap(_._2.jvmHome).iterator def allBuildTargetIds: Seq[BuildTargetIdentifier] = buildTargetInfo.keys.toSeq def allScala: Iterator[ScalaTarget] = diff --git a/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala new file mode 100644 index 00000000000..7bae0d87606 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala @@ -0,0 +1,277 @@ +package scala.meta.internal.metals + +import java.net.URI +import java.nio.file.FileSystem +import java.nio.file.FileSystemNotFoundException +import java.nio.file.FileSystems +import java.nio.file.Path + +import scala.annotation.tailrec +import scala.collection.concurrent.TrieMap +import scala.util.Properties + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.mtags.URIEncoderDecoder +import scala.meta.io.AbsolutePath + +import org.eclipse.lsp4j.CodeActionParams +import org.eclipse.lsp4j.Location +import org.eclipse.lsp4j.ReferenceParams +import org.eclipse.lsp4j.SymbolInformation +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentPositionParams + +case class URIMapper() { + + private lazy val isWindows: Boolean = Properties.isWin + private val metalsFSToLocalJDKs = TrieMap.empty[String, FileSystemInfo] + private val metalsFSToLocalWorkspaceJars = + TrieMap.empty[String, FileSystemInfo] + private val metalsFSToLocalSourceJars = TrieMap.empty[String, FileSystemInfo] + // full local URI path -> full metals URI path + private val localURIToMetalsURI = TrieMap.empty[String, String] + + // in Windows the uri can be sent through in different case so lowercase all. + // e.g. file:///c:/coursier/somejar can be file:///C:/coursier/somejar in a stacktrace + private def changeCase(uri: String): String = + if (isWindows) + uri.toLowerCase() + else + uri + + @tailrec + private def getDecentJDKName(path: Path): String = + if ( + path.getParent == null || !List("lib", "src.zip").contains(path.filename) + ) + path.filename + else + getDecentJDKName(path.getParent) + + def addJDK(jdk: AbsolutePath): Unit = { + val name = getDecentJDKName(jdk.toNIO) + addJDK(name, jdk) + } + + private def addJDK(name: String, sourcePath: AbsolutePath): Unit = { + val metalsURI = s"${URIMapper.jdkURI}/$name" + val localURI = sourcePath.toURI.toString + // AbsolutePath#toURI will partially encode the URI so decode it + val metalsEncodedURI = URIEncoderDecoder.decode(localURI) + localURIToMetalsURI.put(changeCase(metalsEncodedURI), metalsURI) + metalsFSToLocalJDKs.put(name, getFileSystem(sourcePath)) + } + + def addWorkspaceJar(jar: AbsolutePath): Unit = { + val metalsURI = s"${URIMapper.workspaceJarURI}/${jar.filename}" + val localURI = jar.toURI.toString + // AbsolutePath#toURI will partially encode the URI so decode it + val metalsEncodedURI = URIEncoderDecoder.decode(localURI) + localURIToMetalsURI.put(changeCase(metalsEncodedURI), metalsURI) + metalsFSToLocalWorkspaceJars.put(jar.filename, getFileSystem(jar)) + } + + def addSourceJar(jar: AbsolutePath): Unit = { + val metalsURI = s"${URIMapper.sourceJarURI}/${jar.filename}" + val localURI = jar.toURI.toString + // AbsolutePath#toURI will partially encode the URI so decode it + val metalsEncodedURI = URIEncoderDecoder.decode(localURI) + localURIToMetalsURI.put(changeCase(metalsEncodedURI), metalsURI) + metalsFSToLocalSourceJars.put(jar.filename, getFileSystem(jar)) + } + + def reset(): Unit = { + localURIToMetalsURI.clear() + metalsFSToLocalJDKs.clear() + metalsFSToLocalWorkspaceJars.clear() + metalsFSToLocalSourceJars.clear() + } + + private def getFileSystem(localPath: AbsolutePath): FileSystemInfo = { + val fileUri = localPath.toNIO.toUri.toString.stripSuffix("/") + val localUri = s"jar:$fileUri" + val zipURI = URI.create(localUri) + val fs = + try { + FileSystems.getFileSystem(zipURI) + } catch { + case _: FileSystemNotFoundException => + FileSystems.newFileSystem(zipURI, new java.util.HashMap[String, Any]) + } + FileSystemInfo(fs, fileUri) + } + + def getJDKs: Iterator[String] = + metalsFSToLocalJDKs.keySet.toIterator + + def getSourceJars: Iterator[String] = + metalsFSToLocalSourceJars.keySet.toIterator + + def getWorkspaceJars: Iterator[String] = + metalsFSToLocalWorkspaceJars.keySet.toIterator + + def getJDKFileSystem(uri: String): FileSystemInfo = + metalsFSToLocalJDKs(uri) + + def getWorkspaceJarFileSystem(uri: String): FileSystemInfo = + metalsFSToLocalWorkspaceJars(uri) + + def getSourceJarFileSystem(uri: String): FileSystemInfo = + metalsFSToLocalSourceJars(uri) + + private def getMetalsURIFromLocalURI(uri: String): String = { + if (localURIToMetalsURI.contains(changeCase(uri))) + localURIToMetalsURI(changeCase(uri)) + else + uri + } + + def encodeUri(uri: String): String = { + if (uri.startsWith("file://")) { + val path = uri.stripPrefix("file://") + new URI("file", "", path, null).toString + } else if (uri.startsWith("jar:")) { + val ssp = uri.stripPrefix("jar:") + new URI("jar", ssp, null).toString + } else if (uri.startsWith("metalsfs:")) { + // shouldn't need encoding. Definitely don't encode using URI("metalsfs", "", path, null) as this will create triple slashes: metalsfs:///somejar + uri + } else + throw new IllegalStateException(s"Why here? $uri") + } + + // transform the metalsfs URI into a local URI + def convertToLocal(uri: String): String = { + if (uri.startsWith(URIMapper.parentURI)) { + val (fs, fsPath) = URIEncoderDecoder.decode(uri) match { + case jdk if jdk.startsWith(URIMapper.jdkURI) => + val (name, remaining) = URIMapper.getURIParts(jdk, URIMapper.jdkURI) + val fs = getJDKFileSystem(name) + (fs, remaining) + case workspaceJar + if workspaceJar.startsWith(URIMapper.workspaceJarURI) => + val (name, remaining) = + URIMapper.getURIParts(workspaceJar, URIMapper.workspaceJarURI) + val fs = getWorkspaceJarFileSystem(name) + (fs, remaining) + case sourceJar if sourceJar.startsWith(URIMapper.sourceJarURI) => + val (name, remaining) = + URIMapper.getURIParts(sourceJar, URIMapper.sourceJarURI) + val fs = getSourceJarFileSystem(name) + (fs, remaining) + } + // no path - return the original jar uri e.g. file:///somejar.jar + if (fsPath.isEmpty) + // uri already encoded in getFileSystem method + fs.fileUri + else + // path exists - return the jar path uri e.g. jar:file:///somejar.jar!/path + // the Path#toUri call will encode the URI so no need to encode it manually + fs.fs.getPath(fsPath.get).toUri.toString + } else uri + } + + // transform the local URI into a metalsfs URI + def convertToMetalsFS(uri: String): String = { + val decodedURI = URIEncoderDecoder.decode(uri) + val metalsfsUri = if (uri.startsWith("jar:")) { + val path = decodedURI.stripPrefix("jar:") + val splitter = path.indexOf('!') + if (splitter == -1) getMetalsURIFromLocalURI(path) + else { + val remainder = path.substring(splitter + 1) + val localJarPath = path.substring(0, splitter) + val metalsJarPath = getMetalsURIFromLocalURI(localJarPath) + s"${metalsJarPath}${remainder}" + } + } else getMetalsURIFromLocalURI(decodedURI) + encodeUri(metalsfsUri) + } + + def convertToMetalsFS( + symbolInformation: SymbolInformation + ): SymbolInformation = { + val symbolInfo = new SymbolInformation( + symbolInformation.getName, + symbolInformation.getKind, + convertToMetalsFS(symbolInformation.getLocation), + symbolInformation.getContainerName, + ) + symbolInfo.setTags(symbolInformation.getTags) + symbolInfo + } + + def convertToLocal(location: Location): Location = + new Location(convertToLocal(location.getUri), location.getRange) + + def convertToMetalsFS(location: Location): Location = + new Location(convertToMetalsFS(location.getUri), location.getRange) + + def convertToLocal( + params: ReferenceParams + ): ReferenceParams = + new ReferenceParams( + convertToLocal(params.getTextDocument), + params.getPosition, + params.getContext, + ) + + def convertToLocal( + params: TextDocumentPositionParams + ): TextDocumentPositionParams = + new TextDocumentPositionParams( + convertToLocal(params.getTextDocument), + params.getPosition, + ) + + def convertToLocal( + params: TextDocumentIdentifier + ): TextDocumentIdentifier = + new TextDocumentIdentifier(convertToLocal(params.getUri)) + + def convertToLocal( + params: HoverExtParams + ): HoverExtParams = + params.copy(textDocument = convertToLocal(params.textDocument)) + + def convertToLocal( + params: CodeActionParams + ): CodeActionParams = + new CodeActionParams( + convertToLocal(params.getTextDocument()), + params.getRange(), + params.getContext(), + ) +} +case class FileSystemInfo(fs: FileSystem, fileUri: String) + +object URIMapper { + // various metals filesystem setup - change these to what we like + val rootDir: String = "metalsLibraries" + // vscode registers "metalsfs:///" as "metalsfs:/" so just stick to the single slash + val parentURI: String = s"metalsfs:/$rootDir" + val jdkDir: String = "jdk" + val workspaceJarDir: String = "jar" + val sourceJarDir: String = "source" + val jdkURI: String = s"${parentURI}/$jdkDir" + val workspaceJarURI: String = s"${parentURI}/$workspaceJarDir" + val sourceJarURI: String = s"${parentURI}/$sourceJarDir" + + // transform the metalsfs URI into a local URI + // ("metalsfs:/XXX/YYY", "metalsfs:/XXX") => ("YYY", None) + // ("metalsfs:/XXX/YYY/somePackage/someClass", "metalsfs:/XXX") => ("YYY", Some("somePackage/someClass")) + def getURIParts( + uri: String, + prefix: String, + ): (String, Option[String]) = { + val basePart = uri.stripPrefix(s"${prefix}/") + val separatorIdx = basePart.indexOf('/') + if (separatorIdx == -1) + (basePart, None) + else { + val name = basePart.substring(0, separatorIdx) + val remaining = basePart.substring(separatorIdx + 1) + (name, Some(remaining)) + } + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/ClientConfigurationAdapter.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/ClientConfigurationAdapter.scala index 27dbb726f23..0eb4791a981 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/ClientConfigurationAdapter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/ClientConfigurationAdapter.scala @@ -3,7 +3,6 @@ package scala.meta.internal.metals.debug import java.nio.file.Paths import scala.meta.internal.metals.MetalsEnrichments._ -import scala.meta.io.AbsolutePath import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.debug.InitializeRequestArguments @@ -42,22 +41,29 @@ private[debug] final case class ClientConfigurationAdapter( new Position(lspLine, breakpoint.getColumn()) } - def toMetalsPath(path: String): AbsolutePath = { + def toMetalsURI(uriOrPath: String): String = { pathFormat match { // VS Code normally sends in path, which doesn't encode files from jars properly // so URIs are actually sent in this case instead case InitializeRequestArgumentsPathFormat.PATH - if !path.startsWith("file:") && !path.startsWith("jar:") => - Paths.get(path).toUri.toString.toAbsolutePath - case _ => path.toAbsolutePath + if !uriOrPath.startsWith("file:") && !uriOrPath.startsWith( + "jar:" + ) && !uriOrPath.startsWith("metalsfs:") => + Paths.get(uriOrPath).toUri.toString + case _ => uriOrPath } } - def adaptPathForClient(path: AbsolutePath): String = { + def adaptPathForClient(uriOrPath: String): String = { pathFormat match { - case InitializeRequestArgumentsPathFormat.PATH => - if (path.isJarFileSystem) path.toURI.toString else path.toString - case InitializeRequestArgumentsPathFormat.URI => path.toURI.toString + case InitializeRequestArgumentsPathFormat.PATH + if uriOrPath.startsWith("file:") => + uriOrPath.toAbsolutePath.toString + case InitializeRequestArgumentsPathFormat.PATH + if !uriOrPath + .startsWith("jar:") && !uriOrPath.startsWith("metalsfs:") => + Paths.get(uriOrPath).toString + case _ => uriOrPath } } } 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 e6777a00563..0ce3fd9187b 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 @@ -40,6 +40,7 @@ import scala.meta.internal.metals.ScalaTestSuitesDebugRequest import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.internal.metals.StacktraceAnalyzer import scala.meta.internal.metals.StatusBar +import scala.meta.internal.metals.URIMapper import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.metals.clients.language.MetalsQuickPickItem import scala.meta.internal.metals.clients.language.MetalsQuickPickParams @@ -74,6 +75,7 @@ class DebugProvider( definitionProvider: DefinitionProvider, buildServerConnect: () => Option[BuildServerConnection], buildTargets: BuildTargets, + uriMapper: URIMapper, buildTargetClasses: BuildTargetClasses, compilations: Compilations, languageClient: MetalsLanguageClient, @@ -162,6 +164,7 @@ class DebugProvider( MetalsDebugAdapter.`2.x`( buildTargets, targets, + uriMapper, supportVirtualDocuments = clientConfig.isVirtualDocumentSupported(), ) } else { diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProxy.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProxy.scala index ae24ebbf9c4..e3857838334 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProxy.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/DebugProxy.scala @@ -100,7 +100,7 @@ private[debug] final class DebugProxy( client.consume(DebugProtocol.syntheticResponse(request, response)) case request @ SetBreakpointRequest(args) => val originalSource = DebugProtocol.copy(args.getSource) - val metalsSourcePath = clientAdapter.toMetalsPath(originalSource.getPath) + val metalsSourceURI = clientAdapter.toMetalsURI(originalSource.getPath) args.getBreakpoints.foreach { breakpoint => val line = clientAdapter.normalizeLineForServer(breakpoint.getLine) @@ -108,7 +108,7 @@ private[debug] final class DebugProxy( } val requests = - debugAdapter.adaptSetBreakpointsRequest(metalsSourcePath, args) + debugAdapter.adaptSetBreakpointsRequest(metalsSourceURI, args) server .sendPartitioned(requests.map(DebugProtocol.syntheticRequest)) .map(_.map(DebugProtocol.parseResponse[SetBreakpointsResponse])) @@ -122,9 +122,10 @@ private[debug] final class DebugProxy( frame <- lastFrames.find(_.getId() == args.getFrameId()) } yield { val originalSource = frame.getSource() - val sourceUri = clientAdapter.toMetalsPath(originalSource.getPath) + val sourceUri = clientAdapter.toMetalsURI(originalSource.getPath) + val sourcePath = sourceUri.toAbsolutePath compilers.debugCompletions( - sourceUri, + sourcePath, new Position(frame.getLine() - 1, 0), EmptyCancelToken, args, @@ -178,11 +179,11 @@ private[debug] final class DebugProxy( stackFrame <- args.getStackFrames frameSource <- Option(stackFrame.getSource) sourcePath <- Option(frameSource.getPath) - metalsSource <- debugAdapter.adaptStackFrameSource( + metalsSourceUri <- debugAdapter.adaptStackFrameSource( sourcePath, frameSource.getName, ) - } frameSource.setPath(clientAdapter.adaptPathForClient(metalsSource)) + } frameSource.setPath(clientAdapter.adaptPathForClient(metalsSourceUri)) response.setResult(args.toJson) lastFrames = args.getStackFrames() client.consume(response) diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/MetalsDebugAdapter.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/MetalsDebugAdapter.scala index d8416c44095..70f1ab600a5 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/MetalsDebugAdapter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/MetalsDebugAdapter.scala @@ -3,21 +3,21 @@ package scala.meta.internal.metals.debug import scala.meta.internal.metals.BuildTargets import scala.meta.internal.metals.DefinitionProvider import scala.meta.internal.metals.ScalaVersionSelector +import scala.meta.internal.metals.URIMapper import scala.meta.internal.parsing.ClassFinder -import scala.meta.io.AbsolutePath import ch.epfl.scala.bsp4j.BuildTargetIdentifier import org.eclipse.lsp4j.debug.SetBreakpointsArguments private[debug] sealed trait MetalsDebugAdapter { def adaptSetBreakpointsRequest( - sourcePath: AbsolutePath, + sourceUri: String, request: SetBreakpointsArguments, ): Iterable[SetBreakpointsArguments] def adaptStackFrameSource( sourcePath: String, sourceName: String, - ): Option[AbsolutePath] + ): Option[String] } /** @@ -29,20 +29,17 @@ private[debug] class MetalsDebugAdapter1x( ) extends MetalsDebugAdapter { override def adaptSetBreakpointsRequest( - sourcePath: AbsolutePath, + sourceUri: String, request: SetBreakpointsArguments, ): Iterable[SetBreakpointsArguments] = { - handleSetBreakpointsRequest( - sourcePath: AbsolutePath, - request: SetBreakpointsArguments, - ) + handleSetBreakpointsRequest(sourceUri, request) } override def adaptStackFrameSource( sourcePath: String, sourceName: String, - ): Option[AbsolutePath] = { - sourcePathProvider.findPathFor(sourcePath, sourceName) + ): Option[String] = { + sourcePathProvider.findPathFor(sourcePath, sourceName).map(_.toURI.toString) } } @@ -64,10 +61,16 @@ private[debug] object MetalsDebugAdapter { def `2.x`( buildTargets: BuildTargets, targets: Seq[BuildTargetIdentifier], + uriMapper: URIMapper, supportVirtualDocuments: Boolean, ): MetalsDebugAdapter2x = { val sourcePathAdapter = - SourcePathAdapter(buildTargets, targets, supportVirtualDocuments) + SourcePathAdapter( + buildTargets, + uriMapper, + targets, + supportVirtualDocuments, + ) new MetalsDebugAdapter2x(sourcePathAdapter) } } @@ -88,12 +91,12 @@ private[debug] class MetalsDebugAdapter2x(sourcePathAdapter: SourcePathAdapter) extends MetalsDebugAdapter { override def adaptSetBreakpointsRequest( - sourcePath: AbsolutePath, + sourceUri: String, request: SetBreakpointsArguments, ): Iterable[SetBreakpointsArguments] = { // try to find a BSP uri corresponding to the source path or don't send the request - sourcePathAdapter.toDapURI(sourcePath).map { sourceUri => - request.getSource.setPath(sourceUri.toString) + sourcePathAdapter.toDapURI(sourceUri).map { dapUri => + request.getSource.setPath(dapUri.toString()) request } } @@ -101,7 +104,7 @@ private[debug] class MetalsDebugAdapter2x(sourcePathAdapter: SourcePathAdapter) override def adaptStackFrameSource( sourcePath: String, sourceName: String, - ): Option[AbsolutePath] = { - sourcePathAdapter.toMetalsPath(sourcePath) + ): Option[String] = { + sourcePathAdapter.toMetalsPathOrUri(sourcePath) } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/SetBreakpointsRequestHandler.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/SetBreakpointsRequestHandler.scala index f387102f825..a1f61a329e9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/SetBreakpointsRequestHandler.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/SetBreakpointsRequestHandler.scala @@ -22,9 +22,10 @@ private[debug] final class SetBreakpointsRequestHandler( new TrieMap[AbsolutePath, Set[String]] def apply( - sourcePath: AbsolutePath, + sourceUri: String, request: SetBreakpointsArguments, ): Iterable[SetBreakpointsArguments] = { + val sourcePath = sourceUri.toAbsolutePath /* Get symbol for each breakpoint location to figure out the * class file that we need to register the breakpoint for. */ diff --git a/metals/src/main/scala/scala/meta/internal/metals/debug/SourcePathAdapter.scala b/metals/src/main/scala/scala/meta/internal/metals/debug/SourcePathAdapter.scala index ec6c049b5e6..6659dac9a40 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/debug/SourcePathAdapter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/debug/SourcePathAdapter.scala @@ -3,12 +3,11 @@ package scala.meta.internal.metals.debug import java.net.URI import java.nio.file.Paths -import scala.util.Try - import scala.meta.internal.io.FileIO import scala.meta.internal.metals.BuildTargets import scala.meta.internal.metals.Directories import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.URIMapper import scala.meta.io.AbsolutePath import ch.epfl.scala.bsp4j.BuildTargetIdentifier @@ -16,20 +15,22 @@ import ch.epfl.scala.bsp4j.BuildTargetIdentifier private[debug] final class SourcePathAdapter( workspace: AbsolutePath, buildTargets: BuildTargets, + uriMapper: URIMapper, supportVirtualDocuments: Boolean, ) { // when virtual documents are supported there is no need to save jars on disk private val saveJarFileToDisk = !supportVirtualDocuments private val dependencies = workspace.resolve(Directories.dependencies) - def toDapURI(sourcePath: AbsolutePath): Option[URI] = { - if ( - !supportVirtualDocuments && sourcePath.toNIO.startsWith( - dependencies.toNIO - ) - ) { + def toDapURI(sourceUri: String): Option[URI] = { + val localUri = + if (sourceUri.startsWith(URIMapper.parentURI)) + uriMapper.convertToLocal(sourceUri) + else sourceUri + val sourcePath = localUri.toAbsolutePath + if (saveJarFileToDisk && sourcePath.toNIO.startsWith(dependencies.toNIO)) { // if sourcePath is a dependency source file - // we retrieve the original source jar and we build the uri innside the source jar filesystem + // we retrieve the original source jar and we build the uri inside the source jar filesystem for { dependencySource <- sourcePath.toRelativeInside(dependencies) dependencyFolder <- dependencySource.toNIO.iterator.asScala.headOption @@ -41,24 +42,25 @@ private[debug] final class SourcePathAdapter( } yield FileIO.withJarFileSystem(jarFile, create = false)(root => root.resolve(relativePath.toString).toURI ) - } else { + } else Some(sourcePath.toURI) - } } - def toMetalsPath(sourcePath: String): Option[AbsolutePath] = try { - val sourceUri = - Try(URI.create(sourcePath)).getOrElse(Paths.get(sourcePath).toUri()) - sourceUri.getScheme match { - case "jar" => - val path = sourceUri.toAbsolutePath - Some(if (saveJarFileToDisk) path.toFileOnDisk(workspace) else path) - case "file" => Some(AbsolutePath(Paths.get(sourceUri))) - case _ => None - } + def toMetalsPathOrUri(sourcePathOrUri: String): Option[String] = try { + val metalsUri = if (sourcePathOrUri.startsWith("jar:")) { + if (saveJarFileToDisk) { + val path = sourcePathOrUri.toAbsolutePath + path.toFileOnDisk(workspace).toURI.toString + } else + uriMapper.convertToMetalsFS(sourcePathOrUri) + } else if (sourcePathOrUri.startsWith("file:")) + sourcePathOrUri + else + Paths.get(sourcePathOrUri).toUri().toString + Some(metalsUri) } catch { case e: Throwable => - scribe.error(s"Could not resolve $sourcePath", e) + scribe.error(s"Could not resolve $sourcePathOrUri", e) None } } @@ -66,6 +68,7 @@ private[debug] final class SourcePathAdapter( private[debug] object SourcePathAdapter { def apply( buildTargets: BuildTargets, + uriMapper: URIMapper, targets: Seq[BuildTargetIdentifier], supportVirtualDocuments: Boolean, ): SourcePathAdapter = { @@ -73,6 +76,7 @@ private[debug] object SourcePathAdapter { new SourcePathAdapter( workspace, buildTargets, + uriMapper, supportVirtualDocuments, ) } diff --git a/metals/src/main/scala/scala/meta/internal/parsing/FoldingRangeProvider.scala b/metals/src/main/scala/scala/meta/internal/parsing/FoldingRangeProvider.scala index 5e708989d46..3a71586bbe3 100644 --- a/metals/src/main/scala/scala/meta/internal/parsing/FoldingRangeProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/parsing/FoldingRangeProvider.scala @@ -47,7 +47,7 @@ final class FoldingRangeProvider( ): util.List[FoldingRange] = { val result = for { code <- buffers.get(filePath) - if filePath.isJava + if filePath.isJava || filePath.isClassfile } yield { val extractor = new JavaFoldingRangeExtractor(code, foldOnlyLines.get()) diff --git a/metals/src/main/scala/scala/meta/internal/tvp/ClasspathTreeView.scala b/metals/src/main/scala/scala/meta/internal/tvp/ClasspathTreeView.scala index 7765380e17e..63e08048675 100644 --- a/metals/src/main/scala/scala/meta/internal/tvp/ClasspathTreeView.scala +++ b/metals/src/main/scala/scala/meta/internal/tvp/ClasspathTreeView.scala @@ -1,6 +1,7 @@ package scala.meta.internal.tvp import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.URIMapper import scala.meta.internal.mtags.GlobalSymbolIndex import scala.meta.internal.mtags.Symbol import scala.meta.internal.semanticdb.Scala._ @@ -148,13 +149,27 @@ class ClasspathTreeView[Value, Key]( def fromUri(uri: String): NodeUri = { val stripped = uri.stripPrefix(s"$scheme:") - val separator = stripped.lastIndexOf("!/") - if (separator < 0) { - NodeUri(decode(stripped)) + if (stripped.startsWith(s"${URIMapper.workspaceJarURI}/")) { + val remainder = stripped.stripPrefix(s"${URIMapper.workspaceJarURI}/") + val separator = remainder.indexOf('/') + if (separator < 0) { + NodeUri(decode(stripped)) + } else { + val key = decode( + s"${URIMapper.workspaceJarURI}/${remainder.substring(0, separator)}" + ) + val symbol = remainder.substring(separator + 1) + NodeUri(key, symbol) + } } else { - val key = decode(stripped.substring(0, separator)) - val symbol = stripped.substring(separator + 2) - NodeUri(key, symbol) + val separator = stripped.lastIndexOf("!/") + if (separator < 0) { + NodeUri(decode(stripped)) + } else { + val key = decode(stripped.substring(0, separator)) + val symbol = stripped.substring(separator + 2) + NodeUri(key, symbol) + } } } @@ -183,7 +198,7 @@ class ClasspathTreeView[Value, Key]( def isDescendent(child: String): Boolean = if (isRoot) true else child.startsWith(symbol) - def toUri: String = s"$scheme:${encode(key)}!/$symbol" + def toUri: String = s"$scheme:${encode(key)}/$symbol" def parentChain: List[String] = { parent match { case None => toUri :: s"$scheme:" :: Nil diff --git a/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala b/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala index 5bfaa0d125d..ede0a93e5c6 100644 --- a/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala @@ -25,6 +25,7 @@ class MetalsTreeViewProvider( workspace: () => AbsolutePath, languageClient: MetalsLanguageClient, buildTargets: BuildTargets, + uriMapper: URIMapper, compilations: () => TreeViewCompilations, definitionIndex: GlobalSymbolIndex, statistics: StatisticsConfig, @@ -41,18 +42,27 @@ class MetalsTreeViewProvider( private val classpath = new ClasspathSymbols( isStatisticsEnabled = statistics.isTreeView ) - val libraries = new ClasspathTreeView[AbsolutePath, AbsolutePath]( + val libraries = new ClasspathTreeView[String, String]( definitionIndex, Project, "libraries", "Libraries", identity, - _.toURI.toString(), - _.toAbsolutePath, - _.filename, _.toString, - () => buildTargets.allWorkspaceJars, - (path, symbol) => classpath.symbols(path, symbol), + _.toString, + _.toString.stripPrefix(s"${URIMapper.workspaceJarURI}/"), + _.toString, + () => + buildTargets.allWorkspaceJars.map(path => + uriMapper.convertToMetalsFS(path.toNIO.toUri.toString) + ), + (treeViewUri, symbol) => { + val localURI = uriMapper.convertToLocal(treeViewUri) + // root of jar URI needs to be specified as file:///xxx/name.jar + val uri = + localURI.replace(".jar!/", ".jar/").stripSuffix("/").stripPrefix("jar:") + classpath.symbols(uri.toAbsolutePath, symbol) + }, ) val projects = new ClasspathTreeView[BuildTarget, BuildTargetIdentifier]( @@ -61,7 +71,7 @@ class MetalsTreeViewProvider( "projects", "Projects", _.getId(), - _.getUri(), + targetId => s"${targetId.getUri}!", uri => new BuildTargetIdentifier(uri), _.getDisplayName(), _.baseDirectory, @@ -218,7 +228,15 @@ class MetalsTreeViewProvider( ) case Some(uri) => if (libraries.matches(uri)) { - libraries.children(uri) + libraries + .children(uri) + .map(node => { + val jarURI = + s"jar:${node.nodeUri.stripPrefix(libraries.rootUri)}" + val metalsFSURI = + s"${libraries.rootUri}${uriMapper.convertToMetalsFS(jarURI)}" + node.copy(nodeUri = metalsFSURI) + }) } else if (projects.matches(uri)) { projects.children(uri) } else { @@ -293,7 +311,11 @@ class MetalsTreeViewProvider( buildTargets .inferBuildTarget(List(Symbol(closestSymbol.symbol).toplevel)) .map { inferred => - libraries.toUri(inferred.jar, inferred.symbol).parentChain + val metalsUri = + uriMapper.convertToMetalsFS(inferred.jar.toNIO.toUri.toString) + libraries + .toUri(metalsUri, inferred.symbol) + .parentChain } } else { buildTargets diff --git a/tests/unit/src/main/scala/tests/BaseLspSuite.scala b/tests/unit/src/main/scala/tests/BaseLspSuite.scala index e0295e899a3..28145043931 100644 --- a/tests/unit/src/main/scala/tests/BaseLspSuite.scala +++ b/tests/unit/src/main/scala/tests/BaseLspSuite.scala @@ -114,7 +114,7 @@ abstract class BaseLspSuite( .getOrElse(TestingServer.TestDefault) .copy(isVirtualDocumentSupported = Some(useVirtualDocs)) - client = new TestingClient(workspace, buffers) + client = new TestingClient(workspace, () => server, buffers) server = new TestingServer( workspace, client, diff --git a/tests/unit/src/main/scala/tests/TestingClient.scala b/tests/unit/src/main/scala/tests/TestingClient.scala index a86d2854e27..99bc9ba15fe 100644 --- a/tests/unit/src/main/scala/tests/TestingClient.scala +++ b/tests/unit/src/main/scala/tests/TestingClient.scala @@ -56,8 +56,11 @@ import tests.TestOrderings._ * - Can customize how to respond to window/showMessageRequest * - Aggregates published diagnostics and pretty-prints them as strings */ -class TestingClient(workspace: AbsolutePath, val buffers: Buffers) - extends NoopLanguageClient { +class TestingClient( + workspace: AbsolutePath, + server: () => TestingServer, + val buffers: Buffers, +) extends NoopLanguageClient { // Customization of the window/showMessageRequest response var importBuildChanges: MessageActionItem = ImportBuildChanges.notNow var importBuild: MessageActionItem = ImportBuild.notNow @@ -259,8 +262,19 @@ class TestingClient(workspace: AbsolutePath, val buffers: Buffers) null } override def telemetryEvent(`object`: Any): Unit = () + + private def getLocalPath(uri: String): AbsolutePath = { + // In reality the client would use URIs to reference data, not AbsolutePaths. + // If it needed the file contents it would use the LSP file system api to retrieve the data. + // Here we bypass that API and ask the server directly what the jar file should be and then access that directly. + // Clients that don't support LSP would use the readonly dir and wouldn't have to deal with these uri schemes + if (uri.startsWith("metalsfs:")) + server().convertToLocal(uri).toAbsolutePath + else uri.toAbsolutePath + } + override def publishDiagnostics(params: PublishDiagnosticsParams): Unit = { - val path = params.getUri.toAbsolutePath + val path = getLocalPath(params.getUri) diagnostics(path) = params.getDiagnostics.asScala.toSeq diagnosticsCount .getOrElseUpdate(path, new AtomicInteger()) @@ -457,7 +471,6 @@ class TestingClient(workspace: AbsolutePath, val buffers: Buffers) def applyCodeAction( selectedActionIndex: Int, codeActions: List[CodeAction], - server: TestingServer, ): Future[Any] = { if (codeActions.nonEmpty) { if (selectedActionIndex >= codeActions.length) { @@ -472,7 +485,7 @@ class TestingClient(workspace: AbsolutePath, val buffers: Buffers) applyWorkspaceEdit(edit) } if (command != null) { - server.executeCommandUnsafe( + server().executeCommandUnsafe( command.getCommand(), command.getArguments().asScala.toSeq, ) diff --git a/tests/unit/src/main/scala/tests/TestingServer.scala b/tests/unit/src/main/scala/tests/TestingServer.scala index af1b46b8b66..83a1f2a0605 100644 --- a/tests/unit/src/main/scala/tests/TestingServer.scala +++ b/tests/unit/src/main/scala/tests/TestingServer.scala @@ -178,7 +178,8 @@ final case class TestingServer( ): String = { val infos = server.workspaceSymbol(query) infos.foreach(info => { - val path = info.getLocation().getUri().toAbsolutePath + val localUri = convertToLocal(info.getLocation().getUri()) + val path = localUri.toAbsolutePath if (path.isJarFileSystem) virtualDocSources(path.toString.stripPrefix("/")) = path }) @@ -189,7 +190,8 @@ final case class TestingServer( else "" val filename = if (includeFilename) { - val path = Paths.get(URI.create(info.getLocation().getUri())) + val localUri = convertToLocal(info.getLocation().getUri()) + val path = Paths.get(URI.create(localUri)) s" ${path.getFileName()}" } else "" val container = Option(info.getContainerName()).getOrElse("") @@ -613,7 +615,8 @@ final case class TestingServer( } def didFocus(filename: String): Future[DidFocusResult.Value] = { - server.didFocus(toPath(filename).toURI.toString).asScala + val metalsUri = convertToMetalsFS(toPath(filename).toURI.toString) + server.didFocus(metalsUri).asScala } def windowStateDidChange(focused: Boolean): Unit = { @@ -666,12 +669,13 @@ final case class TestingServer( Debug.printEnclosing(filename) val abspath = toPath(filename) val uri = abspath.toURI.toString + val metalsUri = convertToMetalsFS(uri) val extension = PathIO.extension(abspath.toNIO) val text = abspath.readText server .didOpen( new DidOpenTextDocumentParams( - new TextDocumentItem(uri, extension, 0, text) + new TextDocumentItem(metalsUri, extension, 0, text) ) ) .asScala @@ -681,11 +685,12 @@ final case class TestingServer( Debug.printEnclosing(filename) val abspath = toPath(filename) val uri = abspath.toURI.toString + val metalsUri = convertToMetalsFS(uri) Future.successful { server .didClose( new DidCloseTextDocumentParams( - new TextDocumentIdentifier(uri) + new TextDocumentIdentifier(metalsUri) ) ) } @@ -1448,7 +1453,8 @@ final case class TestingServer( if (isSameFile) { s"L${location.getRange.getStart.getLine}" } else { - val path = location.getUri.toAbsolutePath + val localUri = convertToLocal(location.getUri) + val path = localUri.toAbsolutePath val filename = path.toNIO.getFileName if (path.isDependencySource(workspace)) filename.toString else s"$filename:${location.getRange.getStart.getLine}" @@ -1589,8 +1595,12 @@ final case class TestingServer( expected: String, )(implicit loc: munit.Location): Unit = { val viewId: String = TreeViewProvider.Project + val metalsUri = if (uri.startsWith("libraries:file")) { + val jarURI = s"jar:${uri.stripPrefix("libraries:")}" + s"libraries:${convertToMetalsFS(jarURI)}" + } else uri val result = - server.treeView.children(TreeViewChildrenParams(viewId, uri)).nodes + server.treeView.children(TreeViewChildrenParams(viewId, metalsUri)).nodes val obtained = result .map { node => val collapse = @@ -1609,6 +1619,14 @@ final case class TestingServer( Assertions.assertNoDiff(obtained, expected) } + def convertToMetalsFS(uri: String): String = { + server.convertToMetalsFS(uri) + } + + def convertToLocal(uri: String): String = { + server.convertToLocal(uri) + } + def findTextInDependencyJars( include: String, pattern: String, diff --git a/tests/unit/src/main/scala/tests/codeactions/BaseCodeActionLspSuite.scala b/tests/unit/src/main/scala/tests/codeactions/BaseCodeActionLspSuite.scala index 0fe5247e87e..e6d7beaafab 100644 --- a/tests/unit/src/main/scala/tests/codeactions/BaseCodeActionLspSuite.scala +++ b/tests/unit/src/main/scala/tests/codeactions/BaseCodeActionLspSuite.scala @@ -94,7 +94,7 @@ abstract class BaseCodeActionLspSuite( .recover { case _: Throwable if expectError => Nil } - _ <- client.applyCodeAction(selectedActionIndex, codeActions, server) + _ <- client.applyCodeAction(selectedActionIndex, codeActions) _ <- server.didSave(newPath) { _ => if (newPath != path) server.toPath(newPath).readText diff --git a/tests/unit/src/test/scala/tests/FindTextInDependencyJarsSuite.scala b/tests/unit/src/test/scala/tests/FindTextInDependencyJarsSuite.scala index b053fe2a010..80b6350684f 100644 --- a/tests/unit/src/test/scala/tests/FindTextInDependencyJarsSuite.scala +++ b/tests/unit/src/test/scala/tests/FindTextInDependencyJarsSuite.scala @@ -90,7 +90,8 @@ class FindTextInDependencyJarsSuite ): Unit = { val rendered = locations .map { loc => - val uri = URI.create(loc.getUri()) + val localURI = server.convertToLocal(loc.getUri()) + val uri = URI.create(localURI) val input = if (uri.getScheme() == "jar") { val jarPath = uri.toAbsolutePath val relativePath = diff --git a/tests/unit/src/test/scala/tests/JavaDefinitionSuite.scala b/tests/unit/src/test/scala/tests/JavaDefinitionSuite.scala index 237dbdae510..519591c4934 100644 --- a/tests/unit/src/test/scala/tests/JavaDefinitionSuite.scala +++ b/tests/unit/src/test/scala/tests/JavaDefinitionSuite.scala @@ -153,7 +153,8 @@ class JavaDefinitionSuite extends BaseLspSuite("java-definition") { if (characterInc == -1) { throw new Exception("Query must contain @@") } else { - val path = AbsolutePath.fromAbsoluteUri(URI.create(uri)) + val localUri = server.convertToLocal(uri) + val path = AbsolutePath.fromAbsoluteUri(URI.create(localUri)) val raw = query.replaceAll("@@", "").trim() val result = FileIO .slurp(path, StandardCharsets.UTF_8) @@ -173,7 +174,8 @@ class JavaDefinitionSuite extends BaseLspSuite("java-definition") { } private def renderLocation(loc: l.Location): String = { - val path = AbsolutePath.fromAbsoluteUri(URI.create(loc.getUri())) + val localURI = server.convertToLocal(loc.getUri()) + val path = AbsolutePath.fromAbsoluteUri(URI.create(localURI)) val relativePath = path.jarPath .map(jarPath => s"${jarPath.filename}${path}") diff --git a/tests/unit/src/test/scala/tests/StatusBarSuite.scala b/tests/unit/src/test/scala/tests/StatusBarSuite.scala index 05c3c4290a4..297b31ad2df 100644 --- a/tests/unit/src/test/scala/tests/StatusBarSuite.scala +++ b/tests/unit/src/test/scala/tests/StatusBarSuite.scala @@ -11,7 +11,7 @@ import scala.meta.internal.metals.StatusBar class StatusBarSuite extends BaseSuite { val time = new FakeTime - val client = new TestingClient(PathIO.workingDirectory, Buffers()) + val client = new TestingClient(PathIO.workingDirectory, () => null, Buffers()) var status = new StatusBar( () => client, time, diff --git a/tests/unit/src/test/scala/tests/URIMapperSuite.scala b/tests/unit/src/test/scala/tests/URIMapperSuite.scala new file mode 100644 index 00000000000..f33309e0a25 --- /dev/null +++ b/tests/unit/src/test/scala/tests/URIMapperSuite.scala @@ -0,0 +1,65 @@ +package tests + +import scala.meta.internal.metals.JdkSources +import scala.meta.internal.metals.URIMapper + +class URIMapperSuite extends BaseSuite { + + test("convertToMetalsFS-file-windows-1") { + val entry = "file:///e%3A/Foo.scala" + val expected = "file:///e:/Foo.scala" + + val uriMapper = new URIMapper() + val obtained = uriMapper.convertToMetalsFS(entry) + assertEquals(obtained, expected) + } + + test("convertToMetalsFS-file-windows-2") { + val entry = "file:///e:/Foo.scala" + val expected = "file:///e:/Foo.scala" + + val uriMapper = new URIMapper() + val obtained = uriMapper.convertToMetalsFS(entry) + assertEquals(obtained, expected) + } + + test("convertToMetalsFS-file-unix") { + val entry = "file:///Foo.scala" + val expected = "file:///Foo.scala" + + val uriMapper = new URIMapper() + val obtained = uriMapper.convertToMetalsFS(entry) + assertEquals(obtained, expected) + } + + test("convertToMetalsFS-jdk") { + val uriMapper = new URIMapper() + val zip = JdkSources(None) match { + case Right(zip) => zip + case Left(_) => fail("No JDK defined") + } + val jdkUri = zip.toNIO.toUri().toString + uriMapper.addJDK(zip) + val metalsFSUri = uriMapper.convertToMetalsFS(jdkUri) + val localFSUri = uriMapper.convertToLocal(metalsFSUri) + assertEquals(localFSUri, jdkUri) + val backToMetalsFSUri = uriMapper.convertToMetalsFS(localFSUri) + assertEquals(backToMetalsFSUri, metalsFSUri) + } + + test("convertToMetalsFS-jdk-class") { + val uriMapper = new URIMapper() + val zip = JdkSources(None) match { + case Right(zip) => zip + case Left(_) => fail("No JDK defined") + } + val jdkUri = zip.toNIO.toUri().toString + uriMapper.addJDK(zip) + val metalsFSUri = uriMapper.convertToMetalsFS(jdkUri) + val metalsClassUri = s"$metalsFSUri/javaClass.ext" + val localFSUri = uriMapper.convertToLocal(metalsClassUri) + assertEquals(localFSUri, s"jar:$jdkUri!/javaClass.ext") + val backToMetalsFSUri = uriMapper.convertToMetalsFS(localFSUri) + assertEquals(backToMetalsFSUri, metalsClassUri) + } +} diff --git a/tests/unit/src/test/scala/tests/codeactions/CreateNewSymbolLspSuite.scala b/tests/unit/src/test/scala/tests/codeactions/CreateNewSymbolLspSuite.scala index 67e0dc7527f..d2a6032fed2 100644 --- a/tests/unit/src/test/scala/tests/codeactions/CreateNewSymbolLspSuite.scala +++ b/tests/unit/src/test/scala/tests/codeactions/CreateNewSymbolLspSuite.scala @@ -110,7 +110,7 @@ class CreateNewSymbolLspSuite extends BaseCodeActionLspSuite("createNew") { None } } - client.applyCodeAction(selectedActionIndex, codeActions, server) + client.applyCodeAction(selectedActionIndex, codeActions) } _ <- server.didSave(path)(identity) _ = if (expectNoDiagnostics) assertNoDiagnostics() else ()