Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reimplement Macros with Scala 3 metaprogramming #689

Merged
merged 14 commits into from
Mar 15, 2022
2 changes: 1 addition & 1 deletion .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
strategy:
matrix:
jdk: [ 11, 8 ]
scala: [ 2.12.15, 2.13.8, 3.0.2 ] # Should be sync with Mergify conditions (.mergify.yml)
scala: [ 2.12.15, 2.13.8, 3.1.2-RC2 ] # Should be sync with Mergify conditions (.mergify.yml)
name: Check / Tests (Scala ${{ matrix.scala }} & JDK ${{ matrix.jdk }})
steps:
- name: Checkout
Expand Down
30 changes: 11 additions & 19 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,10 @@ val isScala3 = Def.setting {
CrossVersion.partialVersion(scalaVersion.value).exists(_._1 != 2)
}

// specs2 hasn't been doing Scala 3 releases, so we use for3Use2_13.
//
// Since these are just test dependencies, it isn't really a problem.
// But if too much more time passes and specs2 still hasn't released
// for Scala 3, we should consider ripping it out.

def specs2(scalaVersion: String) =
Seq(
"org.specs2" %% "specs2-core" % "4.14.1" % Test,
"org.specs2" %% "specs2-junit" % "4.14.1" % Test,
).map(_.cross(CrossVersion.for3Use2_13))
Seq("core", "junit").map { n =>
("org.specs2" %% s"specs2-$n" % "4.14.1").cross(CrossVersion.for3Use2_13) % Test
}

val jacksonVersion = "2.11.4"
val jacksonDatabindVersion = jacksonVersion
Expand Down Expand Up @@ -147,28 +140,27 @@ lazy val `play-json` = crossProject(JVMPlatform, JSPlatform)
.settings(
commonSettings ++ playJsonMimaSettings ++ Def.settings(
libraryDependencies ++= (
if (isScala3.value) Nil
if (isScala3.value) Seq.empty
else
Seq(
"org.scala-lang" % "scala-reflect" % scalaVersion.value,
"com.chuusai" %% "shapeless" % "2.3.8" % Test,
)
Seq("org.scala-lang" % "scala-reflect" % scalaVersion.value)
),
libraryDependencies ++= Seq(
"org.scalatest" %%% "scalatest" % "3.2.11" % Test,
"org.scalatestplus" %%% "scalacheck-1-15" % "3.2.11.0" % Test,
"org.scalacheck" %%% "scalacheck" % "1.15.4" % Test,
("com.chuusai" %% "shapeless" % "2.3.7").cross(CrossVersion.for3Use2_13) % Test
),
libraryDependencies += {
if (isScala3.value)
if (isScala3.value) {
"org.scala-lang" %% "scala3-compiler" % scalaVersion.value % Provided
else
} else {
"org.scala-lang" % "scala-compiler" % scalaVersion.value % Provided
}
},
libraryDependencies ++=
(CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, 13)) => Seq()
case Some((3, _)) => Nil
case Some((2, 13)) => Seq.empty
case Some((3, _)) => Seq.empty
case _ => Seq(compilerPlugin(("org.scalamacros" % "paradise" % "2.1.1").cross(CrossVersion.full)))
}),
Compile / unmanagedSourceDirectories += {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,17 @@ The above example can be made even more concise by using body parsers with a typ

The macros work for classes and traits meeting the following requirements.

**Class in Scala 2.x:**

- It must have a companion object having `apply` and `unapply` methods.
- The return types of the `unapply` must match the argument types of the `apply` method.
- The parameter names of the `apply` method must be the same as the property names desired in the JSON.

**Class in Scala 3.1.x:** (+3.1.2-RC2)

- It must be provided a [`Conversion`](https://dotty.epfl.ch/api/scala/Conversion.html) to a `_ <: Product`.
- It must be provided a valid [`ProductOf`](https://dotty.epfl.ch/api/scala/deriving/Mirror$.html#ProductOf-0).

Case classes automatically meet these requirements. For custom classes or traits, you might have to implement them.

A trait can also supported, if and only if it's a sealed one and if the sub-types comply with the previous requirements:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ private[json] trait JsValueMacros {

}

trait JsMacrosWithOptions extends JsMacros {
trait JsMacrosWithOptions[Opts <: Json.MacroOptions] extends JsMacros {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required for the Scala3 equivalent, not a compatibility issue for me, as compile-time

override def reads[A]: Reads[A] = macro JsMacroImpl.withOptionsReadsImpl[A]
override def writes[A]: OWrites[A] = macro JsMacroImpl.withOptionsWritesImpl[A]
override def format[A]: OFormat[A] = macro JsMacroImpl.withOptionsFormatImpl[A]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/*
* Copyright (C) 2009-2021 Lightbend Inc. <https://www.lightbend.com>
cchantep marked this conversation as resolved.
Show resolved Hide resolved
*/

package play.api.libs.json

import scala.quoted.{ Expr, Quotes, Type }

private[json] trait ImplicitResolver[A] {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type Q <: Quotes

protected implicit val quotes: Q

import quotes.reflect.*

protected implicit val aTpe: Type[A]

protected final lazy val aTpeRepr: TypeRepr = TypeRepr.of(using aTpe)

import Json.Placeholder

// The placeholder type
protected final lazy val PlaceholderType: TypeRepr =
TypeRepr.of[Placeholder]

/**
* Refactor the input types, by replacing any type matching the `filter`,
* by the given `replacement`.
*/
@annotation.tailrec
private def refactor(
in: List[TypeRepr],
base: (TypeRepr, /*Type*/ Symbol),
out: List[TypeRepr],
tail: List[
(List[TypeRepr], (TypeRepr, /*Type*/ Symbol), List[TypeRepr])
],
filter: TypeRepr => Boolean,
replacement: TypeRepr,
altered: Boolean
): (TypeRepr, Boolean) = in match {
case tpe :: ts =>
tpe match {
case t if filter(t) =>
refactor(
ts,
base,
(replacement :: out),
tail,
filter,
replacement,
true
)

case AppliedType(t, as) if as.nonEmpty =>
refactor(
as,
t -> t.typeSymbol,
List.empty,
(ts, base, out) :: tail,
filter,
replacement,
altered
)

case t =>
refactor(
ts,
base,
(t :: out),
tail,
filter,
replacement,
altered
)
}

case _ => {
val tpe = base._1.appliedTo(out.reverse)

tail match {
case (x, y, more) :: ts =>
refactor(
x,
y,
(tpe :: more),
ts,
filter,
replacement,
altered
)

case _ => tpe -> altered
}
}
}

/**
* Replaces any reference to the type itself by the Placeholder type.
* @return the normalized type + whether any self reference has been found
*/
private def normalized(tpe: TypeRepr): (TypeRepr, Boolean) =
tpe match {
case t if t =:= aTpeRepr => PlaceholderType -> true

case AppliedType(t, args) if args.nonEmpty =>
refactor(
args,
t -> t.typeSymbol,
List.empty,
List.empty,
_ =:= aTpeRepr,
PlaceholderType,
false
)

case t => t -> false
}

/* Restores reference to the type itself when Placeholder is found. */
private def denormalized(ptype: TypeRepr): TypeRepr = ptype match {
case t if t =:= PlaceholderType =>
aTpeRepr

case AppliedType(base, args) if args.nonEmpty =>
refactor(
args,
base -> base.typeSymbol,
List.empty,
List.empty,
_ == PlaceholderType,
aTpeRepr,
false
)._1

case _ =>
ptype
}

private val PlaceholderHandlerName =
"play.api.libs.json.Json.Placeholder.Format"

/**
* @param tc the type representation of the typeclass
* @param forwardExpr the `Expr` that forward to the materialized instance itself
*/
private class ImplicitTransformer[T](forwardExpr: Expr[T]) extends TreeMap {
private val denorm = denormalized _

@SuppressWarnings(Array("AsInstanceOf"))
override def transformTree(tree: Tree)(owner: Symbol): Tree = tree match {
case TypeApply(tpt, args) =>
TypeApply(
transformTree(tpt)(owner).asInstanceOf[Term],
args.map(transformTree(_)(owner).asInstanceOf[TypeTree])
)

case t @ (Select(_, _) | Ident(_)) if t.show == PlaceholderHandlerName =>
forwardExpr.asTerm

case tt: TypeTree =>
super.transformTree(
TypeTree.of(using denorm(tt.tpe).asType)
)(owner)

case Apply(fun, args) =>
Apply(transformTree(fun)(owner).asInstanceOf[Term], args.map(transformTree(_)(owner).asInstanceOf[Term]))

case _ =>
super.transformTree(tree)(owner)
}
}

private def createImplicit[M[_]](
debug: String => Unit
)(tc: Type[M], ptype: TypeRepr, tx: TreeMap): Option[Implicit] = {
val pt = ptype.asType
val (ntpe, selfRef) = normalized(ptype)
val ptpe = ntpe

// infers given
val neededGivenType = TypeRepr
.of[M](using tc)
.appliedTo(ptpe)

val neededGiven: Option[Term] = Implicits.search(neededGivenType) match {
case suc: ImplicitSearchSuccess => {
if (!selfRef) {
Some(suc.tree)
} else {
tx.transformTree(suc.tree)(suc.tree.symbol) match {
case t: Term => Some(t)
case _ => Option.empty[Term]
}
}
}

case _ =>
Option.empty[Term]
}

debug {
val show: Option[String] =
try {
neededGiven.map(_.show)
} catch {
case e: MatchError /* Dotty bug */ =>
neededGiven.map(_.symbol.fullName)
}

s"// Resolve given ${prettyType(
TypeRepr.of(using tc)
)} for ${prettyType(ntpe)} as ${prettyType(
neededGivenType
)} (self? ${selfRef}) = ${show.mkString}"
}

neededGiven.map(_ -> selfRef)
}

protected def resolver[M[_], T](
forwardExpr: Expr[M[T]],
debug: String => Unit
)(tc: Type[M]): TypeRepr => Option[Implicit] = {
val tx =
new ImplicitTransformer[M[T]](forwardExpr)

createImplicit(debug)(tc, _: TypeRepr, tx)
}

/**
* @param sym a type symbol
*/
protected def typeName(sym: Symbol): String =
sym.fullName.replaceAll("(\\.package\\$|\\$|java\\.lang\\.|scala\\.Predef\\$\\.)", "")

// To print the implicit types in the compiler messages
private[json] final def prettyType(t: TypeRepr): String = t match {
case _ if t <:< TypeRepr.of[EmptyTuple] =>
"EmptyTuple"

case AppliedType(ty, a :: b :: Nil) if ty <:< TypeRepr.of[*:] =>
s"${prettyType(a)} *: ${prettyType(b)}"

case AppliedType(_, args) =>
typeName(t.typeSymbol) + args.map(prettyType).mkString("[", ", ", "]")

case OrType(a, b) =>
s"${prettyType(a)} | ${prettyType(b)}"

case _ => {
val sym = t.typeSymbol

if (sym.isTypeParam) {
sym.name
} else {
typeName(sym)
}
}
}

type Implicit = (Term, Boolean)
}
Loading