diff --git a/.gitmodules b/.gitmodules index 5387ab9fa..bec46109d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -51,3 +51,7 @@ [submodule "liberated/mill-scalafix"] path = liberated/mill-scalafix url = git@github.com:bleep-build/mill-scalafix.git +[submodule "liberated/cellar"] + path = liberated/cellar + url = git@github.com:bleep-build/cellar.git + branch = main diff --git a/bench-bigdeps/src/main/java/bench/Marker.java b/bench-bigdeps/src/main/java/bench/Marker.java new file mode 100644 index 000000000..bc9b7a71e --- /dev/null +++ b/bench-bigdeps/src/main/java/bench/Marker.java @@ -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 {} diff --git a/bleep-cli/src/scala-3/bleep/mcp/BleepMcpServer.scala b/bleep-cli/src/scala-3/bleep/mcp/BleepMcpServer.scala index d31db02f6..cc1cf8024 100644 --- a/bleep-cli/src/scala-3/bleep/mcp/BleepMcpServer.scala +++ b/bleep-cli/src/scala-3/bleep/mcp/BleepMcpServer.scala @@ -30,6 +30,15 @@ private[mcp] def stripAnsi(s: String): String = AnsiPattern.matcher(s).replaceAl */ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { private val startedRef = new AtomicReference(initialStarted) + + /** Tasty-query Context, project classpath and JRE classpath cached per-project. Cold init on a Spring-Boot-sized classpath is ~5s; this turns every + * subsequent symbol lookup into a hash-map hit. Invalidated when the build watcher detects a `bleep.yaml` change. + */ + private val symbolContextCache = + new java.util.concurrent.ConcurrentHashMap[ + model.CrossProjectName, + (tastyquery.Contexts.Context, tastyquery.Classpaths.Classpath, tastyquery.Classpaths.Classpath) + ]() private def started: Started = startedRef.get() override def initialize( @@ -111,6 +120,8 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { case Right(None) => () // parsed JSON identical, no actual change case Right(Some(newStarted)) => + // Drop any cached tasty-query Contexts before swapping in the new build — classpaths may have changed. + symbolContextCache.clear() startedRef.set(newStarted) val msg = s"Build reloaded (${changedFiles.mkString(", ")}). Project list and build model updated." newStarted.logger.info(msg) @@ -167,6 +178,9 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { |- bleep.programs — list projects that have a mainClass (runnable programs) |- bleep.scripts — list scripts defined in the build |- bleep.run — compile and run a project or script, returns stdout/stderr + |- bleep.symbol.get — look up a JVM symbol (class/object/method/type) by FQN on a project's classpath, returns signature + members + docs as Markdown + |- bleep.symbol.find — discover symbols: set `scope` to enumerate a package/class (companion members included), or set `pattern` to substring-search the whole classpath + |- bleep.symbol.get_source — fetch the source code of a symbol from a published -sources.jar (coordinate optional; inferred from the project's classpath when omitted) |- bleep.restart — restart the MCP server process (e.g. after producing a new bleep binary)""".stripMargin ) ) @@ -189,6 +203,9 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { scriptsTool, runTool, statusTool, + symbolGetTool, + symbolFindTool, + symbolGetSourceTool, restartTool ) ) @@ -526,6 +543,51 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { None ) + private def symbolGetTool: ToolFunction[IO] = ToolFunction.text[IO, SymbolGetArgs]( + ToolFunction.Info( + "bleep.symbol.get", + Some("Get Symbol"), + Some( + "Look up a JVM symbol (class, trait, object, method, type, package) by fully qualified name on a project's classpath. Returns Markdown with signature, flags, members, and docstring if available. Prefer this over hallucinating API signatures." + ), + ToolFunction.Effect.ReadOnly, + false + ), + (args, _) => symbolGet(args), + None + ) + + private def symbolFindTool: ToolFunction[IO] = ToolFunction.text[IO, SymbolFindArgs]( + ToolFunction.Info( + "bleep.symbol.find", + Some("Find Symbols"), + Some( + "Discover symbols on a project's classpath. Two modes selected by which args you set: " + + "(a) set `scope` (a package or class FQN) to enumerate its public members — class scopes also include companion-object members, sectioned in the output; " + + "(b) omit `scope` and set `pattern` for a case-insensitive substring search across the whole classpath. " + + "When both are set, scope members are filtered by pattern. Results are ranked exact-match → prefix → shortest → alphabetical. Limit defaults to 50." + ), + ToolFunction.Effect.ReadOnly, + false + ), + (args, _) => symbolFind(args), + None + ) + + private def symbolGetSourceTool: ToolFunction[IO] = ToolFunction.text[IO, SymbolGetSourceArgs]( + ToolFunction.Info( + "bleep.symbol.get_source", + Some("Get Symbol Source"), + Some( + "Fetch the source code of a symbol from a published `-sources.jar`. Coordinate is optional — if omitted, it's inferred from the jar path on the project's Coursier-managed classpath. Returns a fenced code block with the file path and line range. Fetches on-demand via Coursier (cached after the first request)." + ), + ToolFunction.Effect.ReadOnly, + false + ), + (args, _) => symbolGetSource(args), + None + ) + private def restartTool: ToolFunction[IO] = ToolFunction.text[IO, NoArgs]( ToolFunction.Info( "bleep.restart", @@ -638,6 +700,8 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { events <- collectedEvents.get // Push to history _ <- buildHistory.update(_.push(BuildRun(System.currentTimeMillis(), "compile", events.reverse))) + // Compile rewrote classesDir bytes — drop any tasty-query Contexts that indexed the old artifacts. + _ <- IO(symbolContextCache.clear()) } yield formatCompileResult(events.reverse, previousState, verbose, None, None) } @@ -710,6 +774,8 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { trr <- testRunResult.get // Push to history _ <- buildHistory.update(_.push(BuildRun(System.currentTimeMillis(), "test", events.reverse))) + // Test compile rewrote classesDir bytes — drop any tasty-query Contexts that indexed the old artifacts. + _ <- IO(symbolContextCache.clear()) } yield formatTestResult(events.reverse, trr, previousState, includeThrowables = verbose, None, None) } @@ -808,6 +874,8 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { // Push to history modeStr = if (mode == BleepBspProtocol.BuildMode.Test) "test" else "compile" _ <- buildHistory.update(_.push(BuildRun(System.currentTimeMillis(), modeStr, reversedEvents))) + // Watch cycle just rewrote classesDir bytes — drop any tasty-query Contexts that indexed the old artifacts. + _ <- IO(symbolContextCache.clear()) summary = if (mode == BleepBspProtocol.BuildMode.Test) formatTestResult(reversedEvents, None, previousState, includeThrowables = false, None, None) @@ -1062,6 +1130,440 @@ class BleepMcpServer(initialStarted: Started) extends McpServer[IO] { .noSpaces } + // ======================================================================== + // Symbol lookup (cellar bridge) + // ======================================================================== + + private def resolveProjectName(name: String): (model.CrossProjectName, bleep.ResolvedProject) = { + val cpn = started.globs.exactProjectMap.getOrElse( + name, + throw new BleepException.Text(s"'$name' is not a valid project name") + ) + (cpn, started.resolvedProject(cpn)) + } + + /** Build a tasty-query Context for the named bleep project, or reuse the cached one. The cache is invalidated by the build watcher on `bleep.yaml` changes. + * Body runs in the caller's IO.blocking thread. + */ + private def withSymbolContext[A](projectName: String)( + body: (bleep.ResolvedProject, tastyquery.Contexts.Context, tastyquery.Classpaths.Classpath, tastyquery.Classpaths.Classpath) => A + ): IO[A] = IO.blocking { + val (cpn, project) = resolveProjectName(projectName) + val cached = symbolContextCache.get(cpn) + val (ctx, projectCp, jreCp) = + if (cached != null) (cached._1, cached._2, cached._3) + else { + val jvm = started.resolvedJvm.forceGet + val jrtCp = bleep.symbols.SymbolsBridge.jreClasspath(jvm) + val projCp = bleep.symbols.SymbolsBridge.projectClasspath(project) + val context = tastyquery.Contexts.Context.initialize(jrtCp ++ projCp) + // putIfAbsent so a parallel call that beat us doesn't get overwritten — both contexts are valid, we just pick one + val existing = symbolContextCache.putIfAbsent(cpn, (context, projCp, jrtCp)) + if (existing != null) (existing._1, existing._2, existing._3) + else (context, projCp, jrtCp) + } + body(project, ctx, projectCp, jreCp) + } + + /** Warn the agent when source files are newer than the last compile, so they don't act on data that doesn't reflect their pending edits. */ + private def stalenessBanner(project: bleep.ResolvedProject): String = { + val count = countNewerSources(project) + if (count <= 0) "" + else + s"// Note: $count source file(s) in '${project.name}' are newer than the last compile.\n" + + "// Results may not reflect pending edits — call bleep.compile to refresh.\n\n" + } + + private def countNewerSources(project: bleep.ResolvedProject): Int = + try { + val classesMtime = newestMtime(project.classesDir) + if (classesMtime == 0L) 0 // nothing compiled yet → nothing to compare against + else { + var count = 0 + project.sources.foreach { sourcePath => + if (java.nio.file.Files.exists(sourcePath)) { + if (java.nio.file.Files.isDirectory(sourcePath)) { + val stream = java.nio.file.Files.walk(sourcePath) + try + stream.forEach { p => + if ( + !java.nio.file.Files.isDirectory(p) && isSourceFile(p.toString) && + java.nio.file.Files.getLastModifiedTime(p).toMillis > classesMtime + ) count += 1 + } + finally stream.close() + } else if ( + isSourceFile(sourcePath.toString) && + java.nio.file.Files.getLastModifiedTime(sourcePath).toMillis > classesMtime + ) count += 1 + } + } + count + } + } catch case _: Throwable => 0 + + private def newestMtime(dir: java.nio.file.Path): Long = + if (!java.nio.file.Files.exists(dir)) 0L + else { + try { + val stream = java.nio.file.Files.walk(dir) + try { + var max = 0L + stream.forEach { p => + if (!java.nio.file.Files.isDirectory(p)) { + val m = java.nio.file.Files.getLastModifiedTime(p).toMillis + if (m > max) max = m + } + } + max + } finally stream.close() + } catch case _: Throwable => 0L + } + + private def isSourceFile(path: String): Boolean = + path.endsWith(".scala") || path.endsWith(".java") || path.endsWith(".kt") + + /** Per-symbol location annotation: `(path:line)` for in-project symbols whose source path resolves to an existing file, `(in group:artifact:version)` for + * symbols backed by a dep jar, none otherwise. + */ + private def locationFor( + sym: tastyquery.Symbols.Symbol, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + ): Option[String] = { + val posInfo = sym.tree.flatMap { t => + val pos = t.asInstanceOf[tastyquery.Trees.Tree].pos + if (pos.isUnknown || pos.isSynthetic || pos.sourceFile == tastyquery.SourceFile.NoSource) None + else Some((pos.sourceFile.path, pos.startLine + 1)) + } + posInfo match { + case Some((path, line)) => + val asPath = java.nio.file.Paths.get(path) + if (asPath.isAbsolute && java.nio.file.Files.exists(asPath)) { + // In-project compiled code: the TASTy file carries the absolute source path scalac was invoked with. Relativize to bleep's build dir for readability. + val rel = + try started.buildPaths.buildDir.toAbsolutePath.normalize.relativize(asPath.toAbsolutePath.normalize).toString + catch case _: Throwable => asPath.toString + Some(s"$rel:$line") + } else { + // Sources-jar-style relative path — combined with the inferred coordinate it's still actionable via get_source. + inferCoordinate(sym, project, classpath).map(c => s"in $c").orElse(Some(s"$path:$line")) + } + case None => + // Java/no-position symbol: best we can do is name the dep that contains it. + inferCoordinate(sym, project, classpath).map(c => s"in $c") + } + } + + private def formatLineWithLocation( + sym: tastyquery.Symbols.Symbol, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + )(using ctx: tastyquery.Contexts.Context): String = { + val base = cellar.LineFormatter.formatLine(sym) + locationFor(sym, project, classpath) match { + case Some(loc) => s"$base ($loc)" + case None => base + } + } + + private def symbolGet(args: SymbolGetArgs): IO[String] = + withSymbolContext(args.project) { (project, ctx, projectCp, jreCp) => + given tastyquery.Contexts.Context = ctx + val classpath = jreCp ++ projectCp + val body = cellar.SymbolResolver.resolve(args.fqn).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.LookupResult.Found(symbols) => + // Default member-cap so big types (Reactor Flux, Jackson ObjectMapper) don't blow the agent's token budget. + val effectiveLimit = args.limit.orElse(Some(30)) + cellar.GetFormatter.formatGetResult(args.fqn, symbols, None, effectiveLimit, args.hideInherited, args.groupInherited) + case cellar.LookupResult.IsPackage => + s"'${args.fqn}' is a package. Use bleep.symbol.list to explore its contents." + case cellar.LookupResult.PartialMatch(resolvedFqn, missingMember) => + s"Symbol '${args.fqn}' not found. Resolved up to '$resolvedFqn' but member '$missingMember' was not found." + case cellar.LookupResult.NotFound => + val nearMatches = cellar.NearMatchFinder.findNearMatches(args.fqn, classpath).unsafeRunSync()(using cats.effect.unsafe.implicits.global) + val base = s"Symbol '${args.fqn}' not found in project '${args.project}'." + if (nearMatches.isEmpty) base + else s"$base Did you mean one of: ${nearMatches.mkString(", ")}?" + case cellar.LookupResult.LookupFailed(cause) => + s"Symbol lookup for '${args.fqn}' failed: ${cause.getClass.getSimpleName}: ${Option(cause.getMessage).getOrElse("(no message)")}" + } + stalenessBanner(project) + body + } + + private def symbolFind(args: SymbolFindArgs): IO[String] = + withSymbolContext(args.project) { (project, ctx, projectCp, jreCp) => + given tastyquery.Contexts.Context = ctx + val limit = args.limit.getOrElse(50) + val patternOpt = args.pattern.map(_.toLowerCase) + val classpath = jreCp ++ projectCp + val body = args.scope match { + case Some(scopeFqn) => findInScope(scopeFqn, patternOpt, limit, args.project, project, classpath) + case None => findGlobal(patternOpt.get, limit, projectCp, jreCp, args.project, project) + } + stalenessBanner(project) + body + } + + private def findInScope( + scopeFqn: String, + patternOpt: Option[String], + limit: Int, + projectName: String, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + )(using tastyquery.Contexts.Context): String = + cellar.SymbolLister.resolve(scopeFqn).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.ListResolveResult.NotFound => + s"'$scopeFqn' not found in project '$projectName'." + case cellar.ListResolveResult.PartialMatch(resolvedFqn, missingMember) => + s"'$scopeFqn' not found. Resolved up to '$resolvedFqn' but member '$missingMember' was not found." + case cellar.ListResolveResult.Found(target) => + target match { + case cellar.ListTarget.Package(_) => + val all = cellar.SymbolLister.listMembers(target).toList + val filtered = patternOpt.fold(all)(p => all.filter(_.name.toString.toLowerCase.contains(p))) + renderFlat(scopeFqn, filtered, limit, project, classpath) + + case cellar.ListTarget.Cls(cls) => + val declared = cellar.SymbolLister.listMembers(cellar.ListTarget.Cls(cls)).toList + val companion = cellar.SymbolResolver.companionOrJavaStatics(cls) match { + case Some(comp) if comp.isModuleClass => + cellar.SymbolLister.listMembers(cellar.ListTarget.Cls(comp)).toList + case _ => Nil + } + val declaredFiltered = patternOpt.fold(declared)(p => declared.filter(_.name.toString.toLowerCase.contains(p))) + val companionFiltered = patternOpt.fold(companion)(p => companion.filter(_.name.toString.toLowerCase.contains(p))) + renderClassMembers(scopeFqn, declaredFiltered, companionFiltered, limit, project, classpath) + } + } + + private def renderFlat( + scopeFqn: String, + members: List[tastyquery.Symbols.Symbol], + limit: Int, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + )(using tastyquery.Contexts.Context): String = { + if (members.isEmpty) return s"No members of '$scopeFqn' matched." + val truncated = members.length > limit + val lines = members.take(limit).map(sym => formatLineWithLocation(sym, project, classpath)) + val tail = + if (truncated) s"\n\nNote: results truncated at $limit (of ${members.length} total). Increase `limit` to see more." else "" + lines.mkString("\n") + tail + } + + private def renderClassMembers( + scopeFqn: String, + declared: List[tastyquery.Symbols.Symbol], + companion: List[tastyquery.Symbols.Symbol], + limit: Int, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + )(using tastyquery.Contexts.Context): String = { + val total = declared.length + companion.length + if (total == 0) return s"No members of '$scopeFqn' matched." + val declaredTaken = declared.take(limit) + val remaining = math.max(0, limit - declaredTaken.length) + val companionTaken = companion.take(remaining) + val parts = List.newBuilder[String] + if (declaredTaken.nonEmpty) + parts += s"# Declared on $scopeFqn:\n" + declaredTaken.map(sym => formatLineWithLocation(sym, project, classpath)).mkString("\n") + if (companionTaken.nonEmpty) + parts += s"# Declared on $scopeFqn$$ (companion):\n" + companionTaken.map(sym => formatLineWithLocation(sym, project, classpath)).mkString("\n") + val rendered = parts.result().mkString("\n\n") + if (total > limit) rendered + s"\n\nNote: results truncated at $limit (of $total total). Increase `limit` to see more." else rendered + } + + private def findGlobal( + pattern: String, + limit: Int, + projectCp: tastyquery.Classpaths.Classpath, + jreCp: tastyquery.Classpaths.Classpath, + projectName: String, + project: bleep.ResolvedProject + )(using + tastyquery.Contexts.Context + ): String = { + val classpath = jreCp ++ projectCp + val matches = cellar.AllSymbolsStream + .stream(projectCp, jreCp) + .filter(_.name.toString.toLowerCase.contains(pattern)) + .toList + val sorted = matches.sortBy(sym => searchRankKey(pattern, sym.name.toString)) + val limited = sorted.take(limit) + if (limited.isEmpty) return s"No symbols matching '$pattern' found in project '$projectName'." + + // Group adjacent results from the same package — preserves ranking by package-of-first-occurrence while collapsing + // the repeated FQN prefix. Each group renders as `pkg:\n Short — sig` to save tokens on common Spring-style results. + val groups = scala.collection.mutable.LinkedHashMap.empty[String, scala.collection.mutable.ListBuffer[tastyquery.Symbols.Symbol]] + limited.foreach { sym => + val fqn = sym.displayFullName + val idx = fqn.lastIndexOf('.') + val pkg = if (idx < 0) "" else fqn.substring(0, idx) + groups.getOrElseUpdate(pkg, scala.collection.mutable.ListBuffer.empty) += sym + } + + val body = groups.iterator + .map { case (pkg, syms) => + val header = if (pkg.isEmpty) "(top-level):" else s"$pkg:" + val items = syms.iterator + .map { sym => + val name = { + val fqn = sym.displayFullName + val idx = fqn.lastIndexOf('.') + if (idx < 0) fqn else fqn.substring(idx + 1) + } + s" $name — ${formatLineWithLocation(sym, project, classpath)}" + } + .mkString("\n") + s"$header\n$items" + } + .mkString("\n\n") + + val hasScala2 = limited.exists(sym => cellar.TypePrinter.detectLanguage(sym) == cellar.DetectedLanguage.Scala2) + val scala2Note = if (hasScala2) "// Note: results include Scala 2 artifacts; some signatures may be incomplete.\n\n" else "" + val truncationTail = + if (sorted.length > limit) s"\n\n// Showing $limit of ${sorted.length} matches. Increase `limit` for more." + else "" + scala2Note + body + truncationTail + } + + /** Sort key for search results. Exact name match → prefix match → shorter name → alphabetical. */ + private def searchRankKey(lowerQuery: String, name: String): (Int, Int, Int, String) = { + val lowerName = name.toLowerCase + val exact = if (lowerName == lowerQuery) 0 else 1 + val prefix = if (lowerName.startsWith(lowerQuery)) 0 else 1 + (exact, prefix, name.length, name) + } + + private def symbolGetSource(args: SymbolGetSourceArgs): IO[String] = + withSymbolContext(args.project) { (project, ctx, projectCp, _) => + given tastyquery.Contexts.Context = ctx + val body = cellar.SymbolResolver.resolve(args.fqn).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.LookupResult.IsPackage => + s"'${args.fqn}' is a package, not a symbol." + case cellar.LookupResult.NotFound => + s"Symbol '${args.fqn}' not found in project '${args.project}'." + case cellar.LookupResult.PartialMatch(resolvedFqn, missingMember) => + s"Symbol '${args.fqn}' not found. Resolved up to '$resolvedFqn' but member '$missingMember' was not found." + case cellar.LookupResult.LookupFailed(cause) => + s"Symbol lookup for '${args.fqn}' failed: ${cause.getClass.getSimpleName}: ${Option(cause.getMessage).getOrElse("(no message)")}" + case cellar.LookupResult.Found(symbols) => + symbolSourceRef(symbols.head) match { + case None => + s"No source position recorded for '${args.fqn}'. Only Scala 3 (TASTy) and Java symbols carry positions." + case Some(ref) => + val coordEither = args.coordinate match { + case Some(c) => Right(c) + case None => + inferCoordinate(symbols.head, project, projectCp).toRight( + s"Could not auto-infer a Maven coordinate for '${args.fqn}'. Pass `coordinate` explicitly, e.g. via bleep.build.resolved." + ) + } + coordEither match { + case Left(err) => err + case Right(coord) => + bleep.symbols.SourceFetcher.fetch(coord, ref.filePath, ref.startLine, ref.endLine) match { + case Left(err) => err + case Right(result) => + val lineInfo = if (ref.endLine == Int.MaxValue) "" else s" lines ${ref.startLine + 1}–${ref.endLine + 1}" + val header = s"// ${result.entryPath}$lineInfo (from $coord)" + s"```${ref.language}\n$header\n${result.lines.mkString("\n")}\n```" + } + } + } + } + stalenessBanner(project) + body + } + + /** Find which classpath entry holds the symbol's top-level class, then look up that jar in the project's resolved artifacts to recover its Maven + * coordinate. + */ + private def inferCoordinate( + sym: tastyquery.Symbols.Symbol, + project: bleep.ResolvedProject, + classpath: tastyquery.Classpaths.Classpath + ): Option[String] = + topLevelClass(sym).flatMap { topLevel => + val pkgName = topLevel.owner match { + case p: tastyquery.Symbols.PackageSymbol => p.fullName.toString + case _ => "" + } + val binaryName = topLevel.name.toString + val entry = classpath.find { e => + try + e.listAllPackages() + .exists(p => p.dotSeparatedName == pkgName && p.getClassDataByBinaryName(binaryName).isDefined) + catch case _: Exception => false + } + entry.flatMap { e => + val entryPath = java.nio.file.Paths.get(e.toString) + project.resolution.flatMap { res => + res.modules + .find(_.artifacts.exists(a => a.classifier.isEmpty && a.path == entryPath)) + .map(m => s"${m.organization}:${m.name}:${m.version}") + } + } + } + + /** Walk the symbol's owner chain until we hit a package — the result is the top-level enclosing class (the one whose name matches its source file). */ + private def topLevelClass(sym: tastyquery.Symbols.Symbol): Option[tastyquery.Symbols.ClassSymbol] = { + var current: tastyquery.Symbols.Symbol = sym + var lastClass: Option[tastyquery.Symbols.ClassSymbol] = sym match { + case c: tastyquery.Symbols.ClassSymbol => Some(c) + case _ => None + } + while (current.owner != null && !current.owner.isInstanceOf[tastyquery.Symbols.PackageSymbol]) { + current = current.owner + current match { + case c: tastyquery.Symbols.ClassSymbol => lastClass = Some(c) + case _ => () + } + } + lastClass + } + + /** Source range for a symbol. Widens a class's range to cover its companion if they share a file (so `get_source cats.Monad` returns trait + object + * together). + */ + private case class SourceRef(filePath: String, startLine: Int, endLine: Int, language: String) + private def symbolSourceRef(sym: tastyquery.Symbols.Symbol)(using tastyquery.Contexts.Context): Option[SourceRef] = { + val primary = oneSourceRef(sym) + val companion = sym match { + case cls: tastyquery.Symbols.ClassSymbol => cls.companionClass.flatMap(oneSourceRef) + case _ => None + } + (primary, companion) match { + case (Some(p), Some(c)) if p.filePath == c.filePath && p.language == c.language => + Some(SourceRef(p.filePath, math.min(p.startLine, c.startLine), math.max(p.endLine, c.endLine), p.language)) + case _ => primary + } + } + + private def oneSourceRef(sym: tastyquery.Symbols.Symbol): Option[SourceRef] = + sym.tree + .flatMap { t => + val pos = t.asInstanceOf[tastyquery.Trees.Tree].pos + if (pos.isUnknown || pos.isSynthetic || pos.sourceFile == tastyquery.SourceFile.NoSource) None + else Some(SourceRef(pos.sourceFile.path, pos.startLine, pos.endLine, "scala")) + } + .orElse { + sym match { + case s: tastyquery.Symbols.TermOrTypeSymbol if s.sourceLanguage == tastyquery.SourceLanguage.Java => + Some(SourceRef(javaSourcePath(s), 0, Int.MaxValue, "java")) + case _ => None + } + } + + private def javaSourcePath(sym: tastyquery.Symbols.TermOrTypeSymbol): String = { + def topLevel(s: tastyquery.Symbols.TermOrTypeSymbol): tastyquery.Symbols.TermOrTypeSymbol = + s.owner match { + case p: tastyquery.Symbols.TermOrTypeSymbol if !p.isPackage => topLevel(p) + case _ => s + } + topLevel(sym).displayFullName.replace('.', '/') + ".java" + } + /** Compile projects via BSP without collecting events (for run tool). */ private def compileSilently( targetProjects: Array[model.CrossProjectName] diff --git a/bleep-cli/src/scala-3/bleep/mcp/McpToolArgs.scala b/bleep-cli/src/scala-3/bleep/mcp/McpToolArgs.scala index 9af67d5d6..849e3ce6e 100644 --- a/bleep-cli/src/scala-3/bleep/mcp/McpToolArgs.scala +++ b/bleep-cli/src/scala-3/bleep/mcp/McpToolArgs.scala @@ -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 { diff --git a/bleep-cli/src/scala-3/bleep/symbols/SourceFetcher.scala b/bleep-cli/src/scala-3/bleep/symbols/SourceFetcher.scala new file mode 100644 index 000000000..70742d366 --- /dev/null +++ b/bleep-cli/src/scala-3/bleep/symbols/SourceFetcher.scala @@ -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)" + ) + } +} diff --git a/bleep-cli/src/scala-3/bleep/symbols/SymbolsBridge.scala b/bleep-cli/src/scala-3/bleep/symbols/SymbolsBridge.scala new file mode 100644 index 000000000..a5816f379 --- /dev/null +++ b/bleep-cli/src/scala-3/bleep/symbols/SymbolsBridge.scala @@ -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 + } + } +} diff --git a/bleep.yaml b/bleep.yaml index 4a63671bb..66ab6e668 100644 --- a/bleep.yaml +++ b/bleep.yaml @@ -10,6 +10,7 @@ projects: bleep-cli: dependencies: - ch.epfl.scala::bloop-config:2.3.3 + - ch.epfl.scala::tasty-query:1.7.0 - ch.linkyard.mcp::mcp-server:0.3.3 - ch.linkyard.mcp::jsonrpc2-stdio:0.3.3 - com.lihaoyi::pprint:0.9.6 @@ -21,6 +22,7 @@ projects: - org.typelevel::cats-core:2.13.0 - org.typelevel::cats-parse:1.0.0 dependsOn: + - bleep-cellar - bleep-core extends: - template-common @@ -280,6 +282,86 @@ projects: publish: enabled: false source-layout: kotlin + bleep-cellar: + # Vendored from VirtusLab/cellar (MPL-2.0): symbol-lookup library that reads + # tasty/pickles/classfiles via tasty-query, formats Markdown for LLM consumption. + # See ../liberated/cellar/ — trimmed to just lib/src + lib/test + fixtures. + # Uses indent-syntax + accepts upstream's style, so it doesn't extend bleep's + # strict template-common / no-indent template-scala-3. + dependencies: + - ch.epfl.scala::tasty-query:1.7.0 + - org.typelevel::cats-effect:3.6.1 + - io.get-coursier:interface:1.0.28 + - org.scala-lang::scala3-tasty-inspector:3.8.3 + java: + options: -proc:none + platform: + name: jvm + scala: + version: 3.8.3 + sources: ../liberated/cellar/lib/src + bleep-cellar-fixture-java: + java: + options: -proc:none + platform: + name: jvm + sources: ../liberated/cellar/fixtureJava/src + bleep-cellar-fixture-scala2: + java: + options: -proc:none + platform: + name: jvm + scala: + version: 2.13.16 + sources: ../liberated/cellar/fixtureScala2/src + bleep-cellar-fixture-scala3: + java: + options: -proc:none + platform: + name: jvm + scala: + version: 3.8.3 + sources: ../liberated/cellar/fixtureScala3/src + bench-bigdeps: + # Fat-classpath stress target for the symbol-lookup MCP tools. Realistic + # production-flavored stack: Spring Boot web/data/security, Reactor, Netty, + # Jackson, Hibernate (via Spring Data JPA), Guava, Apache Commons. + # The benchmark runner (script `cellar-benchmark`) queries against this + # project's classpath. + dependencies: + - org.springframework.boot:spring-boot-starter-web:3.4.1 + - org.springframework.boot:spring-boot-starter-data-jpa:3.4.1 + - org.springframework.boot:spring-boot-starter-security:3.4.1 + - org.springframework.boot:spring-boot-starter-webflux:3.4.1 + - com.fasterxml.jackson.module:jackson-module-scala_3:2.18.2 + - io.netty:netty-all:4.1.115.Final + - com.google.guava:guava:33.4.0-jre + - org.apache.commons:commons-lang3:3.18.0 + - org.apache.commons:commons-collections4:4.5.0 + java: + options: -proc:none + platform: + name: jvm + publish: + enabled: false + bleep-cellar-tests: + dependencies: + - org.scalameta::munit:1.0.4 + - org.typelevel::munit-cats-effect:2.0.0 + dependsOn: + - bleep-cellar + - bleep-cellar-fixture-java + - bleep-cellar-fixture-scala2 + - bleep-cellar-fixture-scala3 + isTestProject: true + java: + options: -proc:none + platform: + name: jvm + scala: + version: 3.8.3 + sources: ../liberated/cellar/lib/test/src + testFrameworks: munit.Framework bleep-bsp: dependencies: - org.scala-lang::scala3-compiler:3.8.3 @@ -378,6 +460,9 @@ projects: publish: enabled: false scripts: + cellar-benchmark: + main: bleep.scripts.dev.SymbolBenchmark + project: scripts-dev gen-cli-docs: main: bleep.scripts.dev.GenCliDocs project: scripts-dev diff --git a/liberated/cellar b/liberated/cellar new file mode 160000 index 000000000..5c6c9deb1 --- /dev/null +++ b/liberated/cellar @@ -0,0 +1 @@ +Subproject commit 5c6c9deb1588bc810eb4eafbb8cbe46b1a96b84e diff --git a/scripts-dev/src/scala/bleep/scripts/dev/SymbolBenchmark.scala b/scripts-dev/src/scala/bleep/scripts/dev/SymbolBenchmark.scala new file mode 100644 index 000000000..85d9efb5b --- /dev/null +++ b/scripts-dev/src/scala/bleep/scripts/dev/SymbolBenchmark.scala @@ -0,0 +1,317 @@ +package bleep.scripts.dev + +import bleep.{model, BleepScript, Commands, Started} +import bleep.symbols.{SourceFetcher, SymbolsBridge} + +/** Stress-test the symbol-lookup library + its formatters against a realistic fat classpath (Spring Boot + companions, see `bench-bigdeps` in bleep.yaml). + * + * Run with `bleep cellar-benchmark`. + * + * - One Context is built up-front from the bench project's classpath — every scenario reuses it. + * - For each scenario we measure wall-clock latency, print the first ~30 lines of output, and apply a heuristic pass/fail check ("does the answer contain + * this substring"). + * - The final summary is the data we'd actually want for evaluating quality + speed. + */ +object SymbolBenchmark extends BleepScript("SymbolBenchmark") { + + /** Lookup scenario: (label, FQN, expected substring to find in `get` output). */ + private case class GetCase(label: String, fqn: String, expect: String) + + /** Scope-list scenario: (label, scope FQN, optional pattern, expected substring). */ + private case class FindScopeCase(label: String, scope: String, pattern: Option[String], expect: String) + + /** Global-search scenario: (label, pattern, expected substring). */ + private case class FindPatternCase(label: String, pattern: String, expect: String) + + /** Source scenario: (label, FQN, optional coordinate hint, expected substring). */ + private case class SourceCase(label: String, fqn: String, coordinate: Option[String], expect: String) + + private val getCases: List[GetCase] = List( + GetCase("Spring entry point", "org.springframework.boot.SpringApplication", "class SpringApplication"), + GetCase("Spring annotation", "org.springframework.web.bind.annotation.RestController", "RestController"), + GetCase("Spring Data JPA interface", "org.springframework.data.jpa.repository.JpaRepository", "JpaRepository"), + GetCase("Jackson ObjectMapper", "com.fasterxml.jackson.databind.ObjectMapper", "ObjectMapper"), + GetCase("Reactor Flux", "reactor.core.publisher.Flux", "Flux"), + GetCase("Guava ImmutableList", "com.google.common.collect.ImmutableList", "ImmutableList"), + GetCase("Netty ChannelHandler", "io.netty.channel.ChannelHandler", "ChannelHandler"), + GetCase("Hibernate Session", "org.hibernate.Session", "Session"), + GetCase("Spring Security UserDetails", "org.springframework.security.core.userdetails.UserDetails", "UserDetails"), + GetCase("Commons StringUtils", "org.apache.commons.lang3.StringUtils", "StringUtils") + ) + + private val findScopeCases: List[FindScopeCase] = List( + FindScopeCase("Enumerate Spring boot package", "org.springframework.boot", Some("SpringApplication"), "SpringApplication"), + FindScopeCase("ObjectMapper writeValue methods", "com.fasterxml.jackson.databind.ObjectMapper", Some("writeValue"), "writeValue"), + FindScopeCase("Flux flatMap variants", "reactor.core.publisher.Flux", Some("flatMap"), "flatMap"), + FindScopeCase("ImmutableList builder & companion", "com.google.common.collect.ImmutableList", Some("of"), "of"), + FindScopeCase("JpaRepository ancestors + members", "org.springframework.data.jpa.repository.JpaRepository", None, "save") + ) + + private val findPatternCases: List[FindPatternCase] = List( + FindPatternCase("Find RestController across classpath", "RestController", "RestController"), + FindPatternCase("Find ObjectMapper across classpath", "ObjectMapper", "ObjectMapper"), + FindPatternCase("Find flatMap (high-cardinality)", "flatMap", "flatMap"), + FindPatternCase("Find @Transactional", "Transactional", "Transactional"), + FindPatternCase("Find Authentication", "Authentication", "Authentication") + ) + + private val sourceCases: List[SourceCase] = List( + SourceCase("SpringApplication source (auto-infer coord)", "org.springframework.boot.SpringApplication", None, "SpringApplication"), + SourceCase("Flux source (explicit coord)", "reactor.core.publisher.Flux", Some("io.projectreactor:reactor-core:3.7.1"), "Flux") + ) + + override def run(started: Started, commands: Commands, args: List[String]): Unit = { + val benchName = model.CrossProjectName(model.ProjectName("bench-bigdeps"), None) + val project = started.resolvedProject(benchName) + val jvm = started.resolvedJvm.forceGet + + println() + println("=" * 80) + println("SymbolBenchmark — bench-bigdeps") + println("=" * 80) + println(s" classpath entries: ${project.classpath.size} jars + classes dirs") + println(s" JVM: ${jvm.javaBin}") + + val ctxT0 = System.nanoTime() + val jreCp = SymbolsBridge.jreClasspath(jvm) + val projectCp = SymbolsBridge.projectClasspath(project) + val ctx = tastyquery.Contexts.Context.initialize(jreCp ++ projectCp) + val ctxMs = (System.nanoTime() - ctxT0) / 1_000_000L + println(f" Context init: ${ctxMs}%6d ms (one-time)") + println() + + given tastyquery.Contexts.Context = ctx + + val results = scala.collection.mutable.ArrayBuffer.empty[(String, String, Long, Boolean, Int)] + + section("get — full type info") + getCases.foreach { c => + val (out, ms) = time(runGet(c.fqn)) + val ok = out.contains(c.expect) + results += (("get", c.label, ms, ok, out.length)) + report(c.label, c.fqn, ms, ok, out) + } + + section("find (scope) — enumerate package/class with optional pattern") + findScopeCases.foreach { c => + val (out, ms) = time(runFindScope(c.scope, c.pattern)) + val ok = out.contains(c.expect) + results += (("find/scope", c.label, ms, ok, out.length)) + val scopeDesc = c.pattern.fold(c.scope)(p => s"${c.scope} pattern=$p") + report(c.label, scopeDesc, ms, ok, out) + } + + section("find (pattern) — substring across all classpath") + findPatternCases.foreach { c => + val (out, ms) = time(runFindPattern(c.pattern, projectCp, jreCp)) + val ok = out.contains(c.expect) + results += (("find/pattern", c.label, ms, ok, out.length)) + report(c.label, s"pattern=${c.pattern}", ms, ok, out) + } + + section("get_source — fetch -sources.jar and slice") + sourceCases.foreach { c => + val (out, ms) = time(runGetSource(c.fqn, c.coordinate, project, projectCp)) + val ok = out.contains(c.expect) + results += (("get_source", c.label, ms, ok, out.length)) + val coordDesc = c.coordinate.fold("auto")(identity) + report(c.label, s"${c.fqn} ($coordDesc)", ms, ok, out) + } + + println() + println("=" * 80) + println("SUMMARY") + println("=" * 80) + println(f" ${"tool"}%-14s ${"scenario"}%-50s ${"ms"}%8s ${"chars"}%8s ok") + results.foreach { case (tool, label, ms, ok, len) => + val mark = if (ok) " PASS" else " FAIL" + println(f" $tool%-14s $label%-50s $ms%8d $len%8d $mark") + } + val total = results.size + val passed = results.count(_._4) + val avgMs = if (results.isEmpty) 0L else results.map(_._3).sum / results.size + println() + println(f" $passed / $total scenarios passed quality check") + println(f" avg per-scenario latency: $avgMs ms") + } + + private def runGet(fqn: String)(using ctx: tastyquery.Contexts.Context): String = + cellar.SymbolResolver.resolve(fqn).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.LookupResult.Found(symbols) => cellar.GetFormatter.formatGetResult(fqn, symbols, None, Some(30), false, false) + case cellar.LookupResult.IsPackage => s"'$fqn' is a package." + case cellar.LookupResult.PartialMatch(resolved, missing) => s"PartialMatch: resolved=$resolved missing=$missing" + case cellar.LookupResult.NotFound => s"NotFound: $fqn" + case cellar.LookupResult.LookupFailed(cause) => s"LookupFailed: ${cause.getMessage}" + } + + private def runFindScope(scope: String, pattern: Option[String])(using ctx: tastyquery.Contexts.Context): String = + cellar.SymbolLister.resolve(scope).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.ListResolveResult.Found(target) => + val declared = cellar.SymbolLister.listMembers(target).toList + val companion = target match { + case cellar.ListTarget.Cls(cls) => + cellar.SymbolResolver.companionOrJavaStatics(cls) match { + case Some(comp) if comp.isModuleClass => + cellar.SymbolLister.listMembers(cellar.ListTarget.Cls(comp)).toList + case _ => Nil + } + case _ => Nil + } + val all = declared ++ companion + val filtered = pattern.fold(all)(p => all.filter(_.name.toString.toLowerCase.contains(p.toLowerCase))) + filtered.take(50).map(s => cellar.LineFormatter.formatLine(s)).mkString("\n") + case other => s"resolve($scope) = $other" + } + + private def runFindPattern(pattern: String, projectCp: tastyquery.Classpaths.Classpath, jreCp: tastyquery.Classpaths.Classpath)(using + tastyquery.Contexts.Context + ): String = { + val lower = pattern.toLowerCase + val matches = cellar.AllSymbolsStream + .stream(projectCp, jreCp) + .filter(_.name.toString.toLowerCase.contains(lower)) + .toList + val sorted = matches.sortBy { sym => + val n = sym.name.toString + val ln = n.toLowerCase + (if (ln == lower) 0 else 1, if (ln.startsWith(lower)) 0 else 1, n.length, n) + } + val limited = sorted.take(50) + if (limited.isEmpty) return "" + + // Mirror BleepMcpServer.findGlobal: group adjacent results from the same package. + val groups = scala.collection.mutable.LinkedHashMap.empty[String, scala.collection.mutable.ListBuffer[tastyquery.Symbols.Symbol]] + limited.foreach { sym => + val fqn = sym.displayFullName + val idx = fqn.lastIndexOf('.') + val pkg = if (idx < 0) "" else fqn.substring(0, idx) + groups.getOrElseUpdate(pkg, scala.collection.mutable.ListBuffer.empty) += sym + } + val body = groups.iterator + .map { case (pkg, syms) => + val header = if (pkg.isEmpty) "(top-level):" else s"$pkg:" + val items = syms.iterator + .map { sym => + val name = { + val fqn = sym.displayFullName + val idx = fqn.lastIndexOf('.') + if (idx < 0) fqn else fqn.substring(idx + 1) + } + s" $name — ${cellar.LineFormatter.formatLine(sym)}" + } + .mkString("\n") + s"$header\n$items" + } + .mkString("\n\n") + val hasScala2 = limited.exists(sym => cellar.TypePrinter.detectLanguage(sym) == cellar.DetectedLanguage.Scala2) + val scala2Note = if (hasScala2) "// Note: results include Scala 2 artifacts; some signatures may be incomplete.\n\n" else "" + scala2Note + body + } + + private def runGetSource(fqn: String, coordinateHint: Option[String], project: bleep.ResolvedProject, classpath: tastyquery.Classpaths.Classpath)(using + tastyquery.Contexts.Context + ): String = + cellar.SymbolResolver.resolve(fqn).unsafeRunSync()(using cats.effect.unsafe.implicits.global) match { + case cellar.LookupResult.Found(symbols) => + sourceRef(symbols.head) match { + case None => s"No source position for $fqn" + case Some(ref) => + val coord = coordinateHint.orElse(inferCoordinate(symbols.head, project, classpath)) match { + case Some(c) => c + case None => return s"Could not infer coordinate for $fqn" + } + SourceFetcher.fetch(coord, ref.filePath, ref.startLine, ref.endLine) match { + case Right(r) => + val info = if (ref.endLine == Int.MaxValue) "" else s" lines ${ref.startLine + 1}–${ref.endLine + 1}" + s"// ${r.entryPath}$info (from $coord)\n" + r.lines.take(30).mkString("\n") + case Left(err) => err + } + } + case other => s"resolve($fqn) = $other" + } + + private case class SourceRef(filePath: String, startLine: Int, endLine: Int, language: String) + private def sourceRef(sym: tastyquery.Symbols.Symbol): Option[SourceRef] = + sym.tree + .flatMap { t => + val pos = t.asInstanceOf[tastyquery.Trees.Tree].pos + if (pos.isUnknown || pos.isSynthetic || pos.sourceFile == tastyquery.SourceFile.NoSource) None + else Some(SourceRef(pos.sourceFile.path, pos.startLine, pos.endLine, "scala")) + } + .orElse { + sym match { + case s: tastyquery.Symbols.TermOrTypeSymbol if s.sourceLanguage == tastyquery.SourceLanguage.Java => + Some(SourceRef(javaSourcePath(s), 0, Int.MaxValue, "java")) + case _ => None + } + } + + private def javaSourcePath(sym: tastyquery.Symbols.TermOrTypeSymbol): String = { + def topLevel(s: tastyquery.Symbols.TermOrTypeSymbol): tastyquery.Symbols.TermOrTypeSymbol = s.owner match { + case p: tastyquery.Symbols.TermOrTypeSymbol if !p.isPackage => topLevel(p) + case _ => s + } + topLevel(sym).displayFullName.replace('.', '/') + ".java" + } + + private def inferCoordinate(sym: tastyquery.Symbols.Symbol, project: bleep.ResolvedProject, classpath: tastyquery.Classpaths.Classpath): Option[String] = + topLevelClass(sym).flatMap { top => + val pkg = top.owner match { + case p: tastyquery.Symbols.PackageSymbol => p.fullName.toString + case _ => "" + } + val bin = top.name.toString + val entry = classpath.find { e => + try e.listAllPackages().exists(p => p.dotSeparatedName == pkg && p.getClassDataByBinaryName(bin).isDefined) + catch case _: Exception => false + } + entry.flatMap { e => + val ep = java.nio.file.Paths.get(e.toString) + project.resolution.flatMap( + _.modules.find(_.artifacts.exists(a => a.classifier.isEmpty && a.path == ep)).map(m => s"${m.organization}:${m.name}:${m.version}") + ) + } + } + + private def topLevelClass(sym: tastyquery.Symbols.Symbol): Option[tastyquery.Symbols.ClassSymbol] = { + var current: tastyquery.Symbols.Symbol = sym + var last: Option[tastyquery.Symbols.ClassSymbol] = sym match { + case c: tastyquery.Symbols.ClassSymbol => Some(c) + case _ => None + } + while (current.owner != null && !current.owner.isInstanceOf[tastyquery.Symbols.PackageSymbol]) { + current = current.owner + current match { + case c: tastyquery.Symbols.ClassSymbol => last = Some(c) + case _ => () + } + } + last + } + + private def section(title: String): Unit = { + println() + println("-" * 80) + println(title) + println("-" * 80) + } + + private def report(label: String, target: String, ms: Long, ok: Boolean, output: String): Unit = { + val mark = if (ok) "PASS" else "FAIL" + println() + println(f" [$mark] ${label}%-50s ${ms}%6d ms ${target}") + val preview = output.linesIterator.take(30).mkString("\n").trim + if (preview.nonEmpty) { + println(" ───") + preview.linesIterator.foreach(l => println(s" $l")) + val remaining = output.linesIterator.size - 30 + if (remaining > 0) println(s" ... (${remaining} more lines, ${output.length} chars total)") + } + } + + private def time[A](thunk: => A): (A, Long) = { + val t0 = System.nanoTime() + val r = thunk + (r, (System.nanoTime() - t0) / 1_000_000L) + } +}