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
9 changes: 9 additions & 0 deletions fixtureScala2/src/cellar/fixture/scala2/CellarADT.scala
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 23 additions & 11 deletions lib/src/cellar/GetFormatter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -80,22 +80,27 @@ 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,
limit: Option[Int],
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
Expand Down Expand Up @@ -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("$")
5 changes: 3 additions & 2 deletions lib/src/cellar/PublicApiFilter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "<init>"
case _ => false
11 changes: 9 additions & 2 deletions lib/src/cellar/SymbolResolver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions lib/src/cellar/TypePrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions lib/test/src/cellar/GetFormatterTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions lib/test/src/cellar/SymbolResolverTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}