Skip to content
Merged
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
7 changes: 6 additions & 1 deletion lib/src/cellar/AllSymbolsStream.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import tastyquery.Contexts.Context
import tastyquery.Symbols.TermOrTypeSymbol

object AllSymbolsStream:
private val log = java.util.logging.Logger.getLogger("cellar.AllSymbolsStream")
/** Streams all public symbols from the classpath, excluding the given JRE entries. */
def stream(classpath: Classpath, jreClasspath: Classpath)(using ctx: Context): Stream[IO, TermOrTypeSymbol] =
val jreEntries = jreClasspath.toSet
Expand All @@ -17,7 +18,11 @@ object AllSymbolsStream:
Stream
.eval(IO.blocking {
try ctx.findSymbolsByClasspathEntry(entry).toList
catch case _: Throwable => Nil
catch
case e: Throwable =>
if log.isLoggable(java.util.logging.Level.FINE) then
log.log(java.util.logging.Level.FINE, s"Unexpected exception scanning classpath entry: $entry", e)
Nil
})
.flatMap(syms => Stream.emits(syms))
}
Expand Down
6 changes: 6 additions & 0 deletions lib/src/cellar/CellarError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ object CellarError:
s"$base\n\nDid you mean?\n$hint"
override def getCause: Throwable = cause

final case class SymbolLookupFailed(fqn: String, cause: Throwable) extends CellarError:
override def getMessage: String =
val detail = Option(cause.getMessage).getOrElse("(no message)")
s"Symbol lookup for '$fqn' failed unexpectedly: ${cause.getClass.getSimpleName}: $detail"
override def getCause: Throwable = cause

final case class SymbolNotFound(fqn: String, coord: MavenCoordinate, nearMatches: List[String])
extends CellarError:
override def getMessage: String =
Expand Down
8 changes: 7 additions & 1 deletion lib/src/cellar/NearMatchFinder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import tastyquery.Classpaths.Classpath
import tastyquery.Contexts.Context

