From 4b96b254cc08890527c659844305cebc4c67a027 Mon Sep 17 00:00:00 2001 From: Matej Cerny Date: Sat, 4 Jul 2026 17:23:54 +0200 Subject: [PATCH 1/2] Fix method signature rendering fidelity in TypePrinter --- README.md | 4 +- .../cellar/fixture/scala2/CellarCurried.scala | 6 ++ .../cellar/fixture/scala2/CellarSugar.scala | 14 +++ .../cellar/fixture/scala3/CellarCurried.scala | 5 ++ .../cellar/fixture/scala3/CellarSugar.scala | 14 +++ lib/src/cellar/TypePrinter.scala | 62 +++++++++++-- lib/test/src/cellar/TypePrinterTest.scala | 90 +++++++++++++++++++ 7 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 fixtureScala2/src/cellar/fixture/scala2/CellarCurried.scala create mode 100644 fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala create mode 100644 fixtureScala3/src/cellar/fixture/scala3/CellarCurried.scala create mode 100644 fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala diff --git a/README.md b/README.md index 0490da4..ed2b8ab 100644 --- a/README.md +++ b/README.md @@ -320,10 +320,10 @@ profiling { **Origin:** cats.Monad **Members:** ```scala - def flatMap[A, B](fa: F[A])(f: Function1[A, F[B]]): F[B] + def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] def pure[A](x: A): F[A] def flatten[A](ffa: F[F[A]]): F[A] - def iterateWhile[A](f: F[A])(p: Function1[A, Boolean]): F[A] + def iterateWhile[A](f: F[A])(p: A => Boolean): F[A] ... ``` diff --git a/fixtureScala2/src/cellar/fixture/scala2/CellarCurried.scala b/fixtureScala2/src/cellar/fixture/scala2/CellarCurried.scala new file mode 100644 index 0000000..d261aac --- /dev/null +++ b/fixtureScala2/src/cellar/fixture/scala2/CellarCurried.scala @@ -0,0 +1,6 @@ +package cellar.fixture.scala2 + +/** Fixture for signature rendering: unbounded type params + curried parameter lists. */ +trait CellarCurried { + def combine[A, B](a: A)(b: B): B +} diff --git a/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala b/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala new file mode 100644 index 0000000..5f654cb --- /dev/null +++ b/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala @@ -0,0 +1,14 @@ +package cellar.fixture.scala2 + +/** Fixture for signature rendering: function-type sugar + implicit lists. */ +trait CellarShow[A] { + def show(a: A): A +} + +trait CellarSugar { + def transform[A, B](f: A => B): B + def zip[A, B, C](f: (A, B) => C): C + def nested[A, B, C](f: (A => B) => C): C + def thunk[A](f: () => A): A + def withImplicit[A](a: A)(implicit s: CellarShow[A]): A +} diff --git a/fixtureScala3/src/cellar/fixture/scala3/CellarCurried.scala b/fixtureScala3/src/cellar/fixture/scala3/CellarCurried.scala new file mode 100644 index 0000000..c84ac82 --- /dev/null +++ b/fixtureScala3/src/cellar/fixture/scala3/CellarCurried.scala @@ -0,0 +1,5 @@ +package cellar.fixture.scala3 + +/** Fixture for signature rendering: unbounded type params + curried parameter lists. */ +trait CellarCurried: + def combine[A, B](a: A)(b: B): B diff --git a/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala b/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala new file mode 100644 index 0000000..0a24060 --- /dev/null +++ b/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala @@ -0,0 +1,14 @@ +package cellar.fixture.scala3 + +/** Fixture for signature rendering: function-type sugar + implicit/using lists. */ +trait CellarShow[A]: + def show(a: A): A + +trait CellarSugar: + def transform[A, B](f: A => B): B + def zip[A, B, C](f: (A, B) => C): C + def nested[A, B, C](f: (A => B) => C): C + def thunk[A](f: () => A): A + def ctx[A, B](f: A ?=> B): B + def withImplicit[A](a: A)(implicit s: CellarShow[A]): A + def withUsing[A](a: A)(using s: CellarShow[A]): A diff --git a/lib/src/cellar/TypePrinter.scala b/lib/src/cellar/TypePrinter.scala index 062dbc5..a9da09c 100644 --- a/lib/src/cellar/TypePrinter.scala +++ b/lib/src/cellar/TypePrinter.scala @@ -30,8 +30,16 @@ object TypePrinter: case _ => name case t: AppliedType => - val args = t.args.map(printTypeOrWildcard).mkString(", ") - s"${printType(t.tycon)}[$args]" + asFunction(t) match + case Some((contextual, params, result)) => + val arrow = if contextual then " ?=> " else " => " + val lhs = params match + case single :: Nil if !functionArgNeedsParens(single) => printTypeOrWildcard(single) + case _ => params.map(printTypeOrWildcard).mkString("(", ", ", ")") + s"$lhs$arrow${printTypeOrWildcard(result)}" + case None => + val args = t.args.map(printTypeOrWildcard).mkString(", ") + s"${printType(t.tycon)}[$args]" case t: ByNameType => s"=> ${printType(t.resultType)}" case t: AndType => s"${printType(t.first)} & ${printType(t.second)}" @@ -53,19 +61,25 @@ object TypePrinter: def printMethodic(tpe: TypeOrMethodic)(using ctx: Context): String = tpe match case t: MethodType => - val prefix = if t.isContextual then "using " else "" + val prefix = + if t.isContextual then "using " + else if t.isImplicit then "implicit " + else "" val params = t.paramNames.zip(t.paramTypes).map { (n, tp) => s"$n: ${printType(tp)}" } val paramStr = s"($prefix${params.mkString(", ")})" - s"$paramStr: ${printMethodic(t.resultType)}" + val rest = t.resultType match + case _: MethodType | _: PolyType => printMethodic(t.resultType) + case r => s": ${printMethodic(r)}" + s"$paramStr$rest" case t: PolyType => val typeParams = t.paramNames.zip(t.paramTypeBounds).map { (n, bounds) => bounds match case b: AbstractTypeBounds => - val lo = if b.low.toString == "Nothing" then "" else s" >: ${printType(b.low)}" - val hi = if b.high.toString == "Any" then "" else s" <: ${printType(b.high)}" + val lo = if printType(b.low) == "Nothing" then "" else s" >: ${printType(b.low)}" + val hi = if printType(b.high) == "Any" then "" else s" <: ${printType(b.high)}" s"$n$lo$hi" case _ => n.toString } @@ -87,7 +101,7 @@ object TypePrinter: case cls: ClassSymbol => val kind = if cls.isTrait then "trait" else if cls.isModuleClass then "object" else "class" val typeParams = printClassTypeParams(cls.typeParams) - val parents = cls.parents.map(printType).filter(p => p != "Object" && p != "Any") + val parents = cls.parents.map(printParent).filter(p => p != "Object" && p != "Any") val extendsStr = if parents.isEmpty then "" else s" extends ${parents.mkString(" with ")}" s"$kind ${cls.name}$typeParams$extendsStr" @@ -130,7 +144,41 @@ object TypePrinter: case _ => "?" case t: Type => printType(t) + /** Render a class parent, parenthesising function-arrow sugar so it is valid in `extends` position. */ + private def printParent(tpe: Type)(using ctx: Context): String = + val rendered = printType(tpe) + tpe match + case t: AppliedType if asFunction(t).isDefined => s"($rendered)" + case _ => rendered + private def isPackageOrNone(prefix: Type): Boolean = prefix match case _: ThisType => true case _ => false + + /** Decompose `scala.FunctionN` / `scala.ContextFunctionN` into (isContextual, params, result). */ + private def asFunction(t: AppliedType): Option[(Boolean, List[TypeOrWildcard], TypeOrWildcard)] = + t.tycon match + case tycon: TypeRef if isScalaPackage(tycon.prefix) => + val name = tycon.name.toString + val decoded = + if name.startsWith("ContextFunction") then Some((true, name.stripPrefix("ContextFunction"))) + else if name.startsWith("Function") then Some((false, name.stripPrefix("Function"))) + else None + decoded.flatMap { (contextual, digits) => + digits.toIntOption + .filter(arity => arity >= 0 && t.args.sizeIs == arity + 1) + .map(_ => (contextual, t.args.init, t.args.last)) + } + case _ => None + + private def isScalaPackage(prefix: Prefix): Boolean = + prefix match + case p: PackageRef => p.fullyQualifiedName.toString == "scala" + case _ => false + + /** A function-typed left operand of `=>` must be parenthesised: `(A => B) => C`. */ + private def functionArgNeedsParens(tow: TypeOrWildcard): Boolean = + tow match + case t: AppliedType => asFunction(t).isDefined + case _ => false diff --git a/lib/test/src/cellar/TypePrinterTest.scala b/lib/test/src/cellar/TypePrinterTest.scala index 6182c05..55d4b32 100644 --- a/lib/test/src/cellar/TypePrinterTest.scala +++ b/lib/test/src/cellar/TypePrinterTest.scala @@ -16,6 +16,15 @@ class TypePrinterTest 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("detectLanguage returns Scala3 for scala3 fixture symbol"): withCtx { ctx => IO.blocking { @@ -99,3 +108,84 @@ class TypePrinterTest extends CatsEffectSuite: assert(sig.nonEmpty) } } + + test("printSymbolSignature renders unbounded type params and curried lists (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarCurried") + val combine = cls.declarations.find(_.name.toString == "combine").get + val sig = TypePrinter.printSymbolSignature(combine) + assertEquals(sig, "def combine[A, B](a: A)(b: B): B") + } + } + + test("printSymbolSignature renders unbounded type params and curried lists (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala2.CellarCurried") + val combine = cls.declarations.find(_.name.toString == "combine").get + val sig = TypePrinter.printSymbolSignature(combine) + assertEquals(sig, "def combine[A, B](a: A)(b: B): B") + } + } + + private def sugarSig(fqn: String, method: String)(using ctx: Context): String = + val cls = ctx.findStaticClass(fqn) + TypePrinter.printSymbolSignature(cls.declarations.find(_.name.toString == method).get) + + test("printSymbolSignature renders function types as arrow sugar (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val fqn = "cellar.fixture.scala3.CellarSugar" + assertEquals(sugarSig(fqn, "transform"), "def transform[A, B](f: A => B): B") + assertEquals(sugarSig(fqn, "zip"), "def zip[A, B, C](f: (A, B) => C): C") + assertEquals(sugarSig(fqn, "nested"), "def nested[A, B, C](f: (A => B) => C): C") + assertEquals(sugarSig(fqn, "thunk"), "def thunk[A](f: () => A): A") + assertEquals(sugarSig(fqn, "ctx"), "def ctx[A, B](f: A ?=> B): B") + } + } + + test("printSymbolSignature parenthesises a function-typed parent in extends position"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("scala.PartialFunction") + val sig = TypePrinter.printSymbolSignature(cls) + assert(sig.contains("extends (A => B)"), s"Expected parenthesised function parent in: $sig") + } + } + + test("printSymbolSignature renders implicit and using param lists (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val fqn = "cellar.fixture.scala3.CellarSugar" + assertEquals(sugarSig(fqn, "withImplicit"), "def withImplicit[A](a: A)(implicit s: CellarShow[A]): A") + assertEquals(sugarSig(fqn, "withUsing"), "def withUsing[A](a: A)(using s: CellarShow[A]): A") + } + } + + test("printSymbolSignature renders function types as arrow sugar (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + assertEquals( + sugarSig("cellar.fixture.scala2.CellarSugar", "transform"), + "def transform[A, B](f: A => B): B" + ) + } + } + + test("printSymbolSignature renders an implicit param list (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + assertEquals( + sugarSig("cellar.fixture.scala2.CellarSugar", "withImplicit"), + "def withImplicit[A](a: A)(implicit s: CellarShow[A]): A" + ) + } + } From 3044bf78670c64ecb4ef12023ff1a36a2df18d86 Mon Sep 17 00:00:00 2001 From: Matej Cerny Date: Sat, 4 Jul 2026 17:23:54 +0200 Subject: [PATCH 2/2] Fix HKT, Tuple and infix rendering in TypePrinter --- README.md | 7 +- .../fixture/scala2/CellarHigherKinded.scala | 8 + .../cellar/fixture/scala2/CellarSugar.scala | 6 + .../fixture/scala3/CellarHigherKinded.scala | 15 ++ .../cellar/fixture/scala3/CellarSugar.scala | 11 + lib/src/cellar/TypePrinter.scala | 121 ++++++++--- lib/test/src/cellar/TypePrinterTest.scala | 203 +++++++++++++++++- 7 files changed, 337 insertions(+), 34 deletions(-) create mode 100644 fixtureScala2/src/cellar/fixture/scala2/CellarHigherKinded.scala create mode 100644 fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala diff --git a/README.md b/README.md index ed2b8ab..ac47d52 100644 --- a/README.md +++ b/README.md @@ -320,10 +320,11 @@ profiling { **Origin:** cats.Monad **Members:** ```scala - def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] def pure[A](x: A): F[A] + def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] def flatten[A](ffa: F[F[A]]): F[A] - def iterateWhile[A](f: F[A])(p: A => Boolean): F[A] + def untilM[G[_], A](f: F[A])(cond: => F[Boolean])(implicit G: Alternative[G]): F[G[A]] + def compose[G[_]](implicit evidence$1: Applicative[G]): Applicative[[α] =>> F[G[α]]] ... ``` @@ -333,7 +334,7 @@ profiling { cellar list-external org.typelevel:cats-core_3:2.10.0 cats --limit 5 object Eval$ - trait ComposedContravariantCovariant[F, G] extends Contravariant[TypeLambda] + trait ComposedContravariantCovariant[F, G] extends Contravariant[[α] =>> F[G[α]]] object Later$ object Show$ trait EvalSemigroup[A] extends Semigroup[Eval[A]] diff --git a/fixtureScala2/src/cellar/fixture/scala2/CellarHigherKinded.scala b/fixtureScala2/src/cellar/fixture/scala2/CellarHigherKinded.scala new file mode 100644 index 0000000..83bccbe --- /dev/null +++ b/fixtureScala2/src/cellar/fixture/scala2/CellarHigherKinded.scala @@ -0,0 +1,8 @@ +package cellar.fixture.scala2 + +/** Fixture for signature rendering: higher-kinded type parameters. */ +trait CellarHigherKinded { + def wrap[F[_], A](fa: F[A]): F[A] + def bounded[F[X <: AnyRef]](fa: F[String]): F[String] + def bimap[G[_, _], A, B](g: G[A, B]): G[A, B] +} diff --git a/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala b/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala index 5f654cb..069b35f 100644 --- a/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala +++ b/fixtureScala2/src/cellar/fixture/scala2/CellarSugar.scala @@ -6,9 +6,15 @@ trait CellarShow[A] { } trait CellarSugar { + def wildcard: List[_] + def boundedWildcard: List[_ <: AnyRef] + def transform[A, B](f: A => B): B def zip[A, B, C](f: (A, B) => C): C def nested[A, B, C](f: (A => B) => C): C def thunk[A](f: () => A): A + def pair[A, B](t: (A, B)): (A, B) + def triple[A, B, C](t: (A, B, C)): (A, B, C) + def tupleArg[A, B](f: ((A, B)) => Boolean): Boolean def withImplicit[A](a: A)(implicit s: CellarShow[A]): A } diff --git a/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala b/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala new file mode 100644 index 0000000..b8508d1 --- /dev/null +++ b/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala @@ -0,0 +1,15 @@ +package cellar.fixture.scala3 + +/** Fixture for signature rendering: higher-kinded type parameters. */ +trait CellarBox[F[_]] + +trait CellarBoundedBox[F[_ <: AnyRef]] + +trait CellarHigherKinded: + def wrap[F[_], A](fa: F[A]): F[A] + def compose[F[_], G[_]](bf: CellarBox[F], bg: CellarBox[G]): CellarBox[[A] =>> F[G[A]]] + def composeBounded[F[_]](bf: CellarBox[F]): CellarBoundedBox[[A <: AnyRef] =>> F[A]] + def bounded[F[X <: AnyRef]](fa: F[String]): F[String] + def upper[F[A] <: Iterable[A]](fa: F[Int]): F[Int] + def selfBounded[F[A <: Comparable[A]]](fa: F[String]): F[String] + def bimap[G[_, _], A, B](g: G[A, B]): G[A, B] diff --git a/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala b/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala index 0a24060..30b8b5c 100644 --- a/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala +++ b/fixtureScala3/src/cellar/fixture/scala3/CellarSugar.scala @@ -4,11 +4,22 @@ package cellar.fixture.scala3 trait CellarShow[A]: def show(a: A): A +/** Symbolic binary type constructor, to exercise infix rendering (e.g. `F ~> G`). */ +trait ~>[F[_], G[_]]: + def apply[A](fa: F[A]): G[A] + trait CellarSugar: + def mapK[F[_], G[_]](f: F ~> G): Unit + def wildcard: List[?] + def boundedWildcard: List[? <: AnyRef] + def transform[A, B](f: A => B): B def zip[A, B, C](f: (A, B) => C): C def nested[A, B, C](f: (A => B) => C): C def thunk[A](f: () => A): A def ctx[A, B](f: A ?=> B): B + def pair[A, B](t: (A, B)): (A, B) + def triple[A, B, C](t: (A, B, C)): (A, B, C) + def tupleArg[A, B](f: ((A, B)) => Boolean): Boolean def withImplicit[A](a: A)(implicit s: CellarShow[A]): A def withUsing[A](a: A)(using s: CellarShow[A]): A diff --git a/lib/src/cellar/TypePrinter.scala b/lib/src/cellar/TypePrinter.scala index a9da09c..401c57b 100644 --- a/lib/src/cellar/TypePrinter.scala +++ b/lib/src/cellar/TypePrinter.scala @@ -25,7 +25,7 @@ object TypePrinter: t.prefix match case NoPrefix => name case _: ThisType => name - case p: Type if isPackageOrNone(p) => name + case p: Type if isElidedPrefix(p) => name case p: Type => s"${printType(p)}.$name" case _ => name @@ -38,8 +38,15 @@ object TypePrinter: case _ => params.map(printTypeOrWildcard).mkString("(", ", ", ")") s"$lhs$arrow${printTypeOrWildcard(result)}" case None => - val args = t.args.map(printTypeOrWildcard).mkString(", ") - s"${printType(t.tycon)}[$args]" + asTuple(t) match + case Some(elems) => elems.map(printTypeOrWildcard).mkString("(", ", ", ")") + case None => + asInfix(t) match + case Some((lhs, op, rhs)) => + s"${printTypeOrWildcard(lhs)} $op ${printTypeOrWildcard(rhs)}" + case None => + val args = t.args.map(printTypeOrWildcard).mkString(", ") + s"${printType(t.tycon)}[$args]" case t: ByNameType => s"=> ${printType(t.resultType)}" case t: AndType => s"${printType(t.first)} & ${printType(t.second)}" @@ -56,6 +63,9 @@ object TypePrinter: case t: ConstantType => t.value.value.toString case t: MatchType => s"${printType(t.scrutinee)} match { ... }" case t: FlexibleType => printType(t.nonNullableType) + case t: TypeLambda => + val params = t.paramNames.zip(t.paramTypeBounds).map(printTypeParam) + s"[${params.mkString(", ")}] =>> ${printType(t.resultType)}" case _ => tpe.getClass.getSimpleName def printMethodic(tpe: TypeOrMethodic)(using ctx: Context): String = @@ -75,14 +85,7 @@ object TypePrinter: s"$paramStr$rest" case t: PolyType => - val typeParams = t.paramNames.zip(t.paramTypeBounds).map { (n, bounds) => - bounds match - case b: AbstractTypeBounds => - val lo = if printType(b.low) == "Nothing" then "" else s" >: ${printType(b.low)}" - val hi = if printType(b.high) == "Any" then "" else s" <: ${printType(b.high)}" - s"$n$lo$hi" - case _ => n.toString - } + val typeParams = t.paramNames.zip(t.paramTypeBounds).map(printTypeParam) s"[${typeParams.mkString(", ")}]${printMethodic(t.resultType)}" case t: Type => printType(t) @@ -108,9 +111,7 @@ object TypePrinter: case term: TermSymbol => val keyword = termKeyword(term) if term.isModuleVal then s"$keyword ${term.name}" - else - val sig = printMethodic(term.declaredType) - s"$keyword ${term.name}$sig" + else s"$keyword ${term.name}${printTopLevelMethodic(term.declaredType)}" case other => other.toString @@ -121,25 +122,70 @@ object TypePrinter: else if sym.isModuleVal then "object" else "val" - private def printClassTypeParams(params: List[ClassTypeParamSymbol]): String = + private def printTopLevelMethodic(tpe: TypeOrMethodic)(using ctx: Context): String = + tpe match + case t: Type => s": ${printType(t)}" + case t: PolyType => + val typeParams = t.paramNames.zip(t.paramTypeBounds).map(printTypeParam) + s"[${typeParams.mkString(", ")}]${printTopLevelMethodic(t.resultType)}" + case t: MethodType => printMethodic(t) + + private def printClassTypeParams(params: List[ClassTypeParamSymbol])(using ctx: Context): String = if params.isEmpty then "" else - val rendered = params.map { p => - val variance = p.declaredVariance.productPrefix match - case "Covariant" => "+" - case "Contravariant" => "-" - case _ => "" - s"$variance${p.name}" - } + val rendered = params.map(printClassTypeParam) s"[${rendered.mkString(", ")}]" + private def printClassTypeParam(param: ClassTypeParamSymbol)(using ctx: Context): String = + val variance = param.declaredVariance.productPrefix match + case "Covariant" => "+" + case "Contravariant" => "-" + case _ => "" + s"$variance${printTypeParam(param.name, param.declaredBounds)}" + + private def printTypeParam(name: tastyquery.Names.TypeName, bounds: TypeBounds)(using ctx: Context): String = + bounds match + case b: AbstractTypeBounds => + b.high match + case tl: TypeLambda => s"$name${printHkParams(tl)}" + case _ => + val lo = if printType(b.low) == "Nothing" then "" else s" >: ${printType(b.low)}" + val hi = if printType(b.high) == "Any" then "" else s" <: ${printType(b.high)}" + s"$name$lo$hi" + case _ => name.toString + + private def printHkParams(tl: TypeLambda)(using ctx: Context): String = + val rendered = tl.paramNames.zip(tl.paramTypeBounds).map { (name, bounds) => + val (lo, hi) = bounds match + case b: AbstractTypeBounds => + val lo = if printType(b.low) == "Nothing" then "" else s" >: ${printType(b.low)}" + val hi = if printType(b.high) == "Any" then "" else s" <: ${printType(b.high)}" + (lo, hi) + case _ => ("", "") + (name.toString, lo, hi) + } + // A parameter must be named (not `_`) when it is referenced elsewhere in the lambda: + // in a bound (self-referential, e.g. `A <: Comparable[A]`) or in the constructor + // bound (the lambda's result, e.g. `F[A] <: Iterable[A]`). + val resultStr = printType(tl.resultType) + val context = (resultStr :: rendered.flatMap((_, lo, hi) => List(lo, hi))).mkString(" ") + val params = rendered.map { (name, lo, hi) => + val head = if isMentioned(context, name) then name else "_" + s"$head$lo$hi" + } + val constructorBound = if resultStr == "Any" then "" else s" <: $resultStr" + s"[${params.mkString(", ")}]$constructorBound" + + private def isMentioned(context: String, name: String): Boolean = + context.matches(s"(?s).*\\b${java.util.regex.Pattern.quote(name)}\\b.*") + private def printTypeOrWildcard(tow: TypeOrWildcard)(using ctx: Context): String = tow match case w: WildcardTypeArg => w.bounds match case b: AbstractTypeBounds => - val lo = if b.low.toString == "Nothing" then "" else s" >: ${printType(b.low)}" - val hi = if b.high.toString == "Any" then "" else s" <: ${printType(b.high)}" + val lo = if printType(b.low) == "Nothing" then "" else s" >: ${printType(b.low)}" + val hi = if printType(b.high) == "Any" then "" else s" <: ${printType(b.high)}" if lo.isEmpty && hi.isEmpty then "?" else s"?$lo$hi" case _ => "?" case t: Type => printType(t) @@ -151,9 +197,10 @@ object TypePrinter: case t: AppliedType if asFunction(t).isDefined => s"($rendered)" case _ => rendered - private def isPackageOrNone(prefix: Type): Boolean = + private def isElidedPrefix(prefix: Type): Boolean = prefix match case _: ThisType => true + case t: TermRef => t.name.toString == "Predef" || t.name.toString == "package" case _ => false /** Decompose `scala.FunctionN` / `scala.ContextFunctionN` into (isContextual, params, result). */ @@ -172,13 +219,33 @@ object TypePrinter: } case _ => None + /** Decompose `scala.TupleN` (arity >= 2) into its element types. */ + private def asTuple(t: AppliedType): Option[List[TypeOrWildcard]] = + t.tycon match + case tycon: TypeRef if isScalaPackage(tycon.prefix) => + tycon.name.toString.stripPrefix("Tuple").toIntOption + .filter(arity => arity >= 2 && t.args.sizeIs == arity) + .map(_ => t.args) + case _ => None + + /** Decompose a binary applied type whose tycon is a symbolic operator (e.g. `F ~> G`). */ + private def asInfix(t: AppliedType): Option[(TypeOrWildcard, String, TypeOrWildcard)] = + t.tycon match + case tycon: TypeRef if t.args.sizeIs == 2 && isOperatorName(tycon.name.toString) => + Some((t.args.head, tycon.name.toString, t.args(1))) + case _ => None + + /** An identifier composed solely of operator characters (no letters/digits/`_`/`$`). */ + private def isOperatorName(name: String): Boolean = + name.nonEmpty && name.forall(c => !c.isLetterOrDigit && c != '_' && c != '$') + private def isScalaPackage(prefix: Prefix): Boolean = prefix match case p: PackageRef => p.fullyQualifiedName.toString == "scala" case _ => false - /** A function-typed left operand of `=>` must be parenthesised: `(A => B) => C`. */ + /** A function- or tuple-typed left operand of `=>` must be parenthesised: `(A => B) => C`, `((A, B)) => C`. */ private def functionArgNeedsParens(tow: TypeOrWildcard): Boolean = tow match - case t: AppliedType => asFunction(t).isDefined + case t: AppliedType => asFunction(t).isDefined || asTuple(t).isDefined case _ => false diff --git a/lib/test/src/cellar/TypePrinterTest.scala b/lib/test/src/cellar/TypePrinterTest.scala index 55d4b32..871f5a1 100644 --- a/lib/test/src/cellar/TypePrinterTest.scala +++ b/lib/test/src/cellar/TypePrinterTest.scala @@ -131,6 +131,133 @@ class TypePrinterTest extends CatsEffectSuite: } } + test("printSymbolSignature renders higher-kinded type params (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val wrap = cls.declarations.find(_.name.toString == "wrap").get + val sig = TypePrinter.printSymbolSignature(wrap) + assertEquals(sig, "def wrap[F[_], A](fa: F[A]): F[A]") + } + } + + test("printSymbolSignature renders higher-kinded class type params (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val box = ctx.findStaticClass("cellar.fixture.scala3.CellarBox") + val boundedBox = ctx.findStaticClass("cellar.fixture.scala3.CellarBoundedBox") + assertEquals(TypePrinter.printSymbolSignature(box), "trait CellarBox[F[_]]") + assertEquals(TypePrinter.printSymbolSignature(boundedBox), "trait CellarBoundedBox[F[_ <: AnyRef]]") + } + } + + test("printSymbolSignature renders a standalone type lambda as a type argument (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val compose = cls.declarations.find(_.name.toString == "compose").get + val sig = TypePrinter.printSymbolSignature(compose) + assertEquals( + sig, + "def compose[F[_], G[_]](bf: CellarBox[F], bg: CellarBox[G]): CellarBox[[A] =>> F[G[A]]]" + ) + } + } + + test("printSymbolSignature renders a bounded standalone type lambda as a type argument (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val composeBounded = cls.declarations.find(_.name.toString == "composeBounded").get + val sig = TypePrinter.printSymbolSignature(composeBounded) + assertEquals( + sig, + "def composeBounded[F[_]](bf: CellarBox[F]): CellarBoundedBox[[A <: AnyRef] =>> F[A]]" + ) + } + } + + test("printSymbolSignature renders a bounded higher-kinded type param (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val bounded = cls.declarations.find(_.name.toString == "bounded").get + val sig = TypePrinter.printSymbolSignature(bounded) + assertEquals(sig, "def bounded[F[_ <: AnyRef]](fa: F[String]): F[String]") + } + } + + test("printSymbolSignature renders a constructor-bounded higher-kinded type param (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val upper = cls.declarations.find(_.name.toString == "upper").get + val sig = TypePrinter.printSymbolSignature(upper) + assertEquals(sig, "def upper[F[A] <: Iterable[A]](fa: F[Int]): F[Int]") + } + } + + test("printSymbolSignature renders a self-referential higher-kinded param bound (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val selfBounded = cls.declarations.find(_.name.toString == "selfBounded").get + val sig = TypePrinter.printSymbolSignature(selfBounded) + assertEquals(sig, "def selfBounded[F[A <: Comparable[A]]](fa: F[String]): F[String]") + } + } + + test("printSymbolSignature renders a multi-arity higher-kinded type param (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val bimap = cls.declarations.find(_.name.toString == "bimap").get + val sig = TypePrinter.printSymbolSignature(bimap) + assertEquals(sig, "def bimap[G[_, _], A, B](g: G[A, B]): G[A, B]") + } + } + + test("printSymbolSignature renders higher-kinded type params (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala2.CellarHigherKinded") + val wrap = cls.declarations.find(_.name.toString == "wrap").get + val sig = TypePrinter.printSymbolSignature(wrap) + assertEquals(sig, "def wrap[F[_], A](fa: F[A]): F[A]") + } + } + + test("printSymbolSignature renders a bounded higher-kinded type param (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala2.CellarHigherKinded") + val bounded = cls.declarations.find(_.name.toString == "bounded").get + val sig = TypePrinter.printSymbolSignature(bounded) + assertEquals(sig, "def bounded[F[_ <: AnyRef]](fa: F[String]): F[String]") + } + } + + test("printSymbolSignature renders a multi-arity higher-kinded type param (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala2.CellarHigherKinded") + val bimap = cls.declarations.find(_.name.toString == "bimap").get + val sig = TypePrinter.printSymbolSignature(bimap) + assertEquals(sig, "def bimap[G[_, _], A, B](g: G[A, B]): G[A, B]") + } + } + private def sugarSig(fqn: String, method: String)(using ctx: Context): String = val cls = ctx.findStaticClass(fqn) TypePrinter.printSymbolSignature(cls.declarations.find(_.name.toString == method).get) @@ -148,6 +275,17 @@ class TypePrinterTest extends CatsEffectSuite: } } + test("printSymbolSignature renders tuple types as paren sugar (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val fqn = "cellar.fixture.scala3.CellarSugar" + assertEquals(sugarSig(fqn, "pair"), "def pair[A, B](t: (A, B)): (A, B)") + assertEquals(sugarSig(fqn, "triple"), "def triple[A, B, C](t: (A, B, C)): (A, B, C)") + assertEquals(sugarSig(fqn, "tupleArg"), "def tupleArg[A, B](f: ((A, B)) => Boolean): Boolean") + } + } + test("printSymbolSignature parenthesises a function-typed parent in extends position"): withCtx { ctx => IO.blocking { @@ -158,6 +296,33 @@ class TypePrinterTest extends CatsEffectSuite: } } + test("printSymbolSignature renders symbolic binary types as infix (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val sig = sugarSig("cellar.fixture.scala3.CellarSugar", "mapK") + assertEquals(sig, "def mapK[F[_], G[_]](f: F ~> G): Unit") + } + } + + test("printSymbolSignature collapses trivial wildcard bounds (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val sig = sugarSig("cellar.fixture.scala3.CellarSugar", "wildcard") + assertEquals(sig, "def wildcard: List[?]") + } + } + + test("printSymbolSignature keeps a real wildcard upper bound (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val sig = sugarSig("cellar.fixture.scala3.CellarSugar", "boundedWildcard") + assertEquals(sig, "def boundedWildcard: List[? <: AnyRef]") + } + } + test("printSymbolSignature renders implicit and using param lists (Scala 3)"): withCtx { ctx => IO.blocking { @@ -172,10 +337,40 @@ class TypePrinterTest extends CatsEffectSuite: withScala2Ctx { ctx => IO.blocking { given Context = ctx - assertEquals( - sugarSig("cellar.fixture.scala2.CellarSugar", "transform"), - "def transform[A, B](f: A => B): B" - ) + val fqn = "cellar.fixture.scala2.CellarSugar" + assertEquals(sugarSig(fqn, "transform"), "def transform[A, B](f: A => B): B") + assertEquals(sugarSig(fqn, "zip"), "def zip[A, B, C](f: (A, B) => C): C") + assertEquals(sugarSig(fqn, "nested"), "def nested[A, B, C](f: (A => B) => C): C") + assertEquals(sugarSig(fqn, "thunk"), "def thunk[A](f: () => A): A") + } + } + + test("printSymbolSignature renders tuple types as paren sugar (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val fqn = "cellar.fixture.scala2.CellarSugar" + assertEquals(sugarSig(fqn, "pair"), "def pair[A, B](t: (A, B)): (A, B)") + assertEquals(sugarSig(fqn, "triple"), "def triple[A, B, C](t: (A, B, C)): (A, B, C)") + assertEquals(sugarSig(fqn, "tupleArg"), "def tupleArg[A, B](f: ((A, B)) => Boolean): Boolean") + } + } + + test("printSymbolSignature collapses trivial wildcard bounds (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val sig = sugarSig("cellar.fixture.scala2.CellarSugar", "wildcard") + assertEquals(sig, "def wildcard: List[?]") + } + } + + test("printSymbolSignature keeps a real wildcard upper bound (Scala 2)"): + withScala2Ctx { ctx => + IO.blocking { + given Context = ctx + val sig = sugarSig("cellar.fixture.scala2.CellarSugar", "boundedWildcard") + assertEquals(sig, "def boundedWildcard: List[? <: AnyRef]") } }