diff --git a/lib/src/cellar/AllSymbolsStream.scala b/lib/src/cellar/AllSymbolsStream.scala index be6f24e..5b47574 100644 --- a/lib/src/cellar/AllSymbolsStream.scala +++ b/lib/src/cellar/AllSymbolsStream.scala @@ -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 @@ -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)) } diff --git a/lib/src/cellar/CellarError.scala b/lib/src/cellar/CellarError.scala index 3146334..421e1f1 100644 --- a/lib/src/cellar/CellarError.scala +++ b/lib/src/cellar/CellarError.scala @@ -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 = diff --git a/lib/src/cellar/NearMatchFinder.scala b/lib/src/cellar/NearMatchFinder.scala index 0cf4ded..f864064 100644 --- a/lib/src/cellar/NearMatchFinder.scala +++ b/lib/src/cellar/NearMatchFinder.scala @@ -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 @@ -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) diff --git a/lib/src/cellar/SymbolResolver.scala b/lib/src/cellar/SymbolResolver.scala index ad664a4..4503201 100644 --- a/lib/src/cellar/SymbolResolver.scala +++ b/lib/src/cellar/SymbolResolver.scala @@ -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 object SymbolResolver: def resolve(fqn: String)(using ctx: Context): IO[LookupResult] = @@ -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(_))) /** * Multi-segment nested member walk. @@ -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. @@ -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 @@ -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") diff --git a/lib/src/cellar/handlers/GetHandler.scala b/lib/src/cellar/handlers/GetHandler.scala index 6faeddb..25f2a27 100644 --- a/lib/src/cellar/handlers/GetHandler.scala +++ b/lib/src/cellar/handlers/GetHandler.scala @@ -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. */ diff --git a/lib/src/cellar/handlers/GetSourceHandler.scala b/lib/src/cellar/handlers/GetSourceHandler.scala index add3eba..df04219 100644 --- a/lib/src/cellar/handlers/GetSourceHandler.scala +++ b/lib/src/cellar/handlers/GetSourceHandler.scala @@ -30,6 +30,10 @@ object GetSourceHandler: NearMatchFinder.findNearMatches(fqn, classpath).flatMap { nearMatches => IO.raiseError(CellarError.SymbolNotFound(fqn, coord, nearMatches)) } + 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 => diff --git a/lib/test/src/cellar/CellarErrorTest.scala b/lib/test/src/cellar/CellarErrorTest.scala index 65c5005..9cb2e24 100644 --- a/lib/test/src/cellar/CellarErrorTest.scala +++ b/lib/test/src/cellar/CellarErrorTest.scala @@ -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)