From 75a18a07f9571412b690ed55ac3c25009e4c7606 Mon Sep 17 00:00:00 2001 From: rochala Date: Sun, 23 Aug 2026 14:19:14 +0200 Subject: [PATCH] Contract eta-expanded type constructors in argument position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing `C` where `F[_]` is expected makes the compiler store the eta-expansion `[X] =>> C[X]` in TASTy, and the printer rendered it literally. That made the Scala 3 output worse than the Scala 2 output for the very same class, and inconsistent within a single line — `List` plain, the other constructor expanded: class NonEmptyList[+A] extends NonEmptyCollection[A, List, [A] =>> NonEmptyList[A]] ... class NonEmptyList[+A] extends NonEmptyCollection[A, List, NonEmptyList] ... // now Found by diffing the 2.13 stdlib read from pickles against the same library recompiled to TASTy: 51 of the 132 differing lines were this, the largest category, and every one favoured the Scala 2 rendering. Contracted only in argument position, and only for a lambda that passes its parameters straight through, in order, with no bounds of their own. The position restriction matters. A hand-written `type F = [A] =>> C[A]` is a different declaration from `type F = C` — its parameter is invariant where C's may not be — and nothing here can tell the two apart: they are structurally identical, and the parameter variance that separates them is package-private in tasty-query. Contracting everywhere would print two distinct sources identically. In argument position the expansion is the compiler's doing; as the right-hand side of an alias it is the author's, so that is left alone. Both directions are pinned by tests. `[A <: AnyRef] =>> F[A]` and `[A] =>> F[G[A]]` keep their rendering, as two pre-existing tests require. Verified against cats-core_3: cats.Applicative still shows its 11 composition lambdas. Co-Authored-By: Claude Opus 5 --- .../fixture/scala3/CellarHigherKinded.scala | 10 +++++ lib/src/cellar/TypePrinter.scala | 44 ++++++++++++++++++- lib/test/src/cellar/TypePrinterTest.scala | 26 +++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala b/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala index b8508d1..05e18c9 100644 --- a/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala +++ b/fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala @@ -5,7 +5,17 @@ trait CellarBox[F[_]] trait CellarBoundedBox[F[_ <: AnyRef]] +/** Passing a type constructor where `F[_]` is expected: TASTy stores the eta-expansion + * `[A] =>> CellarSelfBox[A]`, which should print as the constructor a reader would write. + */ +class CellarSelfBox[A] extends CellarBox[CellarSelfBox] + trait CellarHigherKinded: + /** A hand-written eta-expansion, structurally identical to what the compiler generates when a + * type constructor is passed where `F[_]` is expected. + */ + type HandWrittenEta = [A] =>> List[A] + 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]] diff --git a/lib/src/cellar/TypePrinter.scala b/lib/src/cellar/TypePrinter.scala index 89cff55..740dec2 100644 --- a/lib/src/cellar/TypePrinter.scala +++ b/lib/src/cellar/TypePrinter.scala @@ -59,7 +59,7 @@ object TypePrinter: case Some((lhs, op, rhs)) => s"${printTypeOrWildcard(lhs)} $op ${printTypeOrWildcard(rhs)}" case None => - val args = t.args.map(printTypeOrWildcard).mkString(", ") + val args = t.args.map(printTypeArgument).mkString(", ") s"${printType(t.tycon)}[$args]" case t: ByNameType => s"=> ${printType(t.resultType)}" @@ -146,6 +146,48 @@ object TypePrinter: case other => other.toString + /** + * Renders one type argument, contracting a compiler-generated eta-expansion back to the + * constructor it expanded. + * + * Passing `C` where `F[_]` is expected makes the compiler store `[X] =>> C[X]` in TASTy, so a + * Scala 3 artifact renders `NonEmptyCollection[A, List, [A] =>> NonEmptyList[A]]` where the + * Scala 2 build of the same class renders the `NonEmptyList` a reader would write. + * + * Deliberately limited to argument position, because position is the only signal there is. A + * hand-written `type F = [A] =>> C[A]` is a different declaration from `type F = C`, but the two + * are indistinguishable in the data: measured on a covariant class, the compiler-generated + * expansion reports an *invariant* parameter, exactly like a hand-written one, so comparing the + * lambda's variance against the constructor's does not separate them (and would refuse the very + * case this exists to fix). Reaching the variance at all needs reflection, and it would not help. + * + * So: in argument position the expansion is the compiler's doing, and contracting reads back as + * the source did; as the whole right-hand side of an alias it is the author's, and is left alone. + */ + private def printTypeArgument(arg: TypeOrWildcard)(using ctx: Context): String = + arg match + case tl: TypeLambda => etaContracted(tl).getOrElse(printTypeOrWildcard(tl)) + case other => printTypeOrWildcard(other) + + /** + * `C` for a lambda that just passes its parameters straight through to `C`, in order, with no + * bounds of their own. `[A <: AnyRef] =>> F[A]` says something the bare `F` does not, and + * `[A] =>> F[G[A]]` is not a plain constructor at all — neither contracts. + */ + private def etaContracted(t: TypeLambda)(using ctx: Context): Option[String] = + val trivialBounds = t.paramTypeBounds.forall { + case b: AbstractTypeBounds => printType(b.low) == "Nothing" && printType(b.high) == "Any" + case _ => false + } + t.resultType match + case app: AppliedType if trivialBounds && app.args.sizeIs == t.paramNames.size => + val passesThrough = app.args.zipWithIndex.forall { + case (ref: TypeParamRef, i) => ref.binder == t && ref.paramNum == i + case _ => false + } + Option.when(passesThrough)(printType(app.tycon)) + case _ => None + /** ` >: L <: H`, omitting either half when it is the trivial bound. */ private def printBoundsSuffix(bounds: TypeBounds)(using Context): String = bounds match diff --git a/lib/test/src/cellar/TypePrinterTest.scala b/lib/test/src/cellar/TypePrinterTest.scala index ec7ee5b..b65e3b9 100644 --- a/lib/test/src/cellar/TypePrinterTest.scala +++ b/lib/test/src/cellar/TypePrinterTest.scala @@ -167,6 +167,32 @@ class TypePrinterTest extends CatsEffectSuite: } } + // TASTy stores a type constructor passed to `F[_]` as `[A] =>> C[A]`. Printing the expansion + // makes the Scala 3 rendering worse than the Scala 2 one for the same class, so contract it. + test("printSymbolSignature contracts an eta-expanded type constructor (Scala 3)"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarSelfBox") + val sig = TypePrinter.printSymbolSignature(cls) + assert(!sig.contains("=>>"), s"eta-expansion not contracted: $sig") + assertEquals(sig, "class CellarSelfBox[A] extends CellarBox[CellarSelfBox]") + } + } + + // The mirror of the test above: an author who writes the lambda out is making a different + // declaration from `type HandWrittenEta = List` (its parameter is invariant), so contracting + // here would print two distinct sources identically. Contraction is argument-position only. + test("printSymbolSignature keeps a hand-written eta-expansion in an alias"): + withCtx { ctx => + IO.blocking { + given Context = ctx + val cls = ctx.findStaticClass("cellar.fixture.scala3.CellarHigherKinded") + val alias = cls.declarations.find(_.name.toString == "HandWrittenEta").get + assertEquals(TypePrinter.printSymbolSignature(alias), "type HandWrittenEta = [A] =>> List[A]") + } + } + test("printSymbolSignature renders a bounded standalone type lambda as a type argument (Scala 3)"): withCtx { ctx => IO.blocking {