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
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@
[submodule "liberated/mill-scalafix"]
path = liberated/mill-scalafix
url = [email protected]:bleep-build/mill-scalafix.git
[submodule "liberated/cellar"]
path = liberated/cellar
url = [email protected]:bleep-build/cellar.git
branch = main
4 changes: 4 additions & 0 deletions bench-bigdeps/src/main/java/bench/Marker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package bench;

/** Placeholder so the bleep-cellar-benchmark project compiles. The real point of this project is its dependency closure: Spring Boot + heavy companions, used to stress-test the symbol-lookup MCP tools against a realistic fat classpath. */
final class Marker {}
502 changes: 502 additions & 0 deletions bleep-cli/src/scala-3/bleep/mcp/BleepMcpServer.scala

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions bleep-cli/src/scala-3/bleep/mcp/McpToolArgs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,132 @@ object NoArgs {
).asInstanceOf[JsonSchemaEncoder[NoArgs]]
}

/** Args for bleep.symbol.get. */
case class SymbolGetArgs(project: String, fqn: String, limit: Option[Int], hideInherited: Boolean, groupInherited: Boolean)
object SymbolGetArgs {
private val knownFields = Set("project", "fqn", "limit", "hideInherited", "groupInherited")
given Decoder[SymbolGetArgs] = Decoder.instance { c =>
for {
_ <- rejectUnknownFields(c, knownFields)
project <- c.downField("project").as[String]
fqn <- c.downField("fqn").as[String]
limit <- decodeOptional[Int](c, "limit")
hideInherited <- decodeWithFallback(c, "hideInherited", false)
groupInherited <- decodeWithFallback(c, "groupInherited", false)
} yield SymbolGetArgs(project, fqn, limit, hideInherited, groupInherited)
}
given JsonSchemaEncoder[SymbolGetArgs] = schema(
Json.obj(
"type" -> Json.fromString("object"),
"properties" -> Json.obj(
"project" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Bleep project whose classpath to query. Required.")
),
"fqn" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Fully qualified symbol name (e.g. cats.Monad, com.example.Foo.bar).")
),
"limit" -> Json.obj(
"type" -> Json.fromString("integer"),
"description" -> Json.fromString("Max members to render in the output. Omit for all.")
),
"hideInherited" -> Json.obj(
"type" -> Json.fromString("boolean"),
"description" -> Json.fromString("Show only members declared on the type itself.")
),
"groupInherited" -> Json.obj(
"type" -> Json.fromString("boolean"),
"description" -> Json.fromString("Group members by declaring type with section headers.")
)
),
"required" -> Json.arr(Json.fromString("project"), Json.fromString("fqn"))
)
).asInstanceOf[JsonSchemaEncoder[SymbolGetArgs]]
}

/** Args for bleep.symbol.find — supersedes the older list + search tools. */
case class SymbolFindArgs(project: String, scope: Option[String], pattern: Option[String], limit: Option[Int])
object SymbolFindArgs {
private val knownFields = Set("project", "scope", "pattern", "limit")
given Decoder[SymbolFindArgs] = Decoder.instance { c =>
for {
_ <- rejectUnknownFields(c, knownFields)
project <- c.downField("project").as[String]
scope <- decodeOptional[String](c, "scope")
pattern <- decodeOptional[String](c, "pattern")
limit <- decodeOptional[Int](c, "limit")
_ <-
if (scope.isEmpty && pattern.isEmpty)
Left(DecodingFailure("At least one of 'scope' or 'pattern' must be set.", c.history))
else Right(())
} yield SymbolFindArgs(project, scope, pattern, limit)
}
given JsonSchemaEncoder[SymbolFindArgs] = schema(
Json.obj(
"type" -> Json.fromString("object"),
"properties" -> Json.obj(
"project" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Bleep project whose classpath to query. Required.")
),
"scope" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString(
"Package or class FQN to enumerate. If set, results are the members of that scope (companion members included for classes/traits). If omitted, the whole classpath is searched."
)
),
"pattern" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Case-insensitive substring filter on symbol names. Required when scope is omitted; optional inside a scope.")
),
"limit" -> Json.obj(
"type" -> Json.fromString("integer"),
"description" -> Json.fromString("Max number of results. Default 50.")
)
),
"required" -> Json.arr(Json.fromString("project"))
)
).asInstanceOf[JsonSchemaEncoder[SymbolFindArgs]]
}