object NearMatchFinder:
private val log = java.util.logging.Logger.getLogger("cellar.NearMatchFinder")
def findNearMatches(fqn: String, classpath: Classpath)(using ctx: Context): IO[List[String]] =
IO.blocking {
val simpleName = fqn.lastIndexOf('.') match
Expand All @@ -13,7 +14,12 @@ object NearMatchFinder:
val lowerName = simpleName.toLowerCase

classpath.to(LazyList)
.flatMap(entry => try ctx.findSymbolsByClasspathEntry(entry).toList catch case _: Throwable => Nil)
.flatMap(entry => try ctx.findSymbolsByClasspathEntry(entry).toList catch
case e: Throwable =>
if log.isLoggable(java.util.logging.Level.FINE) then
log.log(java.util.logging.Level.FINE, s"Unexpected exception scanning classpath entry: $entry", e)
Nil
)
.filter(sym => PublicApiFilter.isPublic(sym) && sym.name.toString.toLowerCase == lowerName)
.map(_.displayFullName)
.take(10)
Expand Down
52 changes: 39 additions & 13 deletions lib/src/cellar/SymbolResolver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ object LookupResult:
case object IsPackage extends LookupResult
case object NotFound extends LookupResult
final case class PartialMatch(resolvedFqn: String, missingMember: String) extends LookupResult
final case class LookupFailed(cause: Throwable) extends LookupResult
Comment on lines 12 to +15
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

New LookupFailed behavior isn’t covered by tests. There are existing SymbolResolverTest and CellarErrorTest suites, so it would be good to add coverage that (1) an unexpected tastyquery exception is translated into LookupResult.LookupFailed, and (2) the surfaced CellarError.SymbolLookupFailed message includes the exception type and detail while genuine misses still return NotFound.

Copilot uses AI. Check for mistakes.

object SymbolResolver:
def resolve(fqn: String)(using ctx: Context): IO[LookupResult] =
Expand All @@ -22,17 +23,31 @@ object SymbolResolver:

/** Try to resolve as a top-level class, module, term, type, or package. */
private def tryTopLevel(fqn: String)(using ctx: Context): Option[LookupResult] =
var caught: Option[Throwable] = None
// ClassCastException is a known tastyquery quirk: thrown (instead of MemberNotFoundException)
// when a lookup crosses a package boundary for a non-existent symbol.
def t[A](thunk: => A): Option[A] =
try Some(thunk)
catch
case _: MemberNotFoundException => None
case _: ClassCastException => None
case scala.util.control.NonFatal(e) =>
if caught.isEmpty then caught = Some(e)
None

// A trailing `$` is the JVM-level name of a companion object — treat it as
// an explicit request for the module class, not the class of the same name.
if fqn.endsWith("$") then
val stripped = fqn.stripSuffix("$")
tryOrNone(ctx.findStaticModuleClass(stripped)).map(s => LookupResult.Found(List(s)))
t(ctx.findStaticModuleClass(stripped)).map(s => LookupResult.Found(List(s)))
.orElse(caught.map(LookupResult.LookupFailed(_)))
else
tryOrNone(ctx.findStaticClass(fqn)).map(s => LookupResult.Found(List(s)))
.orElse(tryOrNone(ctx.findStaticModuleClass(fqn)).map(s => LookupResult.Found(List(s))))
t(ctx.findStaticClass(fqn)).map(s => LookupResult.Found(List(s)))
.orElse(t(ctx.findStaticModuleClass(fqn)).map(s => LookupResult.Found(List(s))))
.orElse(tryOrNone(ctx.findStaticTerm(fqn)).map(s => LookupResult.Found(List(s))))
.orElse(tryOrNone(ctx.findStaticType(fqn)).map(s => LookupResult.Found(List(s))))
.orElse(tryOrNone(ctx.findPackage(fqn)).map(_ => LookupResult.IsPackage))
.orElse(caught.map(LookupResult.LookupFailed(_)))
Comment on lines +45 to +50
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

resolve can fall back to tryNestedLookup, but that path still relies on tryOrNone (e.g., in findTopLevelRoot) which swallows all non-MemberNotFoundException exceptions. As a result, unexpected tastyquery failures during nested resolution will still present as NotFound/PartialMatch instead of LookupFailed. To fully address #55, propagate the first unexpected exception through the nested lookup path as well (similar to what you did in tryTopLevel).

Copilot uses AI. Check for mistakes.

/**
* Multi-segment nested member walk.
Expand All @@ -43,11 +58,13 @@ object SymbolResolver:
val segments = fqn.split('.')
if segments.length < 2 then return None

findTopLevelRoot(segments) match
case None => None
case Some((root, rootIdx)) =>
val (root, firstError) = findTopLevelRoot(segments)
root match
case None =>
firstError.map(LookupResult.LookupFailed(_))
case Some((cls, rootIdx)) =>
val resolvedSoFar = segments.take(rootIdx).mkString(".")
walkMembers(root, segments, rootIdx, resolvedSoFar)
walkMembers(cls, segments, rootIdx, resolvedSoFar)

/**
* Walk remaining segments as nested member lookups on the given ClassSymbol.
Expand Down Expand Up @@ -138,7 +155,7 @@ object SymbolResolver:
val segments = fqn.split('.')
if segments.length < 2 then return Left(None)

findTopLevelRoot(segments) match
findTopLevelRoot(segments)._1 match
case None => Left(None)
case Some((root, rootIdx)) =>
var current: ClassSymbol = root
Expand All @@ -157,18 +174,27 @@ object SymbolResolver:

/**
* Try progressively longer prefixes of segments as a top-level class/module.
* Returns the longest matching ClassSymbol and the index past the root segments.
* Returns the longest matching ClassSymbol, the index past the root segments,
* and the first unexpected exception encountered (if any).
*/
private def findTopLevelRoot(segments: Array[String])(using ctx: Context): Option[(ClassSymbol, Int)] =
private def findTopLevelRoot(segments: Array[String])(using ctx: Context): (Option[(ClassSymbol, Int)], Option[Throwable]) =
var best: Option[(ClassSymbol, Int)] = None
var firstError: Option[Throwable] = None
var i = 1
while i < segments.length do
val prefix = segments.take(i + 1).mkString(".")
val found = tryOrNone(ctx.findStaticClass(prefix))
.orElse(tryOrNone(ctx.findStaticModuleClass(prefix)))
def t[A](thunk: => A): Option[A] =
try Some(thunk)
catch
case _: MemberNotFoundException => None
case _: ClassCastException => None
case scala.util.control.NonFatal(e) =>
if firstError.isEmpty then firstError = Some(e)
None
val found = t(ctx.findStaticClass(prefix)).orElse(t(ctx.findStaticModuleClass(prefix)))
if found.isDefined then best = Some((found.get, i + 1))
i += 1
best
(best, firstError)

private[cellar] val universalBaseClasses = Set("scala.Any", "scala.AnyRef", "java.lang.Object")

Expand Down
2 changes: 2 additions & 0 deletions lib/src/cellar/handlers/GetHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ object GetHandler:
else s"$base Did you mean one of: ${nearMatches.mkString(", ")}?"
Console[IO].errorln(msg).as(ExitCode.Error)
}
case LookupResult.LookupFailed(cause) =>
IO.raiseError(CellarError.SymbolLookupFailed(fqn, cause))
}

