Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement 'find in JAR files' LSP extension #3093

Merged
merged 12 commits into from
Oct 8, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,11 @@ object MetalsEnrichments
filename.endsWith(".jar") || filename.endsWith(".srcjar")
}

def isZip: Boolean = {
val filename = path.toNIO.getFileName.toString
filename.endsWith(".zip")
}

/**
* Reads file contents from editor buffer with fallback to disk.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import scala.meta.internal.metals.codelenses.WorksheetCodeLens
import scala.meta.internal.metals.debug.BuildTargetClasses
import scala.meta.internal.metals.debug.DebugParametersJsonParsers
import scala.meta.internal.metals.debug.DebugProvider
import scala.meta.internal.metals.findfiles._
import scala.meta.internal.metals.formatting.OnTypeFormattingProvider
import scala.meta.internal.metals.formatting.RangeFormattingProvider
import scala.meta.internal.metals.newScalaFile.NewFileProvider
Expand Down Expand Up @@ -266,6 +267,7 @@ class MetalsLanguageServer(
var worksheetProvider: WorksheetProvider = _
var popupChoiceReset: PopupChoiceReset = _
var stacktraceAnalyzer: StacktraceAnalyzer = _
var findTextInJars: FindTextInDependencyJars = _

private val clientConfig: ClientConfiguration =
new ClientConfiguration(
Expand Down Expand Up @@ -754,6 +756,11 @@ class MetalsLanguageServer(
() => bspSession.map(_.mainConnectionIsBloop).getOrElse(false)
)
}
findTextInJars = new FindTextInDependencyJars(
buildTargets,
() => workspace,
languageClient
)
}
}

Expand Down Expand Up @@ -1887,6 +1894,13 @@ class MetalsLanguageServer(
.orNull
}.asJava

@JsonRequest("metals/findTextInDependencyJars")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document it similar to docs/integrations/tree-view-protocol.md

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on this. Before merging this in lets get a basic section on this in the new-editor.md if possible. Not sure it needs to be a full page on its own since it's quite small, but having it documented somewhere that it exists, what it expects, and what it returns in important for other clients to know how to implement this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tgodzik do you mind if I do it as a separate PR? This one is quite old already and has more than 40 comments, so I would prefer to finally merge it when others approve.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, not an issue. Let me know if you don't manage to do it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry was mid review and didn't see this before. We can merge and do a follow-up if you'd prefer, but I do think it's pretty important to get this documented. If not others have to dig into the code to know how to implement it.

def findTextInDependencyJars(
params: FindTextInDependencyJarsRequest
): CompletableFuture[util.List[Location]] = {
findTextInJars.find(params).map(_.asJava).asJava
}

private def generateBspConfig(): Future[Unit] = {
val servers: List[BuildTool with BuildServerProvider] =
buildTools.loadSupported().collect {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package scala.meta.internal.metals.findfiles

import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.file.Files

import scala.collection.mutable.ArrayBuffer
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.util.control.NonFatal

import scala.meta.internal.io.FileIO
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.ScalafmtConfig.PathMatcher.Nio
import scala.meta.internal.metals._
import scala.meta.io.AbsolutePath

import org.eclipse.lsp4j.Location
import org.eclipse.lsp4j.Position
import org.eclipse.lsp4j.Range

class FindTextInDependencyJars(
buildTargets: BuildTargets,
workspace: () => AbsolutePath,
languageClient: MetalsLanguageClient
)(implicit ec: ExecutionContext) {
import FindTextInDependencyJars._

def find(request: FindTextInDependencyJarsRequest): Future[List[Location]] = {
val req = Request.fromRequest(request)

def readInclude: Future[Option[String]] =
paramOrInput(req.options.flatMap(_.include))(
MetalsInputBoxParams(value = ".conf", prompt = "Enter file mask")
)

def readPattern: Future[Option[String]] =
paramOrInput(Option(req.query.pattern))(
MetalsInputBoxParams(prompt = "Enter content to search for")
)

readInclude.zipWith(readPattern) { (maybeInclude, maybePattern) =>
maybeInclude
.zip(maybePattern)
.map { case (include, pattern) =>
val allLocations: ArrayBuffer[Location] = new ArrayBuffer[Location]()
val includeMatcher = Nio(s"glob:**$include")
val excludeMatcher =
req.options.flatMap(_.exclude).map(e => Nio(s"glob:**$e"))

(buildTargets.allWorkspaceJars ++ JdkSources()).foreach {
classpathEntry =>
try {
val locations: List[Location] =
if (
classpathEntry.isFile && (classpathEntry.isJar || classpathEntry.isZip)
) {
visitJar(
path = classpathEntry,
include = includeMatcher,
exclude = excludeMatcher,
pattern = pattern
)
} else Nil

allLocations ++= locations
} catch {
case NonFatal(e) =>
scribe.error(
s"Failed to find text in dependency files for $classpathEntry",
e
)
}
}

allLocations.toList
}
.flatten
.toList
}
}

private def isSuitableFile(
path: AbsolutePath,
include: Nio,
exclude: Option[Nio]
): Boolean = {
path.isFile &&
include.matches(path) &&
exclude.forall(matcher => !matcher.matches(path))
}

private def visitJar(
path: AbsolutePath,
include: Nio,
exclude: Option[Nio],
pattern: String
): List[Location] = {
FileIO
.withJarFileSystem(path, create = false, close = true) { root =>
FileIO
.listAllFilesRecursively(root)
.filter(isSuitableFile(_, include, exclude))
.flatMap { absPath =>
val fileRanges: List[Range] = visitFileInsideJar(absPath, pattern)
if (fileRanges.nonEmpty) {
val result = absPath.toFileOnDisk(workspace())
fileRanges
.map(range => new Location(result.toURI.toString, range))
} else Nil
}
}
.toList
}

private def visitFileInsideJar(
path: AbsolutePath,
pattern: String
): List[Range] = {
var reader: BufferedReader = null
val positions: ArrayBuffer[Int] = new ArrayBuffer[Int]()
val results: ArrayBuffer[Range] = new ArrayBuffer[Range]()
val contentLength: Int = pattern.length()

try {
reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(path.toNIO))
)
var lineNumber: Int = 0
var line: String = reader.readLine()
while (line != null) {
var occurence = line.indexOf(pattern)
while (occurence != -1) {
positions += occurence
occurence = line.indexOf(pattern, occurence + 1)
}

positions.foreach { position =>
results += new Range(
new Position(lineNumber, position),
new Position(lineNumber, position + contentLength)
)
}

positions.clear()
lineNumber = lineNumber + 1
line = reader.readLine()
}
} finally {
if (reader != null) reader.close()
}

results.toList
}

private def paramOrInput(
param: Option[String]
)(input: => MetalsInputBoxParams): Future[Option[String]] = {
param match {
case Some(value) =>
Future.successful(Some(value))
case None =>
languageClient
.metalsInputBox(input)
.asScala
.map(checkResult)
}
}

private def checkResult(result: MetalsInputBoxResult) = result match {
case name if !name.cancelled && name.value.nonEmpty =>
Some(name.value)
case _ =>
None
}
}

object FindTextInDependencyJars {
// These are just more typesafe wrappers, duplicating the structure of original model
private case class Request(options: Option[Options], query: TextSearchQuery)
private object Request {
def fromRequest(request: FindTextInDependencyJarsRequest): Request = {
val options = Option(request.options).map { options =>
Options(
include = Option(options.include),
exclude = Option(options.exclude)
)
}

val query = TextSearchQuery(
pattern = request.query.pattern,
isRegExp = Option(request.query.isRegExp),
isCaseSensitive = Option(request.query.isCaseSensitive),
isWordMatch = Option(request.query.isWordMatch)
)

Request(options = options, query = query)
}
}

private case class Options(include: Option[String], exclude: Option[String])
private case class TextSearchQuery(
pattern: String,
isRegExp: Option[Boolean],
isCaseSensitive: Option[Boolean],
isWordMatch: Option[Boolean]
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package scala.meta.internal.metals.findfiles

import javax.annotation.Nullable

case class FindTextInDependencyJarsRequest(
@Nullable
options: FindTextInFilesOptions,
query: TextSearchQuery
)

case class TextSearchQuery(
pattern: String,
@Nullable
isRegExp: java.lang.Boolean,
@Nullable
isCaseSensitive: java.lang.Boolean,
@Nullable
isWordMatch: java.lang.Boolean
)

case class FindTextInFilesOptions(
@Nullable
include: String,
@Nullable
exclude: String
)
21 changes: 21 additions & 0 deletions tests/unit/src/main/scala/tests/TestingServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import scala.meta.internal.metals.UserConfiguration
import scala.meta.internal.metals.WindowStateDidChangeParams
import scala.meta.internal.metals.debug.Stoppage
import scala.meta.internal.metals.debug.TestDebugger
import scala.meta.internal.metals.findfiles._
import scala.meta.internal.mtags.Semanticdbs
import scala.meta.internal.parsing.Trees
import scala.meta.internal.semanticdb.Scala.Symbols
Expand Down Expand Up @@ -1472,6 +1473,26 @@ final class TestingServer(
Assertions.assertNoDiff(obtained, expected)
}

def findTextInDependencyJars(
include: String,
pattern: String
): Future[List[Location]] = {
server
.findTextInDependencyJars(
FindTextInDependencyJarsRequest(
FindTextInFilesOptions(include = include, exclude = null),
TextSearchQuery(
pattern = pattern,
isRegExp = null,
isCaseSensitive = null,
isWordMatch = null
)
)
)
.asScala
.map(_.asScala.toList)
}

def textContents(filename: String): String =
toPath(filename).toInputFromBuffers(buffers).text
def textContentsOnDisk(filename: String): String =
Expand Down
Loading