From 2cc3fb5499ebee0f95f9d542b8284a166d9997d2 Mon Sep 17 00:00:00 2001 From: Arthur McGibbon Date: Tue, 22 Mar 2022 17:43:54 +0000 Subject: [PATCH] Add filesystem support for jars --- .../meta/internal/metals/ClientCommands.scala | 10 + .../internal/metals/ClientConfiguration.scala | 4 + .../internal/metals/FileSystemProvider.scala | 188 ++++++++++++++++++ .../scala/meta/internal/metals/Indexer.scala | 3 + .../metals/InitializationOptions.scala | 4 + .../metals/MetalsLanguageServer.scala | 13 ++ .../meta/internal/metals/ServerCommands.scala | 36 ++++ 7 files changed, 258 insertions(+) create mode 100644 metals/src/main/scala/scala/meta/internal/metals/FileSystemProvider.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 8968a0b4a6f..3453d8b167f 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientCommands.scala @@ -224,6 +224,15 @@ object ClientCommands { |""".stripMargin ) + val CreateLibraryFileSystem = new ParametrizedCommand[String]( + "metals-create-library-filesystem", + "Create Library FS", + """|Notifies the client that it should create an empty + |filesystem to navigate jar dependencies + |""".stripMargin, + arguments = """`string`, the URI root of the filesystem.""".stripMargin + ) + val RefreshModel = new Command( "metals-model-refresh", "Refresh model", @@ -308,6 +317,7 @@ object ClientCommands { FocusDiagnostics, GotoLocation, EchoCommand, + CreateLibraryFileSystem, RefreshModel, ShowStacktrace, CopyWorksheetOutput, diff --git a/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala b/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala index e4272d37418..ed42eec454f 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ClientConfiguration.scala @@ -57,6 +57,10 @@ case class ClientConfiguration(initialConfig: MetalsServerConfig) { def isVirtualDocumentSupported(): Boolean = initializationOptions.isVirtualDocumentSupported.getOrElse(false) + def isLibraryFileSystemSupported(): Boolean = + // TODO - make this false before commit + initializationOptions.isLibraryFileSystemSupported.getOrElse(true) + def icons(): Icons = initializationOptions.icons .map(Icons.fromString) diff --git a/metals/src/main/scala/scala/meta/internal/metals/FileSystemProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/FileSystemProvider.scala new file mode 100644 index 00000000000..e1939d788a3 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/FileSystemProvider.scala @@ -0,0 +1,188 @@ +package scala.meta.internal.metals + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.clients.language.MetalsLanguageClient +import scala.meta.internal.io.FileIO +import scala.meta.io.AbsolutePath +import scala.annotation.tailrec +import java.nio.file.Files +import java.util.stream.Collectors +import java.nio.charset.StandardCharsets + +final case class FSReadDirectoryResponse( + name: String, + isFile: Boolean +) + +final case class FSReadFileResponse( + name: String, + value: String, + error: String +) + +final case class FSStatResponse( + name: String, + isFile: Boolean +) + +final class FileSystemProvider( + languageClient: MetalsLanguageClient, + clientConfig: ClientConfiguration, + buildTargets: BuildTargets +)(implicit ec: ExecutionContext) { + + private val jarFileSystemRoot = "metals_library_dependencies:/" + + private case class JarInfo(path: AbsolutePath, isSource: Boolean) + private var jarNameToPath = Map.empty[String, JarInfo] + + private def isTopLevel(uriAsStr: String) = + uriAsStr == jarFileSystemRoot + + private def isJarLevel(uriAsStr: String) = { + val possibleJarName = uriAsStr.stripPrefix(jarFileSystemRoot) + possibleJarName.nonEmpty && !possibleJarName.contains('/') + } + + private def getJarName(uriAsStr: String): Option[String] = { + val possibleJarName = uriAsStr.stripPrefix(jarFileSystemRoot) + if (possibleJarName.nonEmpty) { + Some(possibleJarName.takeWhile(_ != '/')) + } else None + } + + private def getTopLevelJars: List[FSReadDirectoryResponse] = { + if (jarNameToPath.isEmpty) { + val workspaceJars = buildTargets.allWorkspaceJars.toList + val sourceAndSourcelessJars = workspaceJars.map(jar => { + val sourceJarName = jar.filename.replace(".jar", "-sources.jar") + buildTargets + .sourceJarFile(sourceJarName) + .map(sourceJar => JarInfo(sourceJar, isSource = true)) + .getOrElse(JarInfo(jar, isSource = false)) + }) + jarNameToPath = + sourceAndSourcelessJars.map(f => f.path.filename -> f).toMap + } + jarNameToPath.keys.map(FSReadDirectoryResponse(_, false)).toList + } + + private def getJarDirs(uriAsStr: String, jarName: String): List[String] = { + @tailrec + def getDirs(remainingUri: String, dirs: List[String]): List[String] = { + if (remainingUri.isEmpty) + dirs + else { + val (head, tail) = remainingUri.stripPrefix("/").span(_ != '/') + getDirs(tail, head :: dirs) + } + } + val remainingUri = + uriAsStr.stripPrefix(jarFileSystemRoot).stripPrefix(jarName) + getDirs(remainingUri, List.empty).reverse + } + + private def accessJar[A]( + uriAsStr: String, + convert: AbsolutePath => A + ): Option[A] = { + + @tailrec + def resolve(path: AbsolutePath, dirs: List[String]): AbsolutePath = { + if (dirs.isEmpty) + path + else + resolve(path.resolve(dirs.head), dirs.tail) + } + + for { + jarName <- getJarName(uriAsStr) + jarInfo = jarNameToPath(jarName) + jarDirs = getJarDirs(uriAsStr, jarName) + } yield { + FileIO + .withJarFileSystem( + jarInfo.path, + create = false, + // TODO check if close is correct - copied from FindTextInDependencyJars + close = !jarInfo.isSource + ) { root => convert(resolve(root, jarDirs)) } + } + } + + private def getJarChildren( + uriAsStr: String + ): List[FSReadDirectoryResponse] = { + accessJar( + uriAsStr, + uriPath => + Files + .list(uriPath.toNIO) + .map(path => + FSReadDirectoryResponse( + path.filename, + isFile = !Files.isDirectory(path) + ) + ) + .collect(Collectors.toList()) + .asScala + .toList + ).getOrElse(List.empty) + } + + private def readFileContents(uriAsStr: String): FSReadFileResponse = { + accessJar( + uriAsStr, + path => { + val contents = + new String(Files.readAllBytes(path.toNIO), StandardCharsets.UTF_8) + FSReadFileResponse(path.filename, contents, null) + } + ).getOrElse(FSReadFileResponse(uriAsStr, null, "Unknown error")) + } + + private def getSystemStatInfo(uriAsStr: String): FSStatResponse = { + accessJar( + uriAsStr, + path => + FSStatResponse(path.filename, isFile = !Files.isDirectory(path.toNIO)) + ) + // TODO introduce error class + .getOrElse(FSStatResponse("Fake", false)) + } + + def createLibraryFileSystem(): Unit = + if (clientConfig.isLibraryFileSystemSupported()) { + val params = + ClientCommands.CreateLibraryFileSystem.toExecuteCommandParams( + jarFileSystemRoot + ) + languageClient.metalsExecuteClientCommand(params) + } + + def readDirectory( + uriAsStr: String + ): Future[Array[FSReadDirectoryResponse]] = + Future { + val contents = if (isTopLevel(uriAsStr)) { + getTopLevelJars + } else + getJarChildren(uriAsStr) + contents.toArray + } + + def readFile(uriAsStr: String): Future[FSReadFileResponse] = Future { + readFileContents(uriAsStr) + } + + def getSystemStat(uriAsStr: String): Future[FSStatResponse] = + Future { + if (isTopLevel(uriAsStr) || isJarLevel(uriAsStr)) + FSStatResponse("/", isFile = false) + else + getSystemStatInfo(uriAsStr) + } +} 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 14a5bda192d..1043ce88b53 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Indexer.scala @@ -42,6 +42,7 @@ import ch.epfl.scala.{bsp4j => b} final case class Indexer( workspaceReload: () => WorkspaceReload, doctor: () => Doctor, + fileSystemProvider: () => FileSystemProvider, languageClient: DelegatingLanguageClient, bspSession: () => Option[BspSession], executionContext: ExecutionContextExecutorService, @@ -266,6 +267,8 @@ final case class Indexer( buildTool.importedBuild.dependencySources ) } + // create library FS after jars have been indexed + fileSystemProvider().createLibraryFileSystem() // 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 25ad60f6369..67e0d62aef2 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/InitializationOptions.scala @@ -64,6 +64,7 @@ final case class InitializationOptions( isHttpEnabled: Option[Boolean], commandInHtmlFormat: Option[CommandHTMLFormat], isVirtualDocumentSupported: Option[Boolean], + isLibraryFileSystemSupported: Option[Boolean], openFilesOnRenameProvider: Option[Boolean], quickPickProvider: Option[Boolean], renameFileThreshold: Option[Int], @@ -109,6 +110,7 @@ object InitializationOptions { None, None, None, + None, None ) @@ -152,6 +154,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/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala index 97ad43c631d..cad30b104ac 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala @@ -261,6 +261,7 @@ class MetalsLanguageServer( private var compilers: Compilers = _ private var scalafixProvider: ScalafixProvider = _ private var fileDecoderProvider: FileDecoderProvider = _ + private var fileSystemProvider: FileSystemProvider = _ private var testProvider: TestSuitesProvider = _ private var workspaceReload: WorkspaceReload = _ private var buildToolSelector: BuildToolSelector = _ @@ -730,6 +731,11 @@ class MetalsLanguageServer( clientConfig, classFinder ) + fileSystemProvider = new FileSystemProvider( + languageClient, + clientConfig, + buildTargets + ) popupChoiceReset = new PopupChoiceReset( workspace, tables, @@ -1713,6 +1719,12 @@ class MetalsLanguageServer( params.kind == "class" ) .asJavaObject + case ServerCommands.FileSystemStat(uriAsStr) => + fileSystemProvider.getSystemStat(uriAsStr).asJavaObject + case ServerCommands.FileSystemReadFile(uriAsStr) => + fileSystemProvider.readFile(uriAsStr).asJavaObject + case ServerCommands.FileSystemReadDirectory(uriAsStr) => + fileSystemProvider.readDirectory(uriAsStr).asJavaObject case ServerCommands.RunDoctor() => Future { doctor.executeRunDoctor() @@ -2314,6 +2326,7 @@ class MetalsLanguageServer( private val indexer = Indexer( () => workspaceReload, () => doctor, + () => fileSystemProvider, languageClient, () => bspSession, executionContext, 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 5d3e8ca38c6..fba13be46eb 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -149,6 +149,42 @@ object ServerCommands { |""".stripMargin ) + val FileSystemStat = new ParametrizedCommand[String]( + "filesystem-stat", + "Read Directory", + """|Return information about the uri. E.g. uri is a directory + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the file or directory." + ) + + val FileSystemReadDirectory = new ParametrizedCommand[String]( + "filesystem-read-directory", + "Read Directory", + """|Return the contents of a virtual filesystem directory. + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the directory." + ) + + val FileSystemReadFile = new ParametrizedCommand[String]( + "filesystem-read-file", + "Read File", + """|Return the contents of a virtual filesystem file. + | + |Virtual File Systems allow client to display filesystem data + |in the format that Metals dictates. + |e.g. jar library dependencies + |""".stripMargin, + "[uri], uri of the file." + ) + val RunDoctor = new Command( "doctor-run", "Run doctor",