/** Warns to stderr if the target FQN exists in more than one JAR on the classpath. */
Expand Down
4 changes: 4 additions & 0 deletions lib/src/cellar/handlers/GetSourceHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ object GetSourceHandler:
NearMatchFinder.findNearMatches(fqn, classpath).flatMap { nearMatches =>
IO.raiseError(CellarError.SymbolNotFound(fqn, coord, nearMatches))
}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

This match is still non-exhaustive: SymbolResolver.resolve can return LookupResult.PartialMatch, but GetSourceHandler doesn’t handle it. That will result in a MatchError and an unhelpful error message for inputs like Foo.nonExistentMember. Add a PartialMatch case (likely raising CellarError.PartialResolution, similar to GetHandler).

Suggested change
}
}
case partialMatch: LookupResult.PartialMatch =>
IO.raiseError(CellarError.PartialResolution(fqn, partialMatch))

Copilot uses AI. Check for mistakes.
case LookupResult.PartialMatch(resolvedFqn, missingMember) =>
IO.raiseError(CellarError.PartialResolution(fqn, Some(coord), resolvedFqn, missingMember))
case LookupResult.LookupFailed(cause) =>
IO.raiseError(CellarError.SymbolLookupFailed(fqn, cause))
case LookupResult.Found(symbols) =>
IO.blocking(combinedSourceRef(symbols.head)(using ctx)).flatMap {
case None =>
Expand Down
18 changes: 18 additions & 0 deletions lib/test/src/cellar/CellarErrorTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ class CellarErrorTest extends munit.FunSuite:
assert(e.getMessage.contains("Check that the group ID"))
assert(!e.getMessage.contains("Did you mean?"))

test("SymbolLookupFailed getMessage contains fqn and exception type"):
val cause = new RuntimeException("bad TASTy")
val e = CellarError.SymbolLookupFailed("org.foo.Bar$", cause)
assert(e.getMessage.contains("org.foo.Bar$"))
assert(e.getMessage.contains("RuntimeException"))
assert(e.getMessage.contains("bad TASTy"))

test("SymbolLookupFailed getMessage handles null cause message"):
val cause = new NullPointerException()
val e = CellarError.SymbolLookupFailed("org.foo.Bar$", cause)
assert(e.getMessage.contains("NullPointerException"))
assert(e.getMessage.contains("(no message)"))

test("SymbolLookupFailed getCause returns the provided cause"):
val cause = new RuntimeException("oops")
val e = CellarError.SymbolLookupFailed("org.foo.Bar$", cause)
assertEquals(e.getCause, cause)

test("CellarError subtypes can be caught as CellarError"):
val e: CellarError = CellarError.PackageGivenToGet("cats")
intercept[CellarError](throw e)
Expand Down
Loading