From e1f7db233bb51d6b0a629cfe4accefea1e9910c1 Mon Sep 17 00:00:00 2001 From: manykeys Date: Fri, 20 Feb 2026 09:31:25 +0500 Subject: [PATCH 1/2] Add LSP jar file system support --- .../meta/internal/metals/ClientCommands.scala | 6 + .../internal/metals/ClientConfiguration.scala | 3 + .../metals/FallbackMetalsLspService.scala | 1 + .../internal/metals/FileDecoderProvider.scala | 3 + .../meta/internal/metals/IndexProviders.scala | 2 + .../scala/meta/internal/metals/Indexer.scala | 3 + .../metals/InitializationOptions.scala | 3 + .../metals/LSPFileSystemProvider.scala | 170 ++++++++ .../internal/metals/MetalsLspService.scala | 11 + .../metals/ProjectMetalsLspService.scala | 2 + .../meta/internal/metals/ServerCommands.scala | 27 ++ .../meta/internal/metals/URIMapper.scala | 318 ++++++++++++++ .../internal/metals/WorkspaceLspService.scala | 399 ++++++++++++++++-- .../internal/tvp/MetalsTreeViewProvider.scala | 13 +- .../meta/metals/MetalsLanguageServer.scala | 5 +- 15 files changed, 922 insertions(+), 44 deletions(-) create mode 100644 metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala create mode 100644 metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala 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 69ea78db52a..56e0ca1b6af 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala @@ -38,6 +38,12 @@ object ClientCommands { |""".stripMargin, ) + val LibraryFileSystemReady = new Command( + "metals-library-filesystem-ready", + "Library File System Ready", + "Notifies client that library file system is ready", + ) + val RunDoctor = new ParametrizedCommand[String]( "metals-doctor-run", "Run doctor", 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 18dad4ba1f5..d33085f83e4 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala @@ -78,6 +78,9 @@ final class ClientConfiguration( 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/FallbackMetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala index b9150466236..8e327613fe9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala @@ -68,6 +68,7 @@ class FallbackMetalsLspService( folder, compilers, buildTargets, + uriMapper, () => userConfig, shellRunner, optFileSystemSemanticdbs, 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 a4fa5a3762d..5c41bca1aa5 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala @@ -86,6 +86,7 @@ final class FileDecoderProvider( workspace: AbsolutePath, compilers: Compilers, buildTargets: BuildTargets, + uriMapper: URIMapper, userConfig: () => UserConfiguration, shellRunner: ShellRunner, optFileSystemSemanticdbs: () => Option[FileSystemSemanticdbs], @@ -169,6 +170,8 @@ final class FileDecoderProvider( case "file" => decodeMetalsFile(uri) case "metalsDecode" => decodedFileContents(uri.getSchemeSpecificPart()) + case "metalsfs" => + decodedFileContents(uriMapper.convertToLocal(uriAsStr)) case _ => Future.successful( DecoderResponse.failed( diff --git a/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala b/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala index 74535d64f08..2e3f949e18a 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala @@ -29,6 +29,8 @@ trait IndexProviders { def referencesProvider: ReferenceProvider def workspaceSymbols: WorkspaceSymbolProvider def buildTargets: BuildTargets + def uriMapper: URIMapper + def lspFileSystemProvider: LSPFileSystemProvider def semanticDBIndexer: SemanticdbIndexer def fileWatcher: FileWatcher def focusedDocument: Option[AbsolutePath] 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 28b7ad6659a..c4436432445 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala @@ -203,6 +203,9 @@ case class Indexer(indexProviders: IndexProviders)(implicit rc: ReportContext) { buildTool.importedBuild.dependencySources, ) } + if (clientConfig.isLibraryFileSystemSupported()) { + lspFileSystemProvider.sendLibraryFileSystemReady() + } // 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 f7372457a4a..c22509f44f6 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala @@ -69,6 +69,7 @@ final case class InitializationOptions( isHttpEnabled: Option[Boolean], commandInHtmlFormat: Option[CommandHTMLFormat], isVirtualDocumentSupported: Option[Boolean], + isLibraryFileSystemSupported: Option[Boolean], openFilesOnRenameProvider: Option[Boolean], quickPickProvider: Option[Boolean], readClipboardProvider: Option[Boolean], @@ -171,6 +172,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/LSPFileSystemProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala new file mode 100644 index 00000000000..2c635cd42d0 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala @@ -0,0 +1,170 @@ +package scala.meta.internal.metals + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.stream.Collectors + +import scala.concurrent.Await +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.duration.DurationInt + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.clients.language.MetalsLanguageClient +import scala.meta.internal.mtags.URIEncoderDecoder + +/** Response indicating whether an entry is a file or directory. */ +case class FSReadDirectoryResponse(name: String, isFile: Boolean) + +/** Response containing the contents of a directory listing. */ +case class FSReadDirectoriesResponse( + name: String, + directories: Array[FSReadDirectoryResponse], + error: String, +) + +/** Response containing the textual contents of a file. */ +case class FSReadFileResponse(name: String, value: String, error: String) + +/** Response containing file metadata (existence and type). */ +case class FSStatResponse(name: String, isFile: Boolean, error: String) + +/** + * Handles virtual file system requests from the LSP client. + * + * Translates `metalsfs://` URIs into JAR file system reads using Java NIO, + * delegating URI resolution to [[URIMapper]] and `.class` decompilation + * to [[FileDecoderProvider]]. + */ +class LSPFileSystemProvider( + languageClient: MetalsLanguageClient, + uriMapper: URIMapper, + fileDecoderProvider: FileDecoderProvider, + clientConfig: ClientConfiguration, +)(implicit ec: ExecutionContext) { + + /** Notifies the client that the library file system is ready for use. */ + def sendLibraryFileSystemReady(): Unit = { + if (clientConfig.isLibraryFileSystemSupported()) { + val params = + ClientCommands.LibraryFileSystemReady.toExecuteCommandParams() + languageClient.metalsExecuteClientCommand(params) + } + } + + /** + * Lists the contents of a virtual directory. + * + * For top-level URIs returns the three root categories (jdk, jar, source). + * For category URIs returns the available archives from [[BuildTargets]]. + * For paths within an archive opens the corresponding NIO file system + * and lists the directory entries. + */ + def readDirectory(uri: String): Future[FSReadDirectoriesResponse] = Future { + val entries = uri match { + case URIMapper.parentURI => + Array( + FSReadDirectoryResponse(URIMapper.jdkDir, isFile = false), + FSReadDirectoryResponse(URIMapper.workspaceJarDir, isFile = false), + FSReadDirectoryResponse(URIMapper.sourceJarDir, isFile = false), + ) + case URIMapper.jdkURI => + uriMapper.getJDKs + .map(name => FSReadDirectoryResponse(name, isFile = false)) + .toArray + case URIMapper.workspaceJarURI => + uriMapper.getWorkspaceJars + .map(name => FSReadDirectoryResponse(name, isFile = false)) + .toArray + case URIMapper.sourceJarURI => + uriMapper.getSourceJars + .map(name => FSReadDirectoryResponse(name, isFile = false)) + .toArray + case _ => + val (fs, innerPath) = resolveInnerPath(uri) + val path = fs.fs.getPath(innerPath.getOrElse("/")) + Files + .list(path) + .collect(Collectors.toList()) + .asScala + .map(p => + FSReadDirectoryResponse( + p.getFileName.toString, + isFile = Files.isRegularFile(p), + ) + ) + .toArray + } + FSReadDirectoriesResponse(uri, entries, "") + } + + /** + * Reads the textual contents of a file inside a JAR archive. + * + * For `.class` files the bytecode is decompiled via CFR through + * [[FileDecoderProvider]]. All other files are read as UTF-8 text. + */ + def readFile(uri: String): Future[FSReadFileResponse] = Future { + val (fs, innerPath) = resolveInnerPath(uri) + val path = fs.fs.getPath(innerPath.getOrElse("/")) + val contents = + if (path.getFileName.toString.endsWith(".class")) { + val fut = fileDecoderProvider.decodedFileContents(path.toUri.toString) + val res = Await.result(fut, 10.minutes) + Option(res.value).filter(_.nonEmpty).getOrElse(res.error) + } else { + new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + } + FSReadFileResponse(uri, contents, "") + } + + /** + * Returns metadata for the given URI, indicating whether it + * represents a file or a directory. + * + * Known root-level URIs are resolved statically; paths within + * an archive are checked via the NIO file system. + */ + def getSystemStat(uri: String): Future[FSStatResponse] = Future { + uri match { + case URIMapper.parentURI => + FSStatResponse(URIMapper.rootDir, isFile = false, "") + case URIMapper.jdkURI => + FSStatResponse(URIMapper.jdkDir, isFile = false, "") + case URIMapper.workspaceJarURI => + FSStatResponse(URIMapper.workspaceJarDir, isFile = false, "") + case URIMapper.sourceJarURI => + FSStatResponse(URIMapper.sourceJarDir, isFile = false, "") + case _ => + val (fs, innerPath) = resolveInnerPath(uri) + val path = fs.fs.getPath(innerPath.getOrElse("/")) + FSStatResponse(uri, isFile = Files.isRegularFile(path), "") + } + } + + /** + * Resolves a `metalsfs://` URI into a NIO [[FileSystemInfo]] and + * an optional inner path within the archive. + * + * Dispatches to the appropriate [[URIMapper]] method based on whether + * the URI falls under the jdk, workspace jar, or source jar category. + */ + private def resolveInnerPath( + uri: String + ): (FileSystemInfo, Option[String]) = { + val decoded = URIEncoderDecoder.decode(uri) + decoded match { + case jdk if jdk.startsWith(URIMapper.jdkURI) => + val (name, path) = URIMapper.getURIParts(jdk, URIMapper.jdkURI) + (uriMapper.getJDKFileSystem(name), path) + case jar if jar.startsWith(URIMapper.workspaceJarURI) => + val (name, path) = + URIMapper.getURIParts(jar, URIMapper.workspaceJarURI) + (uriMapper.getWorkspaceJarFileSystem(name), path) + case src if src.startsWith(URIMapper.sourceJarURI) => + val (name, path) = + URIMapper.getURIParts(src, URIMapper.sourceJarURI) + (uriMapper.getSourceJarFileSystem(name), path) + } + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala index 99fb844b43e..fad05c6a4d0 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -167,6 +167,9 @@ abstract class MetalsLspService( val buildTargets: BuildTargets = BuildTargets.from(folder, mainBuildTargetsData, tables) + val uriMapper: URIMapper = + URIMapper(buildTargets, () => userConfig.javaHome) + implicit val reports: StdReportContext = new StdReportContext( folder.toNIO, _.flatMap { uri => @@ -603,6 +606,14 @@ abstract class MetalsLspService( protected def fileDecoderProvider: FileDecoderProvider + override lazy val lspFileSystemProvider: LSPFileSystemProvider = + new LSPFileSystemProvider( + languageClient, + uriMapper, + fileDecoderProvider, + clientConfig, + ) + def loadedPresentationCompilerCount(): Int = compilers.loadedPresentationCompilerCount() diff --git a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala index b95548b03b2..a82ba68568d 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala @@ -129,6 +129,7 @@ class ProjectMetalsLspService( folder, compilers, buildTargets, + uriMapper, () => userConfig, shellRunner, optFileSystemSemanticdbs, @@ -686,6 +687,7 @@ class ProjectMetalsLspService( new FolderTreeViewProvider( new Folder(folder, folderVisibleName, true), buildTargets, + uriMapper, definitionIndex, () => userConfig, scalaVersionSelector, 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 13c4ade5906..c25c56d5609 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -137,6 +137,30 @@ object ServerCommands { |""".stripMargin, ) + val FileSystemStat = new ParametrizedCommand[String]( + "filesystem-stat", + "Get file system stat", + """|Get file system stat for the specified uri. + |""".stripMargin, + "[uri]", + ) + + val FileSystemReadDirectory = new ParametrizedCommand[String]( + "filesystem-read-directory", + "Read directory", + """|Read directory for the specified uri. + |""".stripMargin, + "[uri]", + ) + + val FileSystemReadFile = new ParametrizedCommand[String]( + "filesystem-read-file", + "Read file", + """|Read file for the specified uri. + |""".stripMargin, + "[uri]", + ) + val DiscoverMainClasses = new ParametrizedCommand[DebugDiscoveryParams]( "discover-jvm-run-command", "Discover main classes to run and return the object", @@ -842,6 +866,9 @@ object ServerCommands { RunScalafix, ScalafixRunOnly, DecodeFile, + FileSystemStat, + FileSystemReadDirectory, + FileSystemReadFile, DisconnectBuildServer, DisconnectBuildServerAndShutdown, ListBuildTargets, 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..6c826bc066f --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala @@ -0,0 +1,318 @@ +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.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 + +/** + * Bidirectional mapper between virtual `metalsfs://` URIs exposed to + * the LSP client and local `jar:file://` URIs used internally by Metals. + * + * Maintains lazy NIO file system handles for JAR archives and provides + * overloaded conversions for common LSP4J parameter types. + */ +final case class URIMapper( + buildTargets: BuildTargets, + userJavaHome: () => Option[String], +) { + + private lazy val isWindows: Boolean = Properties.isWin + + private def changeCase(uri: String): String = + if (isWindows) uri.toLowerCase() else uri + + /** + * Walks up the path to find a meaningful JDK directory name, + * skipping intermediate segments like `lib` or `src.zip`. + */ + @tailrec + private def getDecentJDKName(path: Path): String = + if ( + path.getParent == null || !List("lib", "src.zip").contains(path.filename) + ) + path.filename + else + getDecentJDKName(path.getParent) + + private def jdkSources: Option[AbsolutePath] = + JdkSources(userJavaHome()).toOption + + /** Returns the display names of all available JDK source archives. */ + def getJDKs: Iterator[String] = + jdkSources.iterator.map(jdk => getDecentJDKName(jdk.toNIO)) + + /** Returns the filenames of all workspace dependency JARs. */ + def getWorkspaceJars: Iterator[String] = + buildTargets.allWorkspaceJars.map(_.filename) + + /** Returns the filenames of all source JARs. */ + def getSourceJars: Iterator[String] = + buildTargets.allSourceJars.map(_.filename) + + /** + * Returns an existing NIO [[FileSystem]] for the given archive, + * creating one if it does not yet exist. + */ + private def getOrCreateFileSystem(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) + } + + private def findJdkByName(name: String): Option[AbsolutePath] = + jdkSources.find(jdk => getDecentJDKName(jdk.toNIO) == name) + + private def findWorkspaceJarByName(name: String): Option[AbsolutePath] = + buildTargets.allWorkspaceJars.find(_.filename == name) + + private def findSourceJarByName(name: String): Option[AbsolutePath] = + buildTargets.allSourceJars.find(_.filename == name) + + /** Opens the NIO file system for a JDK source archive identified by name. */ + def getJDKFileSystem(name: String): FileSystemInfo = + findJdkByName(name) + .map(getOrCreateFileSystem) + .getOrElse(throw new NoSuchElementException(s"JDK not found: $name")) + + /** Opens the NIO file system for a workspace JAR identified by filename. */ + def getWorkspaceJarFileSystem(name: String): FileSystemInfo = + findWorkspaceJarByName(name) + .map(getOrCreateFileSystem) + .getOrElse( + throw new NoSuchElementException(s"Workspace jar not found: $name") + ) + + /** Opens the NIO file system for a source JAR identified by filename. */ + def getSourceJarFileSystem(name: String): FileSystemInfo = + findSourceJarByName(name) + .map(getOrCreateFileSystem) + .getOrElse( + throw new NoSuchElementException(s"Source jar not found: $name") + ) + + /** + * Determines the virtual category (jdk, jar, or source) for a local + * `file://` JAR path by matching it against known build dependencies. + * + * @param decodedJarPath a decoded `file:///path/to/some.jar` string + * @return the corresponding `metalsfs:/metalsLibraries//` + * prefix, or `None` if the path is not a known dependency + */ + private def classifyLocalUri( + decodedJarPath: String + ): Option[String] = { + val path = + try Some(AbsolutePath(java.nio.file.Paths.get(new URI(decodedJarPath)))) + catch { case _: Exception => None } + + path.flatMap { absPath => + val filename = absPath.filename + jdkSources match { + case Some(jdk) + if changeCase( + absPath.toURI.toString.stripSuffix("/") + ) == changeCase(jdk.toURI.toString.stripSuffix("/")) => + val name = getDecentJDKName(jdk.toNIO) + Some(s"${URIMapper.jdkURI}/$name") + case _ => + if ( + buildTargets.allWorkspaceJars.exists(j => + changeCase(j.toURI.toString.stripSuffix("/")) == changeCase( + absPath.toURI.toString.stripSuffix("/") + ) + ) + ) + Some(s"${URIMapper.workspaceJarURI}/$filename") + else if ( + buildTargets.allSourceJars.exists(j => + changeCase(j.toURI.toString.stripSuffix("/")) == changeCase( + absPath.toURI.toString.stripSuffix("/") + ) + ) + ) + Some(s"${URIMapper.sourceJarURI}/$filename") + else + None + } + } + } + + /** + * Percent-encodes the given URI according to its scheme. + * + * `metalsfs:` URIs are returned as-is because constructing them via + * `new URI("metalsfs", "", path, null)` would produce triple slashes. + */ + 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:")) { + uri + } else + throw new IllegalStateException(s"Why here? $uri") + } + + /** + * Converts a virtual `metalsfs://` URI into a local `jar:file://` URI. + * + * If the URI points to an archive root (no inner path), the plain + * `file://` URI of the archive is returned. Otherwise the full + * `jar:file:///archive.jar!/inner/path` form is produced. + * The returned URI is already percent-encoded by `Path#toUri`. + */ + 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) + } + if (fsPath.isEmpty) fs.fileUri + else fs.fs.getPath(fsPath.get).toUri.toString + } else uri + } + + /** + * Converts a local `jar:file://` URI into a virtual `metalsfs://` URI + * by classifying the archive and rewriting the path. + */ + 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) { + classifyLocalUri(path).getOrElse(path) + } else { + val remainder = path.substring(splitter + 1) + val localJarPath = path.substring(0, splitter) + val metalsJarPath = + classifyLocalUri(localJarPath).getOrElse(localJarPath) + s"${metalsJarPath}${remainder}" + } + } else { + classifyLocalUri(decodedURI).getOrElse(decodedURI) + } + encodeUri(metalsfsUri) + } + + /** Rewrites the location URI inside a [[SymbolInformation]] to `metalsfs://`. */ + def convertToMetalsFS( + symbolInformation: SymbolInformation + ): SymbolInformation = { + val symbolInfo = new SymbolInformation( + symbolInformation.getName, + symbolInformation.getKind, + convertToMetalsFS(symbolInformation.getLocation), + symbolInformation.getContainerName, + ) + symbolInfo.setTags(symbolInformation.getTags) + symbolInfo + } + + def convertToMetalsFS(location: Location): Location = + new Location(convertToMetalsFS(location.getUri), location.getRange) + + 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(), + ) +} + +/** Handle to an open NIO [[FileSystem]] together with its source `file://` URI. */ +final case class FileSystemInfo(fs: FileSystem, fileUri: String) + +/** + * Constants and utilities for the `metalsfs://` virtual file system URI scheme. + * + * VS Code normalises `metalsfs:///` to `metalsfs:/`, so all paths + * use the single-slash form. + */ +object URIMapper { + val rootDir: String = "metalsLibraries" + 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" + + /** + * Splits a `metalsfs://` URI into the archive name and an optional + * inner path relative to that archive. + * + * {{{ + * getURIParts("metalsfs:/X/Y", "metalsfs:/X") // ("Y", None) + * getURIParts("metalsfs:/X/Y/a/B", "metalsfs:/X") // ("Y", Some("a/B")) + * }}} + */ + 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/WorkspaceLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala index 820df69c579..d084e7e2449 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala @@ -127,6 +127,10 @@ class WorkspaceLspService( serverInputs.initialServerConfig, initializeParams, ) + private def uriMapper: URIMapper = + folderServices.headOption + .map(_.uriMapper) + .getOrElse(fallbackService.uriMapper) private val languageClient = { val languageClient = @@ -353,7 +357,7 @@ class WorkspaceLspService( def getServiceForOpt(uri: String): Option[ProjectMetalsLspService] = { // "metalsDecode" prefix is used for showing special files and is not an actual file system - val strippedUri = uri.stripPrefix("metalsDecode:") + val strippedUri = uriMapper.convertToLocal(uri).stripPrefix("metalsDecode:") for { path <- strippedUri.toAbsolutePathSafe() service <- @@ -366,6 +370,176 @@ class WorkspaceLspService( def getServiceFor(uri: String): MetalsLspService = getServiceForOpt(uri).getOrElse(fallbackService) + private def toLocalUri(uri: String): String = uriMapper.convertToLocal(uri) + + private def toMetalsFSUri(uri: String): String = + if (clientConfig.isLibraryFileSystemSupported()) + uriMapper.convertToMetalsFS(uri) + else uri + + private def toLocal( + params: TextDocumentPositionParams + ): TextDocumentPositionParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: CompletionParams): CompletionParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: lsp4j.InlayHintParams): lsp4j.InlayHintParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: ReferenceParams): ReferenceParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: HoverExtParams): HoverExtParams = + uriMapper.convertToLocal(params) + + private def toLocal(params: CodeActionParams): CodeActionParams = + uriMapper.convertToLocal(params) + + private def toLocal( + params: DidOpenTextDocumentParams + ): DidOpenTextDocumentParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DidChangeTextDocumentParams + ): DidChangeTextDocumentParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DidCloseTextDocumentParams + ): DidCloseTextDocumentParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DidSaveTextDocumentParams + ): DidSaveTextDocumentParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: DocumentSymbolParams): DocumentSymbolParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DocumentFormattingParams + ): DocumentFormattingParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DocumentOnTypeFormattingParams + ): DocumentOnTypeFormattingParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: DocumentRangeFormattingParams + ): DocumentRangeFormattingParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: RenameParams): RenameParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: CodeLensParams): CodeLensParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: FoldingRangeRequestParams + ): FoldingRangeRequestParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: SelectionRangeParams): SelectionRangeParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal(params: SemanticTokensParams): SemanticTokensParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: CallHierarchyPrepareParams + ): CallHierarchyPrepareParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: CallHierarchyIncomingCallsParams + ): CallHierarchyIncomingCallsParams = { + params.getItem.setUri(toLocalUri(params.getItem.getUri)) + params + } + + private def toLocal( + params: CallHierarchyOutgoingCallsParams + ): CallHierarchyOutgoingCallsParams = { + params.getItem.setUri(toLocalUri(params.getItem.getUri)) + params + } + + private def toLocal( + params: TypeHierarchyPrepareParams + ): TypeHierarchyPrepareParams = { + params.getTextDocument.setUri(toLocalUri(params.getTextDocument.getUri)) + params + } + + private def toLocal( + params: TypeHierarchySupertypesParams + ): TypeHierarchySupertypesParams = { + params.getItem.setUri(toLocalUri(params.getItem.getUri)) + params + } + + private def toLocal( + params: TypeHierarchySubtypesParams + ): TypeHierarchySubtypesParams = { + params.getItem.setUri(toLocalUri(params.getItem.getUri)) + params + } + + private def toMetalsFS(location: Location): Location = + if (clientConfig.isLibraryFileSystemSupported()) + uriMapper.convertToMetalsFS(location) + else location + + private def toMetalsFS( + symbolInformation: SymbolInformation + ): SymbolInformation = + if (clientConfig.isLibraryFileSystemSupported()) + uriMapper.convertToMetalsFS(symbolInformation) + else symbolInformation + def currentFolder: Option[MetalsLspService] = focusedDocument.get().flatMap(getServiceForOpt) @@ -460,8 +634,9 @@ class WorkspaceLspService( override def didOpen( params: DidOpenTextDocumentParams ): CompletableFuture[Unit] = { + val localParams = toLocal(params) focusedDocument.get().foreach(recentlyFocusedFiles.add) - val uri = params.getTextDocument.getUri + val uri = localParams.getTextDocument.getUri val path = uri.toAbsolutePath if (!clientConfig.isDidFocusProvider() || focusedDocument.get().isEmpty) setFocusedDocument(Some(path)) @@ -473,13 +648,14 @@ class WorkspaceLspService( } else None } .getOrElse(fallbackService) - service.didOpen(params) + service.didOpen(localParams) } override def didChange( params: DidChangeTextDocumentParams ): CompletableFuture[Unit] = { - val uri = params.getTextDocument().getUri() + val localParams = toLocal(params) + val uri = localParams.getTextDocument().getUri() /* If a file changed that was most likely caused by the user, * we should consider it as the focused document. * @@ -487,46 +663,62 @@ class WorkspaceLspService( */ if (!clientConfig.isDidFocusProvider()) setFocusedDocument(Some(uri.toAbsolutePath)) - getServiceFor(uri).didChange(params) + getServiceFor(uri).didChange(localParams) } override def didClose(params: DidCloseTextDocumentParams): Unit = { - val path = params.getTextDocument.getUri.toAbsolutePath + val localParams = toLocal(params) + val path = localParams.getTextDocument.getUri.toAbsolutePath if ( !clientConfig.isDidFocusProvider() && focusedDocument.get().contains(path) ) { setFocusedDocument(recentlyFocusedFiles.pollRecent()) } - getServiceFor(params.getTextDocument().getUri()).didClose(params) + getServiceFor(localParams.getTextDocument().getUri()).didClose(localParams) } override def didSave( params: DidSaveTextDocumentParams - ): CompletableFuture[Unit] = - getServiceFor(params.getTextDocument().getUri()).didSave(params) + ): CompletableFuture[Unit] = { + val localParams = toLocal(params) + getServiceFor(localParams.getTextDocument().getUri()).didSave(localParams) + } override def definition( position: TextDocumentPositionParams ): CompletableFuture[ju.List[Location]] = - getServiceFor(position.getTextDocument().getUri()).definition(position) + getServiceFor(toLocal(position).getTextDocument.getUri) + .definition(toLocal(position)) + .asScala + .map(_.asScala.map(toMetalsFS).asJava) + .asJava override def typeDefinition( position: TextDocumentPositionParams ): CompletableFuture[ju.List[Location]] = - getServiceFor(position.getTextDocument().getUri()).typeDefinition(position) + getServiceFor(toLocal(position).getTextDocument.getUri) + .typeDefinition(toLocal(position)) + .asScala + .map(_.asScala.map(toMetalsFS).asJava) + .asJava override def implementation( position: TextDocumentPositionParams ): CompletableFuture[ju.List[Location]] = - getServiceFor(position.getTextDocument().getUri()).implementation(position) + getServiceFor(toLocal(position).getTextDocument.getUri) + .implementation(toLocal(position)) + .asScala + .map(_.asScala.map(toMetalsFS).asJava) + .asJava override def hover(params: HoverExtParams): CompletableFuture[Hover] = - getServiceFor(params.textDocument.getUri()).hover(params) + getServiceFor(toLocal(params).textDocument.getUri()).hover(toLocal(params)) override def inlayHints( params: lsp4j.InlayHintParams ): CompletableFuture[java.util.List[lsp4j.InlayHint]] = - getServiceFor(params.getTextDocument.getUri()).inlayHints(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .inlayHints(toLocal(params)) override def inlayHintResolve( inlayHint: lsp4j.InlayHint @@ -538,75 +730,177 @@ class WorkspaceLspService( override def documentHighlights( params: TextDocumentPositionParams ): CompletableFuture[ju.List[DocumentHighlight]] = - getServiceFor(params.getTextDocument.getUri()).documentHighlights(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .documentHighlights(toLocal(params)) override def documentSymbol(params: DocumentSymbolParams): CompletableFuture[ messages.Either[ju.List[DocumentSymbol], ju.List[SymbolInformation]] ] = - getServiceFor(params.getTextDocument.getUri()).documentSymbol(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .documentSymbol(toLocal(params)) + .asScala + .map { either => + if (either.isLeft) + messages.Either.forLeft[ + ju.List[DocumentSymbol], + ju.List[SymbolInformation], + ](either.getLeft) + else { + val updated = either.getRight.asScala.map(toMetalsFS).asJava + messages.Either.forRight[ + ju.List[DocumentSymbol], + ju.List[SymbolInformation], + ](updated) + } + } + .asJava override def formatting( params: DocumentFormattingParams ): CompletableFuture[ju.List[TextEdit]] = - getServiceFor(params.getTextDocument.getUri()).formatting(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .formatting(toLocal(params)) override def onTypeFormatting( params: DocumentOnTypeFormattingParams ): CompletableFuture[ju.List[TextEdit]] = - getServiceFor(params.getTextDocument.getUri()).onTypeFormatting(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .onTypeFormatting(toLocal(params)) override def rangeFormatting( params: DocumentRangeFormattingParams ): CompletableFuture[ju.List[TextEdit]] = - getServiceFor(params.getTextDocument.getUri()).rangeFormatting(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .rangeFormatting(toLocal(params)) override def prepareRename( params: TextDocumentPositionParams ): CompletableFuture[lsp4j.Range] = - getServiceFor(params.getTextDocument.getUri()).prepareRename(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .prepareRename(toLocal(params)) override def rename(params: RenameParams): CompletableFuture[WorkspaceEdit] = - getServiceFor(params.getTextDocument.getUri()).rename(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .rename(toLocal(params)) override def references( params: ReferenceParams ): CompletableFuture[ju.List[Location]] = - getServiceFor(params.getTextDocument.getUri()).references(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .references(toLocal(params)) + .asScala + .map(_.asScala.map(toMetalsFS).asJava) + .asJava override def prepareCallHierarchy( params: CallHierarchyPrepareParams ): CompletableFuture[ju.List[CallHierarchyItem]] = - getServiceFor(params.getTextDocument.getUri()).prepareCallHierarchy(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .prepareCallHierarchy(toLocal(params)) + .asScala + .map( + _.asScala + .map { item => + if (clientConfig.isLibraryFileSystemSupported()) + item.setUri(toMetalsFSUri(item.getUri)) + item + } + .asJava + ) + .asJava override def callHierarchyIncomingCalls( params: CallHierarchyIncomingCallsParams ): CompletableFuture[ju.List[CallHierarchyIncomingCall]] = - getServiceFor(params.getItem.getUri).callHierarchyIncomingCalls(params) + getServiceFor(toLocal(params).getItem.getUri) + .callHierarchyIncomingCalls(toLocal(params)) + .asScala + .map( + _.asScala + .map { call => + if (clientConfig.isLibraryFileSystemSupported()) { + call.getFrom.setUri(toMetalsFSUri(call.getFrom.getUri)) + } + call + } + .asJava + ) + .asJava override def callHierarchyOutgoingCalls( params: CallHierarchyOutgoingCallsParams ): CompletableFuture[ju.List[CallHierarchyOutgoingCall]] = - getServiceFor(params.getItem.getUri).callHierarchyOutgoingCalls(params) + getServiceFor(toLocal(params).getItem.getUri) + .callHierarchyOutgoingCalls(toLocal(params)) + .asScala + .map( + _.asScala + .map { call => + if (clientConfig.isLibraryFileSystemSupported()) { + call.getTo.setUri(toMetalsFSUri(call.getTo.getUri)) + } + call + } + .asJava + ) + .asJava override def prepareTypeHierarchy( params: TypeHierarchyPrepareParams ): CompletableFuture[ju.List[TypeHierarchyItem]] = - getServiceFor(params.getTextDocument.getUri).prepareTypeHierarchy(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .prepareTypeHierarchy(toLocal(params)) + .asScala + .map( + _.asScala + .map { item => + if (clientConfig.isLibraryFileSystemSupported()) + item.setUri(toMetalsFSUri(item.getUri)) + item + } + .asJava + ) + .asJava override def typeHierarchySupertypes( params: TypeHierarchySupertypesParams ): CompletableFuture[ju.List[TypeHierarchyItem]] = - getServiceFor(params.getItem.getUri).typeHierarchySupertypes(params) + getServiceFor(toLocal(params).getItem.getUri) + .typeHierarchySupertypes(toLocal(params)) + .asScala + .map( + _.asScala + .map { item => + if (clientConfig.isLibraryFileSystemSupported()) + item.setUri(toMetalsFSUri(item.getUri)) + item + } + .asJava + ) + .asJava override def typeHierarchySubtypes( params: TypeHierarchySubtypesParams ): CompletableFuture[ju.List[TypeHierarchyItem]] = - getServiceFor(params.getItem.getUri).typeHierarchySubtypes(params) + getServiceFor(toLocal(params).getItem.getUri) + .typeHierarchySubtypes(toLocal(params)) + .asScala + .map( + _.asScala + .map { item => + if (clientConfig.isLibraryFileSystemSupported()) + item.setUri(toMetalsFSUri(item.getUri)) + item + } + .asJava + ) + .asJava override def completion( params: CompletionParams ): CompletableFuture[CompletionList] = - getServiceFor(params.getTextDocument.getUri).completion(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .completion(toLocal(params)) override def completionItemResolve( item: CompletionItem @@ -618,12 +912,14 @@ class WorkspaceLspService( override def signatureHelp( params: TextDocumentPositionParams ): CompletableFuture[SignatureHelp] = - getServiceFor(params.getTextDocument.getUri).signatureHelp(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .signatureHelp(toLocal(params)) override def codeAction( params: CodeActionParams ): CompletableFuture[ju.List[CodeAction]] = - getServiceFor(params.getTextDocument.getUri).codeAction(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .codeAction(toLocal(params)) override def codeActionResolve( codeAction: CodeAction @@ -637,28 +933,33 @@ class WorkspaceLspService( override def codeLens( params: CodeLensParams ): CompletableFuture[ju.List[CodeLens]] = - getServiceFor(params.getTextDocument.getUri).codeLens(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .codeLens(toLocal(params)) override def foldingRange( params: FoldingRangeRequestParams ): CompletableFuture[ju.List[FoldingRange]] = - getServiceFor(params.getTextDocument.getUri).foldingRange(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .foldingRange(toLocal(params)) override def selectionRange( params: SelectionRangeParams ): CompletableFuture[ju.List[SelectionRange]] = - getServiceFor(params.getTextDocument.getUri).selectionRange(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .selectionRange(toLocal(params)) override def semanticTokensFull( params: SemanticTokensParams ): CompletableFuture[SemanticTokens] = - getServiceFor(params.getTextDocument.getUri).semanticTokensFull(params) + getServiceFor(toLocal(params).getTextDocument.getUri) + .semanticTokensFull(toLocal(params)) override def workspaceSymbol( params: WorkspaceSymbolParams ): CompletableFuture[ju.List[lsp4j.SymbolInformation]] = CancelTokens.future { token => - collectSeq(_.workspaceSymbol(params, token))(_.flatten.asJava) + collectSeq(_.workspaceSymbol(params, token))(_.flatten) + .map(_.map(toMetalsFS).asJava) } override def willRenameFiles( @@ -765,10 +1066,11 @@ class WorkspaceLspService( params: TextDocumentPositionParams ): CompletableFuture[TreeViewNodeRevealResult] = Future { + val localParams = toLocal(params) treeView .reveal( - params.getTextDocument().getUri().toAbsolutePath, - params.getPosition(), + localParams.getTextDocument().getUri().toAbsolutePath, + localParams.getPosition(), ) .orNull }.asJava @@ -776,7 +1078,9 @@ class WorkspaceLspService( override def findTextInDependencyJars( params: FindTextInDependencyJarsRequest ): CompletableFuture[ju.List[Location]] = - collectSeq(_.findTextInDependencyJars(params))(_.flatten.asJava).asJava + collectSeq(_.findTextInDependencyJars(params))(_.flatten) + .map(_.map(toMetalsFS).asJava) + .asJava override def didCancelWorkDoneProgress( params: lsp4j.WorkDoneProgressCancelParams @@ -805,8 +1109,9 @@ class WorkspaceLspService( } uriOpt match { case Some(uri) => - setFocusedDocument(Some(uri.toAbsolutePath)) - getServiceFor(uri).didFocus(uri) + val localUri = toLocalUri(uri) + setFocusedDocument(Some(localUri.toAbsolutePath)) + getServiceFor(localUri).didFocus(localUri) case None => CompletableFuture.completedFuture(DidFocusResult.NoBuildTarget) } @@ -896,6 +1201,18 @@ class WorkspaceLspService( .getOrElse(fallbackService) .decodeFile(uri) .asJavaObject + case ServerCommands.FileSystemStat(uri) => + currentOrHeadOrFallback.lspFileSystemProvider + .getSystemStat(uri) + .asJavaObject + case ServerCommands.FileSystemReadDirectory(uri) => + currentOrHeadOrFallback.lspFileSystemProvider + .readDirectory(uri) + .asJavaObject + case ServerCommands.FileSystemReadFile(uri) => + currentOrHeadOrFallback.lspFileSystemProvider + .readFile(uri) + .asJavaObject case ServerCommands.DiscoverTestSuites(params) => Option(params.uri) match { case None => 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 c76e2e8cf5b..c6242188eb3 100644 --- a/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/tvp/MetalsTreeViewProvider.scala @@ -232,6 +232,7 @@ class MetalsTreeViewProvider( class FolderTreeViewProvider( folder: Folder, buildTargets: BuildTargets, + uriMapper: URIMapper, definitionIndex: GlobalSymbolIndex, userConfig: () => UserConfiguration, scalaVersionSelector: ScalaVersionSelector, @@ -265,8 +266,16 @@ class FolderTreeViewProvider( title = s"Libraries", folder = folder, id = identity, - encode = _.toURI.toString(), - decode = _.toAbsolutePath(followSymlink = false), + encode = path => { + val uri = path.toURI.toString() + if (clientConfig.isLibraryFileSystemSupported()) + uriMapper.convertToMetalsFS(uri) + else uri + }, + decode = uri => + uriMapper + .convertToLocal(uri) + .toAbsolutePath(followSymlink = false), valueTitle = path => { if (path.filename == JdkSources.zipFileName) { maybeUsedJdkVersion diff --git a/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala index 973185f725c..9157162bf0e 100644 --- a/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala @@ -137,7 +137,10 @@ class MetalsLanguageServer( .map(_.asScala) .toList .flatten - allFolders match { + val userFolders = allFolders.filterNot { folder => + Option(folder.getUri()).exists(_.startsWith("metalsfs:")) + } + userFolders match { case Nil => Option(params.getRootUri()) .orElse(Option(params.getRootPath())) From 3f9903e6ccf28394835aa83a29948809d7d1241d Mon Sep 17 00:00:00 2001 From: manykeys Date: Mon, 25 May 2026 23:54:53 +0500 Subject: [PATCH 2/2] Extract JarFileSystemCache, add per-category indexes with rebuild on indexing, read JDK name from JAVA_HOME/release, make readFile non-blocking (flatMap, no Await), close Files.list via Using.resource, add non-exhaustive match fallbacks, move isUriEqual to MetalsEnrichments --- .../metals/mcp/StandaloneMcpService.scala | 7 + .../metals/FallbackMetalsLspService.scala | 4 + .../internal/metals/FileDecoderProvider.scala | 6 +- .../meta/internal/metals/IndexProviders.scala | 3 +- .../scala/meta/internal/metals/Indexer.scala | 1 + .../metals/InitializationOptions.scala | 1 + .../internal/metals/JarFileSystemCache.scala | 66 +++ .../metals/LSPFileSystemProvider.scala | 73 +-- .../internal/metals/MetalsEnrichments.scala | 4 + .../internal/metals/MetalsLspService.scala | 11 +- .../metals/ProjectMetalsLspService.scala | 5 +- .../meta/internal/metals/TargetData.scala | 11 +- .../meta/internal/metals/URIMapper.scala | 470 ++++++++++-------- .../internal/metals/WorkspaceLspService.scala | 16 +- 14 files changed, 402 insertions(+), 276 deletions(-) create mode 100644 metals/src/main/scala/scala/meta/internal/metals/JarFileSystemCache.scala diff --git a/metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/StandaloneMcpService.scala b/metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/StandaloneMcpService.scala index 90addea990e..e0483692c12 100644 --- a/metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/StandaloneMcpService.scala +++ b/metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/StandaloneMcpService.scala @@ -103,6 +103,11 @@ class StandaloneMcpService( initialUserConfig = initialUserConfig.getOrElse(UserConfiguration.default) ) + private val jarFileSystemCache: JarFileSystemCache = new JarFileSystemCache + + private val uriMapper: WorkspaceURIMapper = + new WorkspaceURIMapper(() => Seq(projectMetalsLspService.folderUriMapper)) + lazy val projectMetalsLspService = new ProjectMetalsLspService( ec, scheduledExecutor, @@ -121,6 +126,8 @@ class StandaloneMcpService( workDoneProgress, maxScalaCliServers = 3, moduleStatus, + jarFileSystemCache, + uriMapper, ) /** diff --git a/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala index 8e327613fe9..7ea89c90366 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FallbackMetalsLspService.scala @@ -41,6 +41,8 @@ class FallbackMetalsLspService( override val workDoneProgress: WorkDoneProgress, bspStatus: BspStatus, moduleStatus: ModuleStatus, + jarFileSystemCache: JarFileSystemCache, + uriMapper: WorkspaceURIMapper, ) extends MetalsLspService( ec, sh, @@ -58,6 +60,8 @@ class FallbackMetalsLspService( workDoneProgress, maxScalaCliServers = 10, moduleStatus, + jarFileSystemCache, + uriMapper, ) { val buildServerPromise: Promise[Unit] = Promise.successful(()) 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 5c41bca1aa5..80cf07c5010 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FileDecoderProvider.scala @@ -171,7 +171,11 @@ final class FileDecoderProvider( case "metalsDecode" => decodedFileContents(uri.getSchemeSpecificPart()) case "metalsfs" => - decodedFileContents(uriMapper.convertToLocal(uriAsStr)) + Try(uriMapper.convertToLocal(uriAsStr)) match { + case Success(local) => decodedFileContents(local) + case Failure(e) => + Future.successful(DecoderResponse.failed(uri, e)) + } case _ => Future.successful( DecoderResponse.failed( diff --git a/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala b/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala index 2e3f949e18a..37144accec9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/IndexProviders.scala @@ -29,7 +29,8 @@ trait IndexProviders { def referencesProvider: ReferenceProvider def workspaceSymbols: WorkspaceSymbolProvider def buildTargets: BuildTargets - def uriMapper: URIMapper + def uriMapper: WorkspaceURIMapper + def folderUriMapper: FolderURIMapper def lspFileSystemProvider: LSPFileSystemProvider def semanticDBIndexer: SemanticdbIndexer def fileWatcher: FileWatcher 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 c4436432445..00532ee7514 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala @@ -204,6 +204,7 @@ case class Indexer(indexProviders: IndexProviders)(implicit rc: ReportContext) { ) } if (clientConfig.isLibraryFileSystemSupported()) { + folderUriMapper.rebuildIndexes() lspFileSystemProvider.sendLibraryFileSystemReady() } // Schedule removal of unused toplevel symbols from cache 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 c22509f44f6..d65debe2d89 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala @@ -129,6 +129,7 @@ object InitializationOptions { None, None, None, + None, ) def from( diff --git a/metals/src/main/scala/scala/meta/internal/metals/JarFileSystemCache.scala b/metals/src/main/scala/scala/meta/internal/metals/JarFileSystemCache.scala new file mode 100644 index 00000000000..116cec4c977 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/JarFileSystemCache.scala @@ -0,0 +1,66 @@ +package scala.meta.internal.metals + +import java.net.URI +import java.nio.file.FileSystem +import java.nio.file.FileSystemAlreadyExistsException +import java.nio.file.FileSystemNotFoundException +import java.nio.file.FileSystems + +import scala.collection.mutable +import scala.util.control.NonFatal + +import scala.meta.io.AbsolutePath + +final case class FileSystemInfo(fs: FileSystem, fileUri: String) + +final class JarFileSystemCache { + + private val openFileSystems = mutable.Map.empty[URI, FileSystem] + + /** + * Synchronized to avoid a race where two threads both call newFileSystem + * for the same archive. + */ + def open(localPath: AbsolutePath): FileSystemInfo = synchronized { + val fileUri = localPath.toNIO.toUri.toString.stripSuffix("/") + val zipURI = JarFileSystemCache.jarUriFor(localPath) + val fs = openFileSystems.getOrElseUpdate(zipURI, openFileSystem(zipURI)) + FileSystemInfo(fs, fileUri) + } + + private def openFileSystem(zipURI: URI): FileSystem = + try FileSystems.getFileSystem(zipURI) + catch { + case _: FileSystemNotFoundException => + try + FileSystems + .newFileSystem(zipURI, new java.util.HashMap[String, Any]) + catch { + case _: FileSystemAlreadyExistsException => + FileSystems.getFileSystem(zipURI) + } + } + + def closeObsolete(knownPaths: Set[AbsolutePath]): Unit = synchronized { + val knownUris = knownPaths.map(JarFileSystemCache.jarUriFor) + val obsoleteUris = openFileSystems.keysIterator.filterNot(knownUris).toList + obsoleteUris.foreach(close) + } + + def close(zipURI: URI): Unit = synchronized { + openFileSystems.remove(zipURI).foreach { fs => + try fs.close() + catch { + case NonFatal(e) => + scribe.warn(s"Failed to close jar file system $zipURI", e) + } + } + } +} + +object JarFileSystemCache { + def jarUriFor(localPath: AbsolutePath): URI = { + val fileUri = localPath.toNIO.toUri.toString.stripSuffix("/") + URI.create(s"jar:$fileUri") + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala index 2c635cd42d0..0f2314fe068 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala @@ -4,10 +4,9 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.util.stream.Collectors -import scala.concurrent.Await import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt +import scala.util.Using import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.metals.clients.language.MetalsLanguageClient @@ -40,16 +39,13 @@ class LSPFileSystemProvider( languageClient: MetalsLanguageClient, uriMapper: URIMapper, fileDecoderProvider: FileDecoderProvider, - clientConfig: ClientConfiguration, )(implicit ec: ExecutionContext) { - /** Notifies the client that the library file system is ready for use. */ + /** Callers must gate on [[ClientConfiguration.isLibraryFileSystemSupported]]. */ def sendLibraryFileSystemReady(): Unit = { - if (clientConfig.isLibraryFileSystemSupported()) { - val params = - ClientCommands.LibraryFileSystemReady.toExecuteCommandParams() - languageClient.metalsExecuteClientCommand(params) - } + val params = + ClientCommands.LibraryFileSystemReady.toExecuteCommandParams() + languageClient.metalsExecuteClientCommand(params) } /** @@ -83,17 +79,18 @@ class LSPFileSystemProvider( case _ => val (fs, innerPath) = resolveInnerPath(uri) val path = fs.fs.getPath(innerPath.getOrElse("/")) - Files - .list(path) - .collect(Collectors.toList()) - .asScala - .map(p => - FSReadDirectoryResponse( - p.getFileName.toString, - isFile = Files.isRegularFile(p), + Using.resource(Files.list(path)) { stream => + stream + .collect(Collectors.toList()) + .asScala + .map(p => + FSReadDirectoryResponse( + p.getFileName.toString, + isFile = Files.isRegularFile(p), + ) ) - ) - .toArray + .toArray + } } FSReadDirectoriesResponse(uri, entries, "") } @@ -104,19 +101,23 @@ class LSPFileSystemProvider( * For `.class` files the bytecode is decompiled via CFR through * [[FileDecoderProvider]]. All other files are read as UTF-8 text. */ - def readFile(uri: String): Future[FSReadFileResponse] = Future { - val (fs, innerPath) = resolveInnerPath(uri) - val path = fs.fs.getPath(innerPath.getOrElse("/")) - val contents = - if (path.getFileName.toString.endsWith(".class")) { - val fut = fileDecoderProvider.decodedFileContents(path.toUri.toString) - val res = Await.result(fut, 10.minutes) - Option(res.value).filter(_.nonEmpty).getOrElse(res.error) - } else { - new String(Files.readAllBytes(path), StandardCharsets.UTF_8) - } - FSReadFileResponse(uri, contents, "") - } + def readFile(uri: String): Future[FSReadFileResponse] = + Future(resolveInnerPath(uri)).flatMap { case (fs, innerPath) => + val path = fs.fs.getPath(innerPath.getOrElse("/")) + if (path.getFileName.toString.endsWith(".class")) + fileDecoderProvider.decodedFileContents(path.toUri.toString).map { + res => + val contents = Option(res.value).filter(_.nonEmpty).getOrElse("") + val error = Option(res.error).getOrElse("") + FSReadFileResponse(uri, contents, error) + } + else + Future { + val contents = + new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + FSReadFileResponse(uri, contents, "") + } + } /** * Returns metadata for the given URI, indicating whether it @@ -151,9 +152,8 @@ class LSPFileSystemProvider( */ private def resolveInnerPath( uri: String - ): (FileSystemInfo, Option[String]) = { - val decoded = URIEncoderDecoder.decode(uri) - decoded match { + ): (FileSystemInfo, Option[String]) = + URIEncoderDecoder.decode(uri) match { case jdk if jdk.startsWith(URIMapper.jdkURI) => val (name, path) = URIMapper.getURIParts(jdk, URIMapper.jdkURI) (uriMapper.getJDKFileSystem(name), path) @@ -165,6 +165,7 @@ class LSPFileSystemProvider( val (name, path) = URIMapper.getURIParts(src, URIMapper.sourceJarURI) (uriMapper.getSourceJarFileSystem(name), path) + case other => + throw new IllegalArgumentException(s"Unknown metalsfs URI: $other") } - } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala index acb8ea4fefe..2d2c0c0a773 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala @@ -711,6 +711,10 @@ object MetalsEnrichments implicit class XtensionString(value: String) { + /** Case-insensitive on Windows where e.g. `Coursier/Cache` and `Coursier/cache` are equivalent. */ + def isUriEqual(other: String): Boolean = + if (Properties.isWin) value.equalsIgnoreCase(other) else value == other + /** * Returns true if this is a Scala.js or Scala Native target * diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala index fad05c6a4d0..23b90f52938 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -113,6 +113,8 @@ abstract class MetalsLspService( val workDoneProgress: WorkDoneProgress, maxScalaCliServers: Int, moduleStatus: ModuleStatus, + jarFileSystemCache: JarFileSystemCache, + val uriMapper: WorkspaceURIMapper, ) extends Folder(folder, folderVisibleName, isKnownMetalsProject = true) with Cancelable with TextDocumentService @@ -167,8 +169,12 @@ abstract class MetalsLspService( val buildTargets: BuildTargets = BuildTargets.from(folder, mainBuildTargetsData, tables) - val uriMapper: URIMapper = - URIMapper(buildTargets, () => userConfig.javaHome) + val folderUriMapper: FolderURIMapper = + new FolderURIMapper( + buildTargets, + () => userConfig.javaHome, + jarFileSystemCache, + ) implicit val reports: StdReportContext = new StdReportContext( folder.toNIO, @@ -611,7 +617,6 @@ abstract class MetalsLspService( languageClient, uriMapper, fileDecoderProvider, - clientConfig, ) def loadedPresentationCompilerCount(): Int = diff --git a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala index a82ba68568d..88dd8e69ea9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala @@ -63,6 +63,8 @@ class ProjectMetalsLspService( override val workDoneProgress: WorkDoneProgress, maxScalaCliServers: Int, moduleStatus: ModuleStatus, + jarFileSystemCache: JarFileSystemCache, + uriMapper: WorkspaceURIMapper, ) extends MetalsLspService( ec, sh, @@ -80,6 +82,8 @@ class ProjectMetalsLspService( workDoneProgress, maxScalaCliServers, moduleStatus, + jarFileSystemCache, + uriMapper, ) { scribe.debug(clientConfig.toString()) @@ -938,5 +942,4 @@ class ProjectMetalsLspService( super.resetService() treeView.reset() } - } 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 cdb9e8d0b77..e1826fd4b04 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/TargetData.scala @@ -12,7 +12,6 @@ import scala.collection.mutable.{Map => MMap} import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.concurrent.Promise -import scala.util.Properties import scala.meta.inputs.Input import scala.meta.internal.metals.MetalsEnrichments._ @@ -220,18 +219,10 @@ final class TargetData() { case Some(id) => buildTargetDependencyModules.get(id).iterator.flatten } - /** - * For windows file:///C:/Users/runneradmin/AppData/Local/Coursier/Cache and - * file:///C:/Users/runneradmin/AppData/Local/Coursier/cache is equivalent - */ - def isUriEqual(uri: String, otherUri: String) = { - Properties.isWin && uri.toLowerCase() == otherUri - .toLowerCase() || uri == otherUri - } val allFound = for { module <- depModules artifacts = module.getArtifacts().asScala - if artifacts.exists(artifact => isUriEqual(artifact.getUri(), jarUri)) + if artifacts.exists(_.getUri().isUriEqual(jarUri)) foundJar <- artifacts.find(_.getClassifier() == classifier) foundJarPath = foundJar.getUri().toAbsolutePath if foundJarPath.exists diff --git a/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala index 6c826bc066f..4cdb997e3d9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala @@ -1,13 +1,13 @@ 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.Files import java.nio.file.Path +import java.util.Properties +import java.util.concurrent.atomic.AtomicReference -import scala.annotation.tailrec -import scala.util.Properties +import scala.util.Using +import scala.util.control.NonFatal import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.mtags.URIEncoderDecoder @@ -15,273 +15,305 @@ 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 /** * Bidirectional mapper between virtual `metalsfs://` URIs exposed to * the LSP client and local `jar:file://` URIs used internally by Metals. * - * Maintains lazy NIO file system handles for JAR archives and provides - * overloaded conversions for common LSP4J parameter types. + * `try*` variants return `None` when the receiving mapper does not own + * the archive in question, so an aggregator can fall through to another + * folder's mapper. */ -final case class URIMapper( - buildTargets: BuildTargets, - userJavaHome: () => Option[String], -) { +trait URIMapper { - private lazy val isWindows: Boolean = Properties.isWin + def convertToLocal(uri: String): String + def convertToMetalsFS(uri: String): String - private def changeCase(uri: String): String = - if (isWindows) uri.toLowerCase() else uri + def tryConvertToLocal(uri: String): Option[String] + def tryConvertToMetalsFS(uri: String): Option[String] - /** - * Walks up the path to find a meaningful JDK directory name, - * skipping intermediate segments like `lib` or `src.zip`. - */ - @tailrec - private def getDecentJDKName(path: Path): String = - if ( - path.getParent == null || !List("lib", "src.zip").contains(path.filename) + def getJDKs: Iterator[String] + def getWorkspaceJars: Iterator[String] + def getSourceJars: Iterator[String] + + def tryGetJDKFileSystem(name: String): Option[FileSystemInfo] + def tryGetWorkspaceJarFileSystem(name: String): Option[FileSystemInfo] + def tryGetSourceJarFileSystem(name: String): Option[FileSystemInfo] + + final def getJDKFileSystem(name: String): FileSystemInfo = + tryGetJDKFileSystem(name).getOrElse( + throw new NoSuchElementException(s"JDK not found: $name") ) - path.filename - else - getDecentJDKName(path.getParent) - private def jdkSources: Option[AbsolutePath] = - JdkSources(userJavaHome()).toOption + final def getWorkspaceJarFileSystem(name: String): FileSystemInfo = + tryGetWorkspaceJarFileSystem(name).getOrElse( + throw new NoSuchElementException(s"Workspace jar not found: $name") + ) - /** Returns the display names of all available JDK source archives. */ - def getJDKs: Iterator[String] = - jdkSources.iterator.map(jdk => getDecentJDKName(jdk.toNIO)) + final def getSourceJarFileSystem(name: String): FileSystemInfo = + tryGetSourceJarFileSystem(name).getOrElse( + throw new NoSuchElementException(s"Source jar not found: $name") + ) - /** Returns the filenames of all workspace dependency JARs. */ - def getWorkspaceJars: Iterator[String] = - buildTargets.allWorkspaceJars.map(_.filename) + final def convertToMetalsFS( + symbolInformation: SymbolInformation + ): SymbolInformation = { + val symbolInfo = new SymbolInformation( + symbolInformation.getName, + symbolInformation.getKind, + convertToMetalsFS(symbolInformation.getLocation), + symbolInformation.getContainerName, + ) + symbolInfo.setTags(symbolInformation.getTags) + symbolInfo + } - /** Returns the filenames of all source JARs. */ - def getSourceJars: Iterator[String] = - buildTargets.allSourceJars.map(_.filename) + final def convertToMetalsFS(location: Location): Location = + new Location(convertToMetalsFS(location.getUri), location.getRange) + + final def convertToLocal( + params: TextDocumentIdentifier + ): TextDocumentIdentifier = + new TextDocumentIdentifier(convertToLocal(params.getUri)) + + final def convertToLocal(params: HoverExtParams): HoverExtParams = + params.copy(textDocument = convertToLocal(params.textDocument)) + + final def convertToLocal(params: CodeActionParams): CodeActionParams = + new CodeActionParams( + convertToLocal(params.getTextDocument()), + params.getRange(), + params.getContext(), + ) +} + +/** + * Per-folder [[URIMapper]] backed by that folder's [[BuildTargets]] and + * the shared [[JarFileSystemCache]]. + */ +final class FolderURIMapper( + buildTargets: BuildTargets, + userJavaHome: () => Option[String], + jarFileSystemCache: JarFileSystemCache, +) extends URIMapper { + + private val emptyIndex: Map[String, AbsolutePath] = Map.empty + private val jdkIndex = new AtomicReference(emptyIndex) + private val workspaceJarIndex = new AtomicReference(emptyIndex) + private val sourceJarIndex = new AtomicReference(emptyIndex) /** - * Returns an existing NIO [[FileSystem]] for the given archive, - * creating one if it does not yet exist. + * Derives a display name for a JDK source archive (`src.zip`) by reading + * `JAVA_VERSION`/`IMPLEMENTOR` from the `release` file in `JAVA_HOME`. + * Falls back to the JDK home directory name when `release` is missing + * (e.g. JDKs older than Java 9). */ - private def getOrCreateFileSystem(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) + private def getDecentJDKName(srcZipPath: Path): String = { + val jdkHome = Option(srcZipPath.getParent).map { parent => + if (parent.filename == "lib") parent.getParent else parent + } + jdkHome + .flatMap(readReleaseName) + .orElse(jdkHome.map(_.filename)) + .getOrElse("JDK") } - private def findJdkByName(name: String): Option[AbsolutePath] = - jdkSources.find(jdk => getDecentJDKName(jdk.toNIO) == name) + private def readReleaseName(jdkHome: Path): Option[String] = { + val release = jdkHome.resolve("release") + if (!Files.exists(release)) None + else { + val props = new Properties() + val loaded = + try { + Using.resource(Files.newBufferedReader(release))(props.load) + true + } catch { + case NonFatal(e) => + scribe.warn(s"Failed to read JDK release file at $release", e) + false + } + if (!loaded) None + else { + val version = Option(props.getProperty("JAVA_VERSION")).map(unquote) + val implementor = Option(props.getProperty("IMPLEMENTOR")).map(unquote) + (implementor, version) match { + case (Some(impl), Some(ver)) => Some(s"$impl $ver") + case (_, Some(ver)) => Some(s"JDK $ver") + case _ => None + } + } + } + } - private def findWorkspaceJarByName(name: String): Option[AbsolutePath] = - buildTargets.allWorkspaceJars.find(_.filename == name) + private def unquote(s: String): String = + s.stripPrefix("\"").stripSuffix("\"") - private def findSourceJarByName(name: String): Option[AbsolutePath] = - buildTargets.allSourceJars.find(_.filename == name) + private def jdkSources: Option[AbsolutePath] = + JdkSources(userJavaHome()).toOption - /** Opens the NIO file system for a JDK source archive identified by name. */ - def getJDKFileSystem(name: String): FileSystemInfo = - findJdkByName(name) - .map(getOrCreateFileSystem) - .getOrElse(throw new NoSuchElementException(s"JDK not found: $name")) + /** Call after indexing the build so listings reflect current state. */ + def rebuildIndexes(): Unit = synchronized { + val newJdk = + jdkSources.iterator.map(j => getDecentJDKName(j.toNIO) -> j).toMap + val newWs = buildTargets.allWorkspaceJars.map(j => j.filename -> j).toMap + val newSrc = buildTargets.allSourceJars.map(j => j.filename -> j).toMap - /** Opens the NIO file system for a workspace JAR identified by filename. */ - def getWorkspaceJarFileSystem(name: String): FileSystemInfo = - findWorkspaceJarByName(name) - .map(getOrCreateFileSystem) - .getOrElse( - throw new NoSuchElementException(s"Workspace jar not found: $name") - ) + val newKnownPaths = + newJdk.values.toSet ++ newWs.values.toSet ++ newSrc.values.toSet + jarFileSystemCache.closeObsolete(newKnownPaths) - /** Opens the NIO file system for a source JAR identified by filename. */ - def getSourceJarFileSystem(name: String): FileSystemInfo = - findSourceJarByName(name) - .map(getOrCreateFileSystem) - .getOrElse( - throw new NoSuchElementException(s"Source jar not found: $name") - ) + jdkIndex.set(newJdk) + workspaceJarIndex.set(newWs) + sourceJarIndex.set(newSrc) + } - /** - * Determines the virtual category (jdk, jar, or source) for a local - * `file://` JAR path by matching it against known build dependencies. - * - * @param decodedJarPath a decoded `file:///path/to/some.jar` string - * @return the corresponding `metalsfs:/metalsLibraries//` - * prefix, or `None` if the path is not a known dependency - */ - private def classifyLocalUri( - decodedJarPath: String - ): Option[String] = { - val path = + def getJDKs: Iterator[String] = jdkIndex.get().keys.iterator + def getWorkspaceJars: Iterator[String] = + workspaceJarIndex.get().keys.iterator + def getSourceJars: Iterator[String] = sourceJarIndex.get().keys.iterator + + def tryGetJDKFileSystem(name: String): Option[FileSystemInfo] = + jdkIndex.get().get(name).map(jarFileSystemCache.open) + def tryGetWorkspaceJarFileSystem(name: String): Option[FileSystemInfo] = + workspaceJarIndex.get().get(name).map(jarFileSystemCache.open) + def tryGetSourceJarFileSystem(name: String): Option[FileSystemInfo] = + sourceJarIndex.get().get(name).map(jarFileSystemCache.open) + + private def classifyLocalUri(decodedJarPath: String): Option[String] = { + val pathOpt = try Some(AbsolutePath(java.nio.file.Paths.get(new URI(decodedJarPath)))) - catch { case _: Exception => None } - - path.flatMap { absPath => - val filename = absPath.filename - jdkSources match { - case Some(jdk) - if changeCase( - absPath.toURI.toString.stripSuffix("/") - ) == changeCase(jdk.toURI.toString.stripSuffix("/")) => - val name = getDecentJDKName(jdk.toNIO) - Some(s"${URIMapper.jdkURI}/$name") - case _ => - if ( - buildTargets.allWorkspaceJars.exists(j => - changeCase(j.toURI.toString.stripSuffix("/")) == changeCase( - absPath.toURI.toString.stripSuffix("/") - ) - ) - ) - Some(s"${URIMapper.workspaceJarURI}/$filename") - else if ( - buildTargets.allSourceJars.exists(j => - changeCase(j.toURI.toString.stripSuffix("/")) == changeCase( - absPath.toURI.toString.stripSuffix("/") - ) - ) + catch { + case NonFatal(e) => + scribe.warn( + s"Failed to parse $decodedJarPath while classifying local URI", + e, ) - Some(s"${URIMapper.sourceJarURI}/$filename") - else - None + None + } + + pathOpt.flatMap { path => + val absPathUri = path.toURI.toString.stripSuffix("/") + val matchesJdk = jdkSources.exists { jdk => + absPathUri.isUriEqual(jdk.toURI.toString.stripSuffix("/")) } + if (matchesJdk) + jdkSources + .map(jdk => s"${URIMapper.jdkURI}/${getDecentJDKName(jdk.toNIO)}") + else if ( + buildTargets.allWorkspaceJars + .exists(j => absPathUri.isUriEqual(j.toURI.toString.stripSuffix("/"))) + ) + Some(s"${URIMapper.workspaceJarURI}/${path.filename}") + else if ( + buildTargets.allSourceJars + .exists(j => absPathUri.isUriEqual(j.toURI.toString.stripSuffix("/"))) + ) + Some(s"${URIMapper.sourceJarURI}/${path.filename}") + else + None } } /** - * Percent-encodes the given URI according to its scheme. - * - * `metalsfs:` URIs are returned as-is because constructing them via - * `new URI("metalsfs", "", path, null)` would produce triple slashes. + * `metalsfs:` URIs are returned as-is — `new URI("metalsfs", "", path, null)` + * would otherwise produce triple slashes. */ - 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:")) { - uri - } else - throw new IllegalStateException(s"Why here? $uri") + def encodeUri(uri: String): String = uri match { + case s if s.startsWith("file://") => + new URI("file", "", s.stripPrefix("file://"), null).toString + case s if s.startsWith("jar:") => + new URI("jar", s.stripPrefix("jar:"), null).toString + case s if s.startsWith("metalsfs:") => + s + case _ => + throw new IllegalArgumentException(s"Unsupported URI scheme: $uri") } - /** - * Converts a virtual `metalsfs://` URI into a local `jar:file://` URI. - * - * If the URI points to an archive root (no inner path), the plain - * `file://` URI of the archive is returned. Otherwise the full - * `jar:file:///archive.jar!/inner/path` form is produced. - * The returned URI is already percent-encoded by `Path#toUri`. - */ - def convertToLocal(uri: String): String = { - if (uri.startsWith(URIMapper.parentURI)) { - val (fs, fsPath) = URIEncoderDecoder.decode(uri) match { + def convertToLocal(uri: String): String = + tryConvertToLocal(uri).getOrElse(uri) + + def convertToMetalsFS(uri: String): String = + tryConvertToMetalsFS(uri).getOrElse(encodeUri(uri)) + + def tryConvertToLocal(uri: String): Option[String] = { + if (!uri.startsWith(URIMapper.parentURI)) None + else { + val decoded = URIEncoderDecoder.decode(uri) + val resolved = decoded 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) => + tryGetJDKFileSystem(name).map(fs => (fs, remaining)) + case ws if ws.startsWith(URIMapper.workspaceJarURI) => val (name, remaining) = - URIMapper.getURIParts(workspaceJar, URIMapper.workspaceJarURI) - val fs = getWorkspaceJarFileSystem(name) - (fs, remaining) - case sourceJar if sourceJar.startsWith(URIMapper.sourceJarURI) => + URIMapper.getURIParts(ws, URIMapper.workspaceJarURI) + tryGetWorkspaceJarFileSystem(name).map(fs => (fs, remaining)) + case src if src.startsWith(URIMapper.sourceJarURI) => val (name, remaining) = - URIMapper.getURIParts(sourceJar, URIMapper.sourceJarURI) - val fs = getSourceJarFileSystem(name) - (fs, remaining) + URIMapper.getURIParts(src, URIMapper.sourceJarURI) + tryGetSourceJarFileSystem(name).map(fs => (fs, remaining)) + case _ => None } - if (fsPath.isEmpty) fs.fileUri - else fs.fs.getPath(fsPath.get).toUri.toString - } else uri - } - - /** - * Converts a local `jar:file://` URI into a virtual `metalsfs://` URI - * by classifying the archive and rewriting the path. - */ - 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) { - classifyLocalUri(path).getOrElse(path) - } else { - val remainder = path.substring(splitter + 1) - val localJarPath = path.substring(0, splitter) - val metalsJarPath = - classifyLocalUri(localJarPath).getOrElse(localJarPath) - s"${metalsJarPath}${remainder}" + resolved.map { case (fs, fsPath) => + fsPath.fold(fs.fileUri)(p => fs.fs.getPath(p).toUri.toString) } - } else { - classifyLocalUri(decodedURI).getOrElse(decodedURI) } - encodeUri(metalsfsUri) } - /** Rewrites the location URI inside a [[SymbolInformation]] to `metalsfs://`. */ - def convertToMetalsFS( - symbolInformation: SymbolInformation - ): SymbolInformation = { - val symbolInfo = new SymbolInformation( - symbolInformation.getName, - symbolInformation.getKind, - convertToMetalsFS(symbolInformation.getLocation), - symbolInformation.getContainerName, - ) - symbolInfo.setTags(symbolInformation.getTags) - symbolInfo + def tryConvertToMetalsFS(uri: String): Option[String] = { + val decodedURI = URIEncoderDecoder.decode(uri) + val metalsfsUri = + if (uri.startsWith("jar:")) { + val path = decodedURI.stripPrefix("jar:") + val splitter = path.indexOf('!') + if (splitter == -1) classifyLocalUri(path) + else { + val remainder = path.substring(splitter + 1) + val localJarPath = path.substring(0, splitter) + classifyLocalUri(localJarPath).map(prefix => s"$prefix$remainder") + } + } else classifyLocalUri(decodedURI) + metalsfsUri.map(encodeUri) } +} - def convertToMetalsFS(location: Location): Location = - new Location(convertToMetalsFS(location.getUri), location.getRange) +/** + * Aggregating [[URIMapper]] that delegates to per-folder mappers, + * returning the first non-empty match. Used by the workspace-level + * service so requests from any folder are handled correctly. + */ +final class WorkspaceURIMapper(folders: () => Iterable[URIMapper]) + extends URIMapper { - def convertToLocal( - params: TextDocumentIdentifier - ): TextDocumentIdentifier = - new TextDocumentIdentifier(convertToLocal(params.getUri)) + private def tryAll[A](f: URIMapper => Option[A]): Option[A] = + folders().iterator.flatMap(m => f(m).iterator).nextOption() - def convertToLocal( - params: HoverExtParams - ): HoverExtParams = - params.copy(textDocument = convertToLocal(params.textDocument)) + def convertToLocal(uri: String): String = + tryConvertToLocal(uri).getOrElse(uri) + def convertToMetalsFS(uri: String): String = + tryConvertToMetalsFS(uri).getOrElse(uri) - def convertToLocal( - params: CodeActionParams - ): CodeActionParams = - new CodeActionParams( - convertToLocal(params.getTextDocument()), - params.getRange(), - params.getContext(), - ) -} + def tryConvertToLocal(uri: String): Option[String] = + tryAll(_.tryConvertToLocal(uri)) + def tryConvertToMetalsFS(uri: String): Option[String] = + tryAll(_.tryConvertToMetalsFS(uri)) -/** Handle to an open NIO [[FileSystem]] together with its source `file://` URI. */ -final case class FileSystemInfo(fs: FileSystem, fileUri: String) + def getJDKs: Iterator[String] = folders().iterator.flatMap(_.getJDKs).distinct + def getWorkspaceJars: Iterator[String] = + folders().iterator.flatMap(_.getWorkspaceJars).distinct + def getSourceJars: Iterator[String] = + folders().iterator.flatMap(_.getSourceJars).distinct + + def tryGetJDKFileSystem(name: String): Option[FileSystemInfo] = + tryAll(_.tryGetJDKFileSystem(name)) + def tryGetWorkspaceJarFileSystem(name: String): Option[FileSystemInfo] = + tryAll(_.tryGetWorkspaceJarFileSystem(name)) + def tryGetSourceJarFileSystem(name: String): Option[FileSystemInfo] = + tryAll(_.tryGetSourceJarFileSystem(name)) +} -/** - * Constants and utilities for the `metalsfs://` virtual file system URI scheme. - * - * VS Code normalises `metalsfs:///` to `metalsfs:/`, so all paths - * use the single-slash form. - */ +/** Single-slash form because VS Code normalises `metalsfs:///` to `metalsfs:/`. */ object URIMapper { val rootDir: String = "metalsLibraries" val parentURI: String = s"metalsfs:/$rootDir" diff --git a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala index d084e7e2449..ae421661ac4 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala @@ -127,10 +127,12 @@ class WorkspaceLspService( serverInputs.initialServerConfig, initializeParams, ) - private def uriMapper: URIMapper = - folderServices.headOption - .map(_.uriMapper) - .getOrElse(fallbackService.uriMapper) + + private val jarFileSystemCache: JarFileSystemCache = new JarFileSystemCache + + private val uriMapper: WorkspaceURIMapper = new WorkspaceURIMapper(() => + folderServices.map(_.folderUriMapper) :+ fallbackService.folderUriMapper + ) private val languageClient = { val languageClient = @@ -243,6 +245,8 @@ class WorkspaceLspService( workDoneProgress, bspStatus, moduleStatus, + jarFileSystemCache, + uriMapper, ) } @@ -267,6 +271,8 @@ class WorkspaceLspService( workDoneProgress, maxScalaCliServers = 3, moduleStatus, + jarFileSystemCache, + uriMapper, ) } @@ -357,7 +363,7 @@ class WorkspaceLspService( def getServiceForOpt(uri: String): Option[ProjectMetalsLspService] = { // "metalsDecode" prefix is used for showing special files and is not an actual file system - val strippedUri = uriMapper.convertToLocal(uri).stripPrefix("metalsDecode:") + val strippedUri = uriMapper.convertToLocal(uri.stripPrefix("metalsDecode:")) for { path <- strippedUri.toAbsolutePathSafe() service <-