Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -121,6 +126,8 @@ class StandaloneMcpService(
workDoneProgress,
maxScalaCliServers = 3,
moduleStatus,
jarFileSystemCache,
uriMapper,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class FallbackMetalsLspService(
override val workDoneProgress: WorkDoneProgress,
bspStatus: BspStatus,
moduleStatus: ModuleStatus,
jarFileSystemCache: JarFileSystemCache,
uriMapper: WorkspaceURIMapper,
) extends MetalsLspService(
ec,
sh,
Expand All @@ -58,6 +60,8 @@ class FallbackMetalsLspService(
workDoneProgress,
maxScalaCliServers = 10,
moduleStatus,
jarFileSystemCache,
uriMapper,
) {

val buildServerPromise: Promise[Unit] = Promise.successful(())
Expand All @@ -68,6 +72,7 @@ class FallbackMetalsLspService(
folder,
compilers,
buildTargets,
uriMapper,
() => userConfig,
shellRunner,
optFileSystemSemanticdbs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ final class FileDecoderProvider(
workspace: AbsolutePath,
compilers: Compilers,
buildTargets: BuildTargets,
uriMapper: URIMapper,
userConfig: () => UserConfiguration,
shellRunner: ShellRunner,
optFileSystemSemanticdbs: () => Option[FileSystemSemanticdbs],
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -128,6 +129,7 @@ object InitializationOptions {
None,
None,
None,
None,
)

def from(
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading