diff --git a/fixtureScala2/src/cellar/fixture/scala2/CellarADT.scala b/fixtureScala2/src/cellar/fixture/scala2/CellarADT.scala new file mode 100644 index 0000000..cfd012e --- /dev/null +++ b/fixtureScala2/src/cellar/fixture/scala2/CellarADT.scala @@ -0,0 +1,9 @@ +package cellar.fixture.scala2 + +/** Sealed ADT with subtypes nested inside the companion object — the idiomatic Scala 2 ADT pattern. */ +sealed trait CellarADT + +object CellarADT { + case object CellarAA extends CellarADT + final case class CellarAB(value: Int) extends CellarADT +} diff --git a/lib/src/cellar/GetFormatter.scala b/lib/src/cellar/GetFormatter.scala index 0677c0b..0b275a3 100644 --- a/lib/src/cellar/GetFormatter.scala +++ b/lib/src/cellar/GetFormatter.scala @@ -11,7 +11,7 @@ object GetFormatter: hideInherited: Boolean = false, groupInherited: Boolean = false )(using ctx: Context): String = - val fqn = sym.displayFullName + val fqn = normalizeScala2FQN(sym.displayFullName) val signature = TypePrinter.printSymbolSignature(sym) val flags = renderFlags(sym) val origin = renderOrigin(sym) @@ -80,8 +80,8 @@ object GetFormatter: private def renderOrigin(sym: Symbol): String = sym.owner match - case owner: ClassSymbol => owner.displayFullName - case _ => sym.displayFullName + case owner: ClassSymbol => normalizeScala2FQN(owner.displayFullName) + case _ => normalizeScala2FQN(sym.displayFullName) private def renderMembers( sym: Symbol, @@ -89,13 +89,18 @@ object GetFormatter: hideInherited: Boolean, groupInherited: Boolean )(using ctx: Context): Option[String] = - sym match - case cls: ClassSymbol => - // --hide-inherited silently wins over --group-inherited - if hideInherited then renderFlatMembers(cls.declarations.filter(PublicApiFilter.isPublic).toList, limit) - else if groupInherited then renderGroupedMembers(cls, limit) - else renderFlatMembers(SymbolResolver.collectClassMembers(cls).filter(PublicApiFilter.isPublic), limit) - case _ => None + // For a module val (object term), delegate to its module class so that + // standalone objects show their members when resolved via SymbolResolver. + val clsOpt: Option[ClassSymbol] = sym match + case cls: ClassSymbol => Some(cls) + case term: TermSymbol if term.isModuleVal => term.moduleClass + case _ => None + clsOpt.flatMap { cls => + // --hide-inherited silently wins over --group-inherited + if hideInherited then renderFlatMembers(cls.declarations.filter(PublicApiFilter.isPublic).toList, limit) + else if groupInherited then renderGroupedMembers(cls, limit) + else renderFlatMembers(SymbolResolver.collectClassMembers(cls).filter(PublicApiFilter.isPublic), limit) + } private def formatMember(m: TermOrTypeSymbol)(using Context): String = TypePrinter.printSymbolSignatureSafe(m).linesIterator.mkString(" ").trim @@ -166,5 +171,12 @@ object GetFormatter: case cls: ClassSymbol => val children = cls.sealedChildren if children.isEmpty then None - else Some(children.map(_.displayFullName).mkString(", ")) + else Some(children.map(c => normalizeScala2FQN(c.displayFullName)).mkString(", ")) case _ => None + + /** Strip Scala 2 JVM encoding artifacts from a fully-qualified name. + * - `Outer$.Inner` → `Outer.Inner` (companion-object path separator) + * - `Foo$` → `Foo` (module class marker) + */ + private def normalizeScala2FQN(fqn: String): String = + fqn.replace("$.", ".").stripSuffix("$") diff --git a/lib/src/cellar/PublicApiFilter.scala b/lib/src/cellar/PublicApiFilter.scala index 80a628f..a015c8b 100644 --- a/lib/src/cellar/PublicApiFilter.scala +++ b/lib/src/cellar/PublicApiFilter.scala @@ -13,5 +13,6 @@ object PublicApiFilter: private def isSyntheticSym(sym: Symbol): Boolean = sym match - case s: (ClassSymbol | TermSymbol | TypeSymbol) => s.isSynthetic || s.name.toString.startsWith("$") - case _ => false + case s: (ClassSymbol | TermSymbol | TypeSymbol) => + s.isSynthetic || s.name.toString.startsWith("$") || s.name.toString == "" + case _ => false diff --git a/lib/src/cellar/SymbolResolver.scala b/lib/src/cellar/SymbolResolver.scala index 87ad929..4850e9e 100644 --- a/lib/src/cellar/SymbolResolver.scala +++ b/lib/src/cellar/SymbolResolver.scala @@ -4,7 +4,7 @@ import cats.effect.IO import tastyquery.Contexts.Context import tastyquery.Exceptions.MemberNotFoundException import tastyquery.Names.{termName, typeName} -import tastyquery.Symbols.{ClassSymbol, Symbol, TermOrTypeSymbol} +import tastyquery.Symbols.{ClassSymbol, Symbol, TermOrTypeSymbol, TermSymbol} sealed trait LookupResult object LookupResult: @@ -52,7 +52,14 @@ object SymbolResolver: tryOrNone(ctx.findStaticTerm(fqn)), tryOrNone(ctx.findStaticType(fqn)) ).flatten - val all = (direct ++ packageWrapperMembers(fqn)).distinct + val withWrappers = (direct ++ packageWrapperMembers(fqn)).distinct + // Drop module classes whose module val is also in the result set. + // In Scala 2, findStaticModuleClass returns the raw `Foo$` class, while + // findStaticTerm returns the `object Foo` val — keep only the val. + val moduleClassesWithVal = withWrappers.collect { + case t: TermSymbol if t.isModuleVal => t.moduleClass + }.flatten.toSet + val all = withWrappers.filterNot { case s: ClassSymbol => moduleClassesWithVal.contains(s); case _ => false } if all.nonEmpty then Some(LookupResult.Found(all)) else tryOrNone(ctx.findPackage(fqn)).map(_ => LookupResult.IsPackage) diff --git a/lib/src/cellar/TypePrinter.scala b/lib/src/cellar/TypePrinter.scala index 062dbc5..83af08c 100644 --- a/lib/src/cellar/TypePrinter.scala +++ b/lib/src/cellar/TypePrinter.scala @@ -26,7 +26,9 @@ object TypePrinter: case NoPrefix => name case _: ThisType => name case p: Type if isPackageOrNone(p) => name - case p: Type => s"${printType(p)}.$name" + case p: Type => + val prefix = printType(p) + if prefix == "package" then name else s"$prefix.$name" case _ => name case t: AppliedType => @@ -69,7 +71,10 @@ object TypePrinter: s"$n$lo$hi" case _ => n.toString } - s"[${typeParams.mkString(", ")}]${printMethodic(t.resultType)}" + val resultStr = t.resultType match + case r: (MethodType | PolyType) => printMethodic(r) + case r: Type => s": ${printType(r)}" + s"[${typeParams.mkString(", ")}]$resultStr" case t: Type => printType(t) @@ -95,8 +100,12 @@ object TypePrinter: val keyword = termKeyword(term) if term.isModuleVal then s"$keyword ${term.name}" else - val sig = printMethodic(term.declaredType) - s"$keyword ${term.name}$sig" + term.declaredType match + case tpe: (MethodType | PolyType) => + s"$keyword ${term.name}${printMethodic(tpe)}" + case tpe: Type => + // Zero-param def: declared type is the return type directly + s"$keyword ${term.name}: ${printType(tpe)}" case other => other.toString diff --git a/lib/test/src/cellar/GetFormatterTest.scala b/lib/test/src/cellar/GetFormatterTest.scala index 0e13b1e..9f4e34a 100644 --- a/lib/test/src/cellar/GetFormatterTest.scala +++ b/lib/test/src/cellar/GetFormatterTest.scala @@ -182,6 +182,48 @@ class GetFormatterTest extends CatsEffectSuite: } } + // Scala 2 ADT: sealed trait with subtypes nested inside the companion object. + test("formatSymbol for Scala 2 sealed trait lists nested subtypes with clean names"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala2.CellarADT") + val output = GetFormatter.formatSymbol(cls) + assert(output.contains("**Known subtypes:**"), s"Expected subtypes section in:\n$output") + // Names must use source-level dotted notation, not JVM $ encoding + assert(output.contains("CellarADT.CellarAA"), s"Expected clean CellarAA name in:\n$output") + assert(output.contains("CellarADT.CellarAB"), s"Expected clean CellarAB name in:\n$output") + assert(!output.contains("CellarADT$"), s"Expected no JVM-mangled names in:\n$output") + } + } + + test("formatGetResult for Scala 2 sealed trait does not emit duplicate companion module class result"): + withScala2Ctx { ctx => + given Context = ctx + SymbolResolver.resolve("cellar.fixture.scala2.CellarADT").map { + case LookupResult.Found(syms) => + val output = GetFormatter.formatGetResult("cellar.fixture.scala2.CellarADT", syms) + // Should have trait + object, but not the raw CellarADT$ module class as a third block + val headingCount = output.linesIterator.count(_.startsWith("## cellar.fixture.scala2.CellarADT")) + assertEquals(headingCount, 2, s"Expected exactly 2 headings (trait + object), got $headingCount in:\n$output") + case other => fail(s"Expected Found, got $other") + } + } + + test("formatSymbol for standalone object resolved via SymbolResolver shows its members"): + withCtx { ctx => + given Context = ctx + // Celsius is a standalone object (no companion class) with apply, toFahrenheit, value. + // SymbolResolver deduplicates the module class away, leaving only the TermSymbol. + // renderMembers must delegate to the module class so members are not lost. + SymbolResolver.resolve("cellar.fixture.scala3.Celsius").map { + case LookupResult.Found(syms) => + val output = GetFormatter.formatGetResult("cellar.fixture.scala3.Celsius", syms) + assert(output.contains("apply"), s"Expected 'apply' in standalone object output:\n$output") + case other => fail(s"Expected Found, got $other") + } + } + test("formatSymbol --hide-inherited shows only declared members"): withCtx { ctx => IO.blocking { diff --git a/lib/test/src/cellar/SymbolResolverTest.scala b/lib/test/src/cellar/SymbolResolverTest.scala index fc692dd..4af28fb 100644 --- a/lib/test/src/cellar/SymbolResolverTest.scala +++ b/lib/test/src/cellar/SymbolResolverTest.scala @@ -15,6 +15,15 @@ class SymbolResolverTest extends CatsEffectSuite: result <- ContextResource.make(jars, jrePaths).use { (ctx, _) => body(ctx) } yield result + private def withScala2Ctx[A](body: Context => IO[A]): IO[A] = + TestFixtures.assumeFixturesAvailable() + for + jrePaths <- JreClasspath.jrtPath() + jars <- CoursierFetchClient.fetchClasspath( + TestFixtures.scala2Coord, Seq(TestFixtures.localM2Repo)) + result <- ContextResource.make(jars, jrePaths).use { (ctx, _) => body(ctx) } + yield result + test("resolve class FQN returns Found with ClassSymbol"): withCtx { ctx => given Context = ctx @@ -233,3 +242,45 @@ class SymbolResolverTest extends CatsEffectSuite: case other => fail(s"Expected Found, got $other") } } + + // Scala 2 ADT: sealed trait with subtypes nested inside the companion object. + // Fixture: fixtureScala2/src/cellar/fixture/scala2/CellarADT.scala + // sealed trait CellarADT + // object CellarADT { + // case object CellarAA extends CellarADT + // final case class CellarAB(value: Int) extends CellarADT + // } + + test("Scala 2: resolve nested case object inside companion returns Found"): + withScala2Ctx { ctx => + given Context = ctx + SymbolResolver.resolve("cellar.fixture.scala2.CellarADT.CellarAA").map { + case LookupResult.Found(syms) => + assert(syms.nonEmpty, s"Expected non-empty Found, got $syms") + assert(syms.exists(_.name.toString.stripSuffix("$") == "CellarAA"), s"Expected CellarAA symbol, got $syms") + case other => fail(s"Expected Found for CellarAA, got $other") + } + } + + test("Scala 2: resolve nested case class inside companion returns Found"): + withScala2Ctx { ctx => + given Context = ctx + SymbolResolver.resolve("cellar.fixture.scala2.CellarADT.CellarAB").map { + case LookupResult.Found(syms) => + assert(syms.nonEmpty, s"Expected non-empty Found, got $syms") + assert(syms.exists(_.name.toString == "CellarAB"), s"Expected CellarAB symbol, got $syms") + case other => fail(s"Expected Found for CellarAB, got $other") + } + } + + test("Scala 2: resolving sealed trait does not return raw module class as extra result"): + withScala2Ctx { ctx => + given Context = ctx + SymbolResolver.resolve("cellar.fixture.scala2.CellarADT").map { + case LookupResult.Found(syms) => + // Must have the trait and the object, but not the raw CellarADT$ module class + val names = syms.map(_.name.toString) + assert(!names.exists(_.endsWith("$")), s"Unexpected raw module class in results: $syms") + case other => fail(s"Expected Found, got $other") + } + }