/** Args for bleep.symbol.get_source. Coordinate is optional — if omitted, inferred from the symbol's classpath entry against the project's resolved artifacts.
*/
case class SymbolGetSourceArgs(project: String, fqn: String, coordinate: Option[String])
object SymbolGetSourceArgs {
private val knownFields = Set("project", "fqn", "coordinate")
given Decoder[SymbolGetSourceArgs] = Decoder.instance { c =>
for {
_ <- rejectUnknownFields(c, knownFields)
project <- c.downField("project").as[String]
fqn <- c.downField("fqn").as[String]
coordinate <- decodeOptional[String](c, "coordinate")
} yield SymbolGetSourceArgs(project, fqn, coordinate)
}
given JsonSchemaEncoder[SymbolGetSourceArgs] = schema(
Json.obj(
"type" -> Json.fromString("object"),
"properties" -> Json.obj(
"project" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Bleep project whose classpath to resolve the symbol against.")
),
"fqn" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString("Fully qualified symbol name to fetch source for.")
),
"coordinate" -> Json.obj(
"type" -> Json.fromString("string"),
"description" -> Json.fromString(
"Optional Maven coordinate (group:artifact:version). If omitted, inferred from the project's resolved dependency artifacts."
)
)
),
"required" -> Json.arr(Json.fromString("project"), Json.fromString("fqn"))
)
).asInstanceOf[JsonSchemaEncoder[SymbolGetSourceArgs]]
}

/** Args for run (project/script execution). */
case class RunArgs(name: String, args: List[String], mainClass: Option[String], timeoutSeconds: Option[Int])
object RunArgs {
Expand Down
90 changes: 90 additions & 0 deletions bleep-cli/src/scala-3/bleep/symbols/SourceFetcher.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package bleep.symbols

import coursierapi.{Cache, Dependency, Fetch, Module, Repository}

import java.nio.file.{Files, Path}
import java.util.zip.ZipFile
import scala.jdk.CollectionConverters.*

/** On-demand fetcher for `-sources.jar` artifacts.
*
* Bleep downloads sources eagerly for BSP but not for regular builds, so this fetches sources for a specific Maven coordinate just-in-time via Coursier, then
* slices the requested file out of the zip.
*
* The coursier cache backs both bleep's resolver and our `Fetch.create()` here, so a sources jar fetched once is free the next time.
*/
object SourceFetcher {

case class SourceResult(entryPath: String, startLine: Int, endLine: Int, lines: IndexedSeq[String])

/** Coordinate format: `group:artifact:version`. Returns the path to the local sources jar, or None if the artifact has no sources published. */
def fetchSourcesJar(coordinate: String, extraRepositories: Seq[Repository] = Seq.empty): Option[Path] = {
val (group, artifact, version) = parseCoordinate(coordinate)
try {
val dep = Dependency.of(Module.of(group, artifact, java.util.Collections.emptyMap()), version).withTransitive(false)
val fetch = Fetch
.create()
.addDependencies(dep)
.withCache(Cache.create())
.addClassifiers("sources")
.withMainArtifacts(false)
if (extraRepositories.nonEmpty) { val _ = fetch.addRepositories(extraRepositories*) }
val files = fetch.fetch().asScala.toList
files.headOption.map(_.toPath)
} catch {
case _: Throwable => None
}
}

/** Slice a specific file out of a sources jar. `sourceFilePath` may be a suffix (e.g. `cats/Monad.scala`) — the first zip entry that ends with it wins.
*
* `endLine` may be `Int.MaxValue` to mean "to end of file".
*/
def extractLines(jar: Path, sourceFilePath: String, startLine: Int, endLine: Int): Either[String, SourceResult] = {
val normalizedSource = sourceFilePath.replace('\\', '/')
val zip = new ZipFile(jar.toFile)
try {
val entry = zip
.entries()
.asScala
.find(e => !e.isDirectory && normalizedSource.endsWith(e.getName))
entry match {
case None => Left(s"Source file not found in JAR (looked for suffix of '$normalizedSource').")
case Some(e) =>
val in = zip.getInputStream(e)
try {
val allBytes = in.readAllBytes()
val allLines = new String(allBytes, java.nio.charset.StandardCharsets.UTF_8).split('\n').toVector
val extracted =
if (endLine == Int.MaxValue) allLines.drop(startLine)
else allLines.slice(startLine, math.min(endLine + 1, allLines.length))
Right(SourceResult(e.getName, startLine, endLine, extracted))
} finally in.close()
}
} finally zip.close()
}

/** Fetch + extract in one call. */
def fetch(
coordinate: String,
sourceFilePath: String,
startLine: Int,
endLine: Int,
extraRepositories: Seq[Repository] = Seq.empty
): Either[String, SourceResult] =
fetchSourcesJar(coordinate, extraRepositories) match {
case None => Left(s"No sources JAR published for '$coordinate'.")
case Some(jar) =>
if (!Files.exists(jar)) Left(s"Sources JAR resolved but not present on disk: $jar")
else extractLines(jar, sourceFilePath, startLine, endLine)
}

private def parseCoordinate(s: String): (String, String, String) =
s.split(':') match {
case Array(g, a, v) if g.nonEmpty && a.nonEmpty && v.nonEmpty => (g, a, v)
case _ =>
throw new IllegalArgumentException(
s"Invalid coordinate '$s'. Expected format: group:artifact:version (e.g. org.typelevel:cats-core_3:2.10.0)"
)
}
}
73 changes: 73 additions & 0 deletions bleep-cli/src/scala-3/bleep/symbols/SymbolsBridge.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package bleep.symbols

import bleep.{ResolvedJvm, ResolvedProject}
import tastyquery.Classpaths.Classpath
import tastyquery.Contexts.Context
import tastyquery.jdk.ClasspathLoaders

import java.net.{URI, URLClassLoader}
import java.nio.file.{FileSystems, Files, Path}
import scala.jdk.CollectionConverters.*

/** Bridge from bleep's ResolvedProject / ResolvedJvm to a tasty-query Context.
*
* Bleep already owns the build, so we skip cellar's classpath extraction and feed jars + classes dirs straight into tasty-query.
*/
object SymbolsBridge {

/** Classpath for a resolved project: its compiled classes plus all dependency jars. Test classes are included for test projects via the resolved classpath.
*/
def projectClasspath(project: ResolvedProject): Classpath = {
val paths = project.classpath ++ List(project.classesDir)
readClasspathRobust(paths)
}

/** JRE classpath loaded from the JDK at the given home. Reads from `jrt:/` via the JDK's internal JrtFileSystemProvider, falling back to a URLClassLoader on
* `lib/jrt-fs.jar` if the internal class is locked down.
*/
def jreClasspath(jvm: ResolvedJvm): Classpath = {
val javaHome = jvm.javaBin.getParent.getParent // .../bin/java -> .../
val jrtFsJar = javaHome.resolve("lib/jrt-fs.jar")
if (!Files.exists(jrtFsJar))
throw new IllegalArgumentException(s"Not a valid JDK home (missing lib/jrt-fs.jar): $javaHome")
val env = java.util.Map.of("java.home", javaHome.toString)
val fs =
try {
val cls = Class.forName("jdk.internal.jrtfs.JrtFileSystemProvider")
val ctor = cls.getDeclaredConstructor()
ctor.setAccessible(true)
val provider = ctor.newInstance().asInstanceOf[java.nio.file.spi.FileSystemProvider]
provider.newFileSystem(URI.create("jrt:/"), env)
} catch {
case _: java.lang.reflect.InaccessibleObjectException =>
val cl = new URLClassLoader(Array(jrtFsJar.toUri.toURL))
FileSystems.newFileSystem(URI.create("jrt:/"), env, cl)
}
ClasspathLoaders.read(Files.list(fs.getPath("modules")).iterator().asScala.toList)
}

/** Full context for a project: JRE + project classpath, initialized as one tasty-query Context. */
def contextOf(project: ResolvedProject, jvm: ResolvedJvm): (Context, Classpath) = {
val jre = jreClasspath(jvm)
val projectCp = projectClasspath(project)
val classpath = jre ++ projectCp
(Context.initialize(classpath), classpath)
}

/** Reads the classpath, excluding paths that cause `MatchError` in tasty-query (e.g. vendor-injected JRT modules such as the Azul CRS client). Vendored from
* cellar.ContextResource.
*/
private def readClasspathRobust(paths: List[Path]): Classpath =
try ClasspathLoaders.read(paths)
catch {
case e: MatchError =>
val bad = paths.find { p =>
try { val _ = ClasspathLoaders.read(List(p)); false }
catch { case _: MatchError => true }
}
bad match {
case Some(offender) => readClasspathRobust(paths.filterNot(_ == offender))
case None => throw e
}
}
}
Loading
Loading