Skip to content
Merged
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
10 changes: 10 additions & 0 deletions fixtureScala3/src/cellar/fixture/scala3/CellarHigherKinded.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
44 changes: 43 additions & 1 deletion lib/src/cellar/TypePrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/test/src/cellar/TypePrinterTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down