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/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..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(()) @@ -68,6 +72,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..80cf07c5010 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,12 @@ final class FileDecoderProvider( case "file" => decodeMetalsFile(uri) case "metalsDecode" => decodedFileContents(uri.getSchemeSpecificPart()) + case "metalsfs" => + 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 74535d64f08..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,6 +29,9 @@ trait IndexProviders { def referencesProvider: ReferenceProvider def workspaceSymbols: WorkspaceSymbolProvider def buildTargets: BuildTargets + def uriMapper: WorkspaceURIMapper + def folderUriMapper: FolderURIMapper + 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..00532ee7514 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,10 @@ case class Indexer(indexProviders: IndexProviders)(implicit rc: ReportContext) { buildTool.importedBuild.dependencySources, ) } + if (clientConfig.isLibraryFileSystemSupported()) { + folderUriMapper.rebuildIndexes() + 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..d65debe2d89 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], @@ -128,6 +129,7 @@ object InitializationOptions { None, None, None, + None, ) def from( @@ -171,6 +173,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/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 new file mode 100644 index 00000000000..0f2314fe068 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/LSPFileSystemProvider.scala @@ -0,0 +1,171 @@ +package scala.meta.internal.metals + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.stream.Collectors + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.util.Using + +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, +)(implicit ec: ExecutionContext) { + + /** Callers must gate on [[ClientConfiguration.isLibraryFileSystemSupported]]. */ + def sendLibraryFileSystemReady(): Unit = { + 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("/")) + Using.resource(Files.list(path)) { stream => + stream + .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(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 + * 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]) = + URIEncoderDecoder.decode(uri) 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) + 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 5726dc2727e..3ad0bd47ec3 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,6 +169,13 @@ abstract class MetalsLspService( val buildTargets: BuildTargets = BuildTargets.from(folder, mainBuildTargetsData, tables) + val folderUriMapper: FolderURIMapper = + new FolderURIMapper( + buildTargets, + () => userConfig.javaHome, + jarFileSystemCache, + ) + implicit val reports: StdReportContext = new StdReportContext( folder.toNIO, _.flatMap { uri => @@ -603,6 +612,13 @@ abstract class MetalsLspService( protected def fileDecoderProvider: FileDecoderProvider + override lazy val lspFileSystemProvider: LSPFileSystemProvider = + new LSPFileSystemProvider( + languageClient, + uriMapper, + fileDecoderProvider, + ) + 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 8e95be3ad1f..fa4278413bb 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()) @@ -129,6 +133,7 @@ class ProjectMetalsLspService( folder, compilers, buildTargets, + uriMapper, () => userConfig, shellRunner, optFileSystemSemanticdbs, @@ -687,6 +692,7 @@ class ProjectMetalsLspService( new FolderTreeViewProvider( new Folder(folder, folderVisibleName, true), buildTargets, + uriMapper, definitionIndex, () => userConfig, scalaVersionSelector, @@ -937,5 +943,4 @@ class ProjectMetalsLspService( super.resetService() treeView.reset() } - } 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 e8aa15d71ec..ace7ed66408 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", @@ -843,6 +867,9 @@ object ServerCommands { RunScalafix, ScalafixRunOnly, DecodeFile, + FileSystemStat, + FileSystemReadDirectory, + FileSystemReadFile, DisconnectBuildServer, DisconnectBuildServerAndShutdown, ListBuildTargets, 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 new file mode 100644 index 00000000000..4cdb997e3d9 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/URIMapper.scala @@ -0,0 +1,350 @@ +package scala.meta.internal.metals + +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.concurrent.atomic.AtomicReference + +import scala.util.Using +import scala.util.control.NonFatal + +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.SymbolInformation +import org.eclipse.lsp4j.TextDocumentIdentifier + +/** + * Bidirectional mapper between virtual `metalsfs://` URIs exposed to + * the LSP client and local `jar:file://` URIs used internally by Metals. + * + * `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. + */ +trait URIMapper { + + def convertToLocal(uri: String): String + def convertToMetalsFS(uri: String): String + + def tryConvertToLocal(uri: String): Option[String] + def tryConvertToMetalsFS(uri: String): Option[String] + + 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") + ) + + final def getWorkspaceJarFileSystem(name: String): FileSystemInfo = + tryGetWorkspaceJarFileSystem(name).getOrElse( + throw new NoSuchElementException(s"Workspace jar not found: $name") + ) + + final def getSourceJarFileSystem(name: String): FileSystemInfo = + tryGetSourceJarFileSystem(name).getOrElse( + throw new NoSuchElementException(s"Source jar not found: $name") + ) + + final def convertToMetalsFS( + symbolInformation: SymbolInformation + ): SymbolInformation = { + val symbolInfo = new SymbolInformation( + symbolInformation.getName, + symbolInformation.getKind, + convertToMetalsFS(symbolInformation.getLocation), + symbolInformation.getContainerName, + ) + symbolInfo.setTags(symbolInformation.getTags) + symbolInfo + } + + 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) + + /** + * 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 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 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 unquote(s: String): String = + s.stripPrefix("\"").stripSuffix("\"") + + private def jdkSources: Option[AbsolutePath] = + JdkSources(userJavaHome()).toOption + + /** 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 + + val newKnownPaths = + newJdk.values.toSet ++ newWs.values.toSet ++ newSrc.values.toSet + jarFileSystemCache.closeObsolete(newKnownPaths) + + jdkIndex.set(newJdk) + workspaceJarIndex.set(newWs) + sourceJarIndex.set(newSrc) + } + + 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 NonFatal(e) => + scribe.warn( + s"Failed to parse $decodedJarPath while classifying local URI", + e, + ) + 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 + } + } + + /** + * `metalsfs:` URIs are returned as-is — `new URI("metalsfs", "", path, null)` + * would otherwise produce triple slashes. + */ + 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") + } + + 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) + tryGetJDKFileSystem(name).map(fs => (fs, remaining)) + case ws if ws.startsWith(URIMapper.workspaceJarURI) => + val (name, remaining) = + URIMapper.getURIParts(ws, URIMapper.workspaceJarURI) + tryGetWorkspaceJarFileSystem(name).map(fs => (fs, remaining)) + case src if src.startsWith(URIMapper.sourceJarURI) => + val (name, remaining) = + URIMapper.getURIParts(src, URIMapper.sourceJarURI) + tryGetSourceJarFileSystem(name).map(fs => (fs, remaining)) + case _ => None + } + resolved.map { case (fs, fsPath) => + fsPath.fold(fs.fileUri)(p => fs.fs.getPath(p).toUri.toString) + } + } + } + + 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) + } +} + +/** + * 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 { + + private def tryAll[A](f: URIMapper => Option[A]): Option[A] = + folders().iterator.flatMap(m => f(m).iterator).nextOption() + + def convertToLocal(uri: String): String = + tryConvertToLocal(uri).getOrElse(uri) + def convertToMetalsFS(uri: String): String = + tryConvertToMetalsFS(uri).getOrElse(uri) + + def tryConvertToLocal(uri: String): Option[String] = + tryAll(_.tryConvertToLocal(uri)) + def tryConvertToMetalsFS(uri: String): Option[String] = + tryAll(_.tryConvertToMetalsFS(uri)) + + 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)) +} + +/** Single-slash form because VS Code normalises `metalsfs:///` to `metalsfs:/`. */ +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..ae421661ac4 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala @@ -128,6 +128,12 @@ class WorkspaceLspService( initializeParams, ) + private val jarFileSystemCache: JarFileSystemCache = new JarFileSystemCache + + private val uriMapper: WorkspaceURIMapper = new WorkspaceURIMapper(() => + folderServices.map(_.folderUriMapper) :+ fallbackService.folderUriMapper + ) + private val languageClient = { val languageClient = new ConfiguredLanguageClient(client, clientConfig, Some(this)) @@ -239,6 +245,8 @@ class WorkspaceLspService( workDoneProgress, bspStatus, moduleStatus, + jarFileSystemCache, + uriMapper, ) } @@ -263,6 +271,8 @@ class WorkspaceLspService( workDoneProgress, maxScalaCliServers = 3, moduleStatus, + jarFileSystemCache, + uriMapper, ) } @@ -353,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 = uri.stripPrefix("metalsDecode:") + val strippedUri = uriMapper.convertToLocal(uri.stripPrefix("metalsDecode:")) for { path <- strippedUri.toAbsolutePathSafe() service <- @@ -366,6 +376,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 +640,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 +654,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 +669,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 +736,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 +918,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 +939,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 +1072,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 +1084,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 +1115,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 +1207,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()))