From e3eec4aafc8263bc5c34ccd5cbc109074a554f37 Mon Sep 17 00:00:00 2001 From: Michel Davit Date: Tue, 7 Nov 2023 15:10:37 +0100 Subject: [PATCH] Configure shared and test modules to cross-build scala 3 (#676) * Update build setup * setup scala3 on shared + test * FIx build update * Fix enum macros * Update CI script * Fix CI cross building * Add back runtime check for scala 3 * Restrict false EnumType generation in scala 3 * Update sbt-scoverage to 2.0.9 * Add comment --- .github/workflows/ci.yml | 45 ++++++--- build.sbt | 51 ++++++---- project/plugins.sbt | 2 +- .../shared/AnnotationTypeMacros.scala | 49 ++++++++++ .../magnolify/shared/EnumTypeDerivation.scala | 64 ++++++++++++ .../magnolify/shared/EnumTypeMacros.scala | 61 ++++++++++++ .../shared/AnnotationTypeMacros.scala | 58 +++++++++++ .../magnolify/shared/EnumTypeDerivation.scala | 77 +++++++++++++++ .../magnolify/shared/EnumTypeMacros.scala | 47 +++++++++ .../scala-3/magnolify/shims/package.scala | 26 +++++ .../magnolify/shared/AnnotationType.scala | 32 +----- .../scala/magnolify/shared/EnumType.scala | 98 +------------------ .../magnolify/shared/EnumTypeSuite.scala | 91 ++++++++++++++--- .../scala/magnolify/shared/TestEnumType.scala | 2 +- .../scala/magnolify/shims/ShimsSuite.scala | 6 +- 15 files changed, 535 insertions(+), 174 deletions(-) create mode 100644 shared/src/main/scala-2/magnolify/shared/AnnotationTypeMacros.scala create mode 100644 shared/src/main/scala-2/magnolify/shared/EnumTypeDerivation.scala create mode 100644 shared/src/main/scala-2/magnolify/shared/EnumTypeMacros.scala create mode 100644 shared/src/main/scala-3/magnolify/shared/AnnotationTypeMacros.scala create mode 100644 shared/src/main/scala-3/magnolify/shared/EnumTypeDerivation.scala create mode 100644 shared/src/main/scala-3/magnolify/shared/EnumTypeMacros.scala create mode 100644 shared/src/main/scala-3/magnolify/shims/package.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8acee098..21cad3e57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,12 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13, 2.12] + scala: [3, 2.13, 2.12] java: [corretto@17, corretto@11] + project: [rootJVM] exclude: + - scala: 3 + java: corretto@11 - scala: 2.12 java: corretto@11 runs-on: ${{ matrix.os }} @@ -71,16 +74,20 @@ jobs: run: sbt githubWorkflowCheck - name: Build project - if: matrix.scala == '2.13.12' && matrix.java == 'corretto@11' - run: sbt '++ ${{ matrix.scala }}' coverage test coverageAggregate + if: matrix.scala == '2.13' && matrix.java == 'corretto@11' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' coverage test coverageAggregate - name: Upload coverage report - if: matrix.scala == '2.13.12' && matrix.java == 'corretto@11' + if: matrix.scala == '2.13' && matrix.java == 'corretto@11' run: 'bash <(curl -s https://codecov.io/bash)' - name: Build project - if: '!(matrix.scala == ''2.13.12'' && matrix.java == ''corretto@11'')' - run: sbt '++ ${{ matrix.scala }}' test + if: '!(matrix.scala == ''2.13'' && matrix.java == ''corretto@11'' || matrix.scala == ''3'')' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test + + - name: Build project + if: matrix.scala == '3' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' shared/test test/test - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -94,7 +101,7 @@ jobs: if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') uses: actions/upload-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }} + name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} path: targets.tar publish: @@ -138,22 +145,32 @@ jobs: if: matrix.java == 'corretto@11' && steps.setup-java-corretto-11.outputs.cache-hit == 'false' run: sbt +update - - name: Download target directories (2.13) + - name: Download target directories (3, rootJVM) + uses: actions/download-artifact@v3 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM + + - name: Inflate target directories (3, rootJVM) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (2.13, rootJVM) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13 + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJVM - - name: Inflate target directories (2.13) + - name: Inflate target directories (2.13, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12) + - name: Download target directories (2.12, rootJVM) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12 + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM - - name: Inflate target directories (2.12) + - name: Inflate target directories (2.12, rootJVM) run: | tar xf targets.tar rm targets.tar @@ -225,7 +242,7 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: test_2.13 test_2.12 magnolify_2.13 magnolify_2.12 + modules-ignore: test_3 test_2.13 test_2.12 magnolify_3 magnolify_2.13 magnolify_2.12 magnolify_3 magnolify_2.13 magnolify_2.12 magnolify_3 magnolify_2.13 magnolify_2.12 configs-ignore: test scala-tool scala-doc-tool test-internal validate-steward: diff --git a/build.sbt b/build.sbt index 889b35b15..2c2f4fc0c 100644 --- a/build.sbt +++ b/build.sbt @@ -17,7 +17,7 @@ import sbtprotoc.ProtocPlugin.ProtobufConfig import com.typesafe.tools.mima.core._ val magnoliaScala2Version = "1.1.6" -val magnoliaScala3Version = "1.1.4" +val magnoliaScala3Version = "1.3.4" val algebirdVersion = "0.13.10" val avroVersion = Option(sys.props("avro.version")).getOrElse("1.11.2") @@ -103,19 +103,23 @@ ThisBuild / developers := List( val scala3 = "3.3.0" val scala213 = "2.13.12" val scala212 = "2.12.18" -val defaultScala = scala213 +val scalaDefault = scala213 // github actions val java17 = JavaSpec.corretto("17") val java11 = JavaSpec.corretto("11") -val defaultJava = java11 +val javaDefault = java11 val coverageCond = Seq( - s"matrix.scala == '$defaultScala'", - s"matrix.java == '${defaultJava.render}'" + s"matrix.scala == '${CrossVersion.binaryScalaVersion(scalaDefault)}'", + s"matrix.java == '${javaDefault.render}'" ).mkString(" && ") - -ThisBuild / scalaVersion := defaultScala -ThisBuild / crossScalaVersions := Seq(scala213, scala212) +val scala3Cond = "matrix.scala == '3'" +val scala3Projects = List( + "shared", + "test" +) +ThisBuild / scalaVersion := scalaDefault +ThisBuild / crossScalaVersions := Seq(scala3, scala213, scala212) ThisBuild / githubWorkflowTargetBranches := Seq("main") ThisBuild / githubWorkflowJavaVersions := Seq(java17, java11) ThisBuild / githubWorkflowBuild := Seq( @@ -129,7 +133,16 @@ ThisBuild / githubWorkflowBuild := Seq( name = Some("Upload coverage report"), cond = Some(coverageCond) ), - WorkflowStep.Sbt(List("test"), name = Some("Build project"), cond = Some(s"!($coverageCond)")) + WorkflowStep.Sbt( + List("test"), + name = Some("Build project"), + cond = Some(s"!($coverageCond || $scala3Cond)") + ), + WorkflowStep.Sbt( + scala3Projects.map(p => s"$p/test"), + name = Some("Build project"), + cond = Some(scala3Cond) + ) ) ThisBuild / githubWorkflowAddedJobs ++= Seq( WorkflowJob( @@ -142,8 +155,8 @@ ThisBuild / githubWorkflowAddedJobs ++= Seq( name = Some("Build project") ) ), - scalas = List(defaultScala), - javas = List(defaultJava) + scalas = List(scalaDefault), + javas = List(javaDefault) ) ) @@ -179,13 +192,16 @@ lazy val keepExistingHeader = val commonSettings = Seq( tlFatalWarnings := false, tlJdkRelease := Some(8), + // So far most projects do no support scala 3 + crossScalaVersions := Seq(scala213, scala212), + scalaVersion := scalaDefault, scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { case Some((3, _)) => Seq( // required by magnolia for accessing default values - "-Xretain-trees", + "-Yretain-trees", // tolerate some nested macro expansion - "-Ymax-inlines", + "-Xmax-inlines", "64" ) case Some((2, 13)) => @@ -232,11 +248,9 @@ val commonSettings = Seq( Test / classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Flat ) -lazy val root = project - .in(file(".")) +lazy val root = tlCrossRootProject .enablePlugins(NoPublishPlugin) .settings( - commonSettings, name := "magnolify", description := "A collection of Magnolia add-on modules" ) @@ -262,6 +276,7 @@ lazy val shared = project .in(file("shared")) .settings( commonSettings, + crossScalaVersions := Seq(scala3, scala213, scala212), moduleName := "magnolify-shared", description := "Shared code for Magnolify" ) @@ -271,8 +286,9 @@ lazy val test = project .in(file("test")) .enablePlugins(NoPublishPlugin) .dependsOn(shared) + .settings(commonSettings) .settings( - commonSettings, + crossScalaVersions := Seq(scala3, scala213, scala212), libraryDependencies ++= Seq( "org.scalameta" %% "munit-scalacheck" % munitVersion % Test, "org.typelevel" %% "cats-core" % catsVersion % Test @@ -557,6 +573,7 @@ lazy val jmh: Project = project ) .settings( commonSettings, + crossScalaVersions := Seq(scalaDefault), Jmh / classDirectory := (Test / classDirectory).value, Jmh / dependencyClasspath := (Test / dependencyClasspath).value, // rewire tasks, so that 'jmh:run' automatically invokes 'jmh:compile' diff --git a/project/plugins.sbt b/project/plugins.sbt index 680451bb6..a7ffe5dc0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.6.0") addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.6") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9") addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.4") diff --git a/shared/src/main/scala-2/magnolify/shared/AnnotationTypeMacros.scala b/shared/src/main/scala-2/magnolify/shared/AnnotationTypeMacros.scala new file mode 100644 index 000000000..f8fe4b33c --- /dev/null +++ b/shared/src/main/scala-2/magnolify/shared/AnnotationTypeMacros.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import scala.reflect.macros.whitebox + +object AnnotationTypeMacros { + def annotationTypeMacro[T: c.WeakTypeTag](c: whitebox.Context): c.Tree = { + import c.universe._ + val wtt = weakTypeTag[T] + val pre = wtt.tpe.asInstanceOf[TypeRef].pre + + // Scala 2.12 & 2.13 macros seem to handle annotations differently + // Scala annotation works in both but Java annotations only works in 2.13 + val saType = typeOf[scala.annotation.StaticAnnotation] + val jaType = typeOf[java.lang.annotation.Annotation] + // Annotation for Scala enumerations are on the outer object + val annotated = if (pre <:< typeOf[scala.Enumeration]) pre else wtt.tpe + val trees = annotated.typeSymbol.annotations.map(_.tree).collect { + case t @ q"new $n(..$args)" if t.tpe <:< saType && !(t.tpe <:< jaType) => + // FIXME `t.tree` should work but somehow crashes the compiler + q"new $n(..$args)" + } + + // Get Java annotations via reflection + val j = q"classOf[${annotated.typeSymbol.asClass}].getAnnotations.toList" + val annotations = q"_root_.scala.List(..$trees) ++ $j" + + q"_root_.magnolify.shared.AnnotationType[$wtt]($annotations)" + } +} + +trait AnnotationTypeCompanionMacros { + implicit def gen[T]: AnnotationType[T] = macro AnnotationTypeMacros.annotationTypeMacro[T] +} diff --git a/shared/src/main/scala-2/magnolify/shared/EnumTypeDerivation.scala b/shared/src/main/scala-2/magnolify/shared/EnumTypeDerivation.scala new file mode 100644 index 000000000..3d0bf9f33 --- /dev/null +++ b/shared/src/main/scala-2/magnolify/shared/EnumTypeDerivation.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import magnolia1.{CaseClass, SealedTrait} + +import scala.annotation.{implicitNotFound, nowarn} + +trait EnumTypeDerivation { + type Typeclass[T] = EnumType[T] + + // EnumType can only be split into objects with fixed name + // Avoid invalid ADT derivation involving products by requiring + // implicit EnumValue type-class in magnolia join + // see https://github.com/softwaremill/magnolia/issues/267 + @implicitNotFound("Cannot derive EnumType.EnumValue. EnumType only works for sum types") + trait EnumValue[T] + + implicit def genEnumValue[T]: EnumValue[T] = macro EnumTypeMacros.genEnumValueMacro[T] + + @nowarn + def join[T: EnumValue](caseClass: CaseClass[Typeclass, T]): Typeclass[T] = { + val n = caseClass.typeName.short + val ns = caseClass.typeName.owner + EnumType.create( + n, + ns, + List(n), + caseClass.annotations.toList, + _ => caseClass.rawConstruct(Nil) + ) + } + + def split[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = { + val n = sealedTrait.typeName.short + val ns = sealedTrait.typeName.owner + val subs = sealedTrait.subtypes.map(_.typeclass) + val values = subs.flatMap(_.values).sorted.toList + val annotations = (sealedTrait.annotations ++ subs.flatMap(_.annotations)).toList + EnumType.create( + n, + ns, + values, + annotations, + // it is ok to use the inefficient find here because it will be called only once + // and cached inside an instance of EnumType + v => subs.find(_.name == v).get.from(v) + ) + } +} diff --git a/shared/src/main/scala-2/magnolify/shared/EnumTypeMacros.scala b/shared/src/main/scala-2/magnolify/shared/EnumTypeMacros.scala new file mode 100644 index 000000000..7a9f4f3bf --- /dev/null +++ b/shared/src/main/scala-2/magnolify/shared/EnumTypeMacros.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import magnolia1.Magnolia +import scala.reflect.macros.whitebox + +object EnumTypeMacros { + def scalaEnumTypeMacro[T: c.WeakTypeTag]( + c: whitebox.Context + )(annotations: c.Expr[AnnotationType[T]]): c.Tree = { + import c.universe._ + val wtt = weakTypeTag[T] + val ref = wtt.tpe.asInstanceOf[TypeRef] + val name = ref.pre.typeSymbol.asClass.fullName // find the enum type from the value type + val idx = name.lastIndexOf('.') + val n = name.drop(idx + 1) + val ns = name.take(idx) + val list = q"${ref.pre.termSymbol}.values.iterator.map(_.toString).toList" + val map = q"${ref.pre.termSymbol}.values.iterator.map(x => x.toString -> x).toMap" + q""" + _root_.magnolify.shared.EnumType.create[$wtt]( + $n, $ns, $list, $annotations.annotations, $map.apply(_) + ) + """ + } + + def genEnumValueMacro[T: c.WeakTypeTag](c: whitebox.Context): c.Tree = { + import c.universe._ + val tpe = weakTypeOf[T] + val symbol = tpe.typeSymbol + if (symbol.isModuleClass) { + q"new _root_.magnolify.shared.EnumType.EnumValue[$tpe]{}" + } else { + c.abort(c.enclosingPosition, "EnumType value must be an object") + } + } +} + +trait EnumTypeCompanionMacros extends EnumTypeCompanionLowPrioMacros { + implicit def scalaEnumType[T <: Enumeration#Value: AnnotationType]: EnumType[T] = + macro EnumTypeMacros.scalaEnumTypeMacro[T] +} + +trait EnumTypeCompanionLowPrioMacros extends EnumTypeDerivation { + implicit def gen[T]: EnumType[T] = macro Magnolia.gen[T] +} diff --git a/shared/src/main/scala-3/magnolify/shared/AnnotationTypeMacros.scala b/shared/src/main/scala-3/magnolify/shared/AnnotationTypeMacros.scala new file mode 100644 index 000000000..bf2f0dfc5 --- /dev/null +++ b/shared/src/main/scala-3/magnolify/shared/AnnotationTypeMacros.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import scala.quoted.* +import scala.reflect.ClassTag + +object AnnotationTypeMacros: + private def getClassTag[T](using Type[T], Quotes): Expr[ClassTag[T]] = + import quotes.reflect._ + Expr.summon[ClassTag[T]] match + case Some(ct) => + ct + case None => + report.error( + s"Unable to find a ClassTag for type ${Type.show[T]}", + Position.ofMacroExpansion + ) + throw new Exception("Error when applying macro") + + def annotationTypeMacro[T: Type](using quotes: Quotes): Expr[AnnotationType[T]] = + import quotes.reflect.* + val annotated = Type.of[T] match + case '[Enumeration#Value] => + // Annotation for Scala enumerations are on the outer object + val TypeRef(pre, _) = TypeRepr.of[T]: @unchecked + pre + case _ => + TypeRepr.of[T] + + // only collect scala annotations + val sAnnotations = + Expr.ofList[Any](annotated.typeSymbol.annotations.map(_.asExprOf[Any]).filter { + case '{ $x: java.lang.annotation.Annotation } => false + case '{ $x: scala.annotation.StaticAnnotation } => true + case _ => false + }) + val jAnnotations = annotated.asType match + case '[t] => '{ ${ getClassTag[t] }.runtimeClass.getAnnotations.toList } + val annotations = '{ $sAnnotations ++ $jAnnotations } + '{ AnnotationType[T]($annotations) } + +trait AnnotationTypeCompanionMacros: + inline given gen[T]: AnnotationType[T] = ${ AnnotationTypeMacros.annotationTypeMacro[T] } diff --git a/shared/src/main/scala-3/magnolify/shared/EnumTypeDerivation.scala b/shared/src/main/scala-3/magnolify/shared/EnumTypeDerivation.scala new file mode 100644 index 000000000..784ff722d --- /dev/null +++ b/shared/src/main/scala-3/magnolify/shared/EnumTypeDerivation.scala @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import magnolia1.{CaseClass, CommonDerivation, SealedTrait, SealedTraitDerivation} + +import scala.compiletime.* +import scala.deriving.Mirror + +// Do not extend Derivation so we can add an extra check when deriving the sum type +trait EnumTypeDerivation extends CommonDerivation[EnumType] with SealedTraitDerivation: + + transparent inline def subtypes[T, SubtypeTuple <: Tuple]( + m: Mirror.SumOf[T], + idx: Int = 0 // no longer used, kept for bincompat + ): List[SealedTrait.Subtype[Typeclass, T, _]] = + subtypesFromMirror[T, SubtypeTuple](m, idx) + + inline def derivedMirrorSum[A](sum: Mirror.SumOf[A]): EnumType[A] = + summonAll[Tuple.Map[sum.MirroredElemTypes, [S] =>> S <:< Singleton]] // assert all singleton + split(sealedTraitFromMirror(sum)) + + inline def derivedMirror[A](using mirror: Mirror.Of[A]): EnumType[A] = + inline mirror match + case sum: Mirror.SumOf[A] => derivedMirrorSum[A](sum) + case product: Mirror.ProductOf[A] => derivedMirrorProduct[A](product) + + inline def derived[A](using Mirror.Of[A]): EnumType[A] = derivedMirror[A] + + protected override inline def deriveSubtype[s](m: Mirror.Of[s]): EnumType[s] = + derivedMirror[s](using m) + + def join[T](caseClass: CaseClass[EnumType, T]): EnumType[T] = + // fail at runtime since we can't prevent derivation + // see https://github.com/softwaremill/magnolia/issues/267 + require(caseClass.isObject, s"Cannot derive EnumType[T] for case class ${caseClass.typeInfo}") + val n = caseClass.typeInfo.short + val ns = caseClass.typeInfo.owner + EnumType.create( + n, + ns, + List(n), + caseClass.annotations.toList, + _ => caseClass.rawConstruct(Nil) + ) + end join + + def split[T](sealedTrait: SealedTrait[EnumType, T]): EnumType[T] = + val n = sealedTrait.typeInfo.short + val ns = sealedTrait.typeInfo.owner + val subs = sealedTrait.subtypes.map(_.typeclass) + val values = subs.flatMap(_.values).sorted.toList + val annotations = (sealedTrait.annotations ++ subs.flatMap(_.annotations)).toList + EnumType.create( + n, + ns, + values, + annotations, + // it is ok to use the inefficient find here because it will be called only once + // and cached inside an instance of EnumType + v => subs.find(_.name == v).get.from(v) + ) + end split diff --git a/shared/src/main/scala-3/magnolify/shared/EnumTypeMacros.scala b/shared/src/main/scala-3/magnolify/shared/EnumTypeMacros.scala new file mode 100644 index 000000000..5c6bfd0cf --- /dev/null +++ b/shared/src/main/scala-3/magnolify/shared/EnumTypeMacros.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify.shared + +import scala.quoted.* +import scala.deriving.Mirror + +object EnumTypeMacros: + def scalaEnumTypeMacro[T: Type](annotations: Expr[AnnotationType[T]])(using + quotes: Quotes + ): Expr[EnumType[T]] = + import quotes.reflect.* + val TypeRef(ref, _) = TypeRepr.of[T]: @unchecked // find the enum type from the value type + val e = Ref(ref.termSymbol).asExprOf[Enumeration] + val name = ref.show + val idx = name.lastIndexOf('.') + val n = Expr(name.drop(idx + 1)) + val ns = Expr(name.take(idx)) + val vs = '{ $e.values.toList.map(_.toString) } + val as = '{ $annotations.annotations } + val map = '{ $e.values.iterator.map(x => x.toString -> x.asInstanceOf[T]).toMap.apply(_) } + '{ EnumType.create[T]($n, $ns, $vs, $as, $map) } + +trait EnumTypeCompanionMacros extends EnumTypeCompanionMacros0 + +trait EnumTypeCompanionMacros0 extends EnumTypeCompanionMacros1: + inline given scalaEnumType[T <: Enumeration#Value](using + annotations: AnnotationType[T] + ): EnumType[T] = + ${ EnumTypeMacros.scalaEnumTypeMacro[T]('annotations) } + +trait EnumTypeCompanionMacros1 extends EnumTypeDerivation: + inline given gen[T](using Mirror.Of[T]): EnumType[T] = derivedMirror[T] diff --git a/shared/src/main/scala-3/magnolify/shims/package.scala b/shared/src/main/scala-3/magnolify/shims/package.scala new file mode 100644 index 000000000..867f7cdd3 --- /dev/null +++ b/shared/src/main/scala-3/magnolify/shims/package.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package magnolify + +import scala.util.hashing.MurmurHash3 +import scala.collection.compat.Factory + +package object shims: + type FactoryCompat[-A, +C] = Factory[A, C] + + object MurmurHash3Compat: + def seed(data: Int): Int = MurmurHash3.mix(MurmurHash3.productSeed, data) diff --git a/shared/src/main/scala/magnolify/shared/AnnotationType.scala b/shared/src/main/scala/magnolify/shared/AnnotationType.scala index fb166f455..6ea051731 100644 --- a/shared/src/main/scala/magnolify/shared/AnnotationType.scala +++ b/shared/src/main/scala/magnolify/shared/AnnotationType.scala @@ -16,34 +16,8 @@ package magnolify.shared -import scala.reflect.macros._ +final case class AnnotationType[T](annotations: List[Any]) -sealed case class AnnotationType[T](annotations: List[Any]) - -object AnnotationType { - implicit def apply[T]: AnnotationType[T] = macro applyImpl[T] - - def applyImpl[T: c.WeakTypeTag](c: whitebox.Context): c.Tree = { - import c.universe._ - val wtt = weakTypeTag[T] - val pre = wtt.tpe.asInstanceOf[TypeRef].pre - - // Scala 2.12 & 2.13 macros seem to handle annotations differently - // Scala annotation works in both but Java annotations only works in 2.13 - val saType = typeOf[scala.annotation.StaticAnnotation] - val jaType = typeOf[java.lang.annotation.Annotation] - // Annotation for Scala enumerations are on the outer object - val annotated = if (pre <:< typeOf[scala.Enumeration]) pre else wtt.tpe - val trees = annotated.typeSymbol.annotations.map(_.tree).collect { - case t @ q"new $n(..$args)" if t.tpe <:< saType && !(t.tpe <:< jaType) => - // FIXME `t` should work but somehow crashes the compiler - q"new $n(..$args)" - } - - // Get Java annotations via reflection - val j = q"classOf[${annotated.typeSymbol.asClass}].getAnnotations.toList" - val annotations = q"_root_.scala.List(..$trees) ++ $j" - - q"new _root_.magnolify.shared.AnnotationType[$wtt]($annotations)" - } +object AnnotationType extends AnnotationTypeCompanionMacros { + def apply[T](implicit et: AnnotationType[T]): AnnotationType[T] = et } diff --git a/shared/src/main/scala/magnolify/shared/EnumType.scala b/shared/src/main/scala/magnolify/shared/EnumType.scala index cd75ef0a9..1dad7aee3 100644 --- a/shared/src/main/scala/magnolify/shared/EnumType.scala +++ b/shared/src/main/scala/magnolify/shared/EnumType.scala @@ -16,11 +16,7 @@ package magnolify.shared -import magnolia1._ - import scala.reflect.ClassTag -import scala.reflect.macros._ -import scala.annotation.{implicitNotFound, nowarn} sealed trait EnumType[T] extends Serializable { self => val name: String @@ -43,7 +39,7 @@ sealed trait EnumType[T] extends Serializable { self => } } -object EnumType { +object EnumType extends EnumTypeCompanionMacros { def apply[T](implicit et: EnumType[T]): EnumType[T] = et def apply[T](cm: CaseMapper)(implicit et: EnumType[T]): EnumType[T] = et.map(cm) @@ -70,101 +66,13 @@ object EnumType { override def to(v: T): String = gMap(v) } - // //////////////////////////////////////////////// - - // Java `enum` implicit def javaEnumType[T <: Enum[T]](implicit ct: ClassTag[T]): EnumType[T] = { - val cls: Class[_] = ct.runtimeClass + val cls = ct.runtimeClass.asInstanceOf[Class[T]] val n = ReflectionUtils.name[T] val ns = ReflectionUtils.namespace[T] - val map: Map[String, T] = cls - .getMethod("values") - .invoke(null) - .asInstanceOf[Array[T]] - .iterator + val map: Map[String, T] = cls.getEnumConstants.iterator .map(v => v.name() -> v) .toMap EnumType.create(n, ns, map.keys.toList, cls.getAnnotations.toList, map(_)) } - - // //////////////////////////////////////////////// - - // Scala `Enumeration` - implicit def scalaEnumType[T <: Enumeration#Value: AnnotationType]: EnumType[T] = - macro scalaEnumTypeImpl[T] - - def scalaEnumTypeImpl[T: c.WeakTypeTag]( - c: whitebox.Context - )(annotations: c.Expr[AnnotationType[T]]): c.Tree = { - import c.universe._ - val wtt = weakTypeTag[T] - val ref = wtt.tpe.asInstanceOf[TypeRef] - val fn = ref.pre.typeSymbol.asClass.fullName - val idx = fn.lastIndexOf('.') - val n = fn.drop(idx + 1) // `object extends Enumeration` - val ns = fn.take(idx) - val list = q"${ref.pre.termSymbol}.values.toList.sortBy(_.id).map(_.toString)" - val map = q"${ref.pre.termSymbol}.values.map(x => x.toString -> x).toMap" - - q""" - _root_.magnolify.shared.EnumType.create[$wtt]( - $n, $ns, $list, $annotations.annotations, $map.apply(_)) - """ - } - - // //////////////////////////////////////////////// - - // Scala ADT - def adtEnumType[T]: EnumType[T] = macro Magnolia.gen[T] - - implicit def gen[T]: EnumType[T] = macro Magnolia.gen[T] - - type Typeclass[T] = EnumType[T] - - // EnumType can only be split into objects with fixed name - // Avoid invalid ADT derivation involving products by requiring - // implicit EnumValue type-class in magnolia join - @implicitNotFound("Cannot derive EnumType.EnumValue. EnumType only works for sum types") - trait EnumValue[T] - implicit def genEnumValue[T]: EnumValue[T] = macro genEnumValueMacro[T] - def genEnumValueMacro[T: c.WeakTypeTag](c: whitebox.Context): c.Tree = { - import c.universe._ - val tpe = weakTypeOf[T] - val symbol = tpe.typeSymbol - if (symbol.isModuleClass) { - q"new _root_.magnolify.shared.EnumType.EnumValue[$tpe]{}" - } else { - c.abort(c.enclosingPosition, "EnumType value must be an object") - } - } - - @nowarn - def join[T: EnumValue](caseClass: CaseClass[Typeclass, T]): Typeclass[T] = { - val n = caseClass.typeName.short - val ns = caseClass.typeName.owner - EnumType.create( - n, - ns, - List(n), - caseClass.annotations.toList, - _ => caseClass.rawConstruct(Nil) - ) - } - - def split[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = { - val n = sealedTrait.typeName.short - val ns = sealedTrait.typeName.owner - val subs = sealedTrait.subtypes.map(_.typeclass) - val values = subs.flatMap(_.values).toList - val annotations = (sealedTrait.annotations ++ subs.flatMap(_.annotations)).toList - EnumType.create( - n, - ns, - values, - annotations, - // it is ok to use the inefficient find here because it will be called only once - // and cached inside an instance of EnumType - v => subs.find(_.name == v).get.from(v) - ) - } } diff --git a/test/src/test/scala/magnolify/shared/EnumTypeSuite.scala b/test/src/test/scala/magnolify/shared/EnumTypeSuite.scala index 57868cfb5..6f7c69678 100644 --- a/test/src/test/scala/magnolify/shared/EnumTypeSuite.scala +++ b/test/src/test/scala/magnolify/shared/EnumTypeSuite.scala @@ -16,8 +16,10 @@ package magnolify.shared -import magnolify.test._ -import magnolify.test.Simple._ +import magnolify.test.* +import magnolify.test.Simple.* + +import scala.util.{Properties, Try} class EnumTypeSuite extends MagnolifySuite { test("JavaEnums") { @@ -67,21 +69,78 @@ class EnumTypeSuite extends MagnolifySuite { test("ADT should not generate for invalid types") { // explicit - assertNoDiff( - compileErrors("EnumType.gen[Option[ADT.Color]]"), - """|error: Cannot derive EnumType.EnumValue. EnumType only works for sum types - |EnumType.gen[Option[ADT.Color]] - | ^ - |""".stripMargin - ) + { + val error = compileErrors("EnumType.gen[Option[ADT.Color]]") + val scala2Error = + """|error: Cannot derive EnumType.EnumValue. EnumType only works for sum types + |EnumType.gen[Option[ADT.Color]] + | ^ + |""".stripMargin + val scala3Error = + """|error: Cannot prove that Some[magnolify.test.ADT.Color] <:< Singleton. + | + | ^ + |""".stripMargin + if (Properties.versionNumberString.startsWith("2.12")) { + assertNoDiff(error, scala2Error) + } else { + // scala 3 uses 2.13 + Try(assertNoDiff(error, scala2Error)) + .orElse(Try(assertNoDiff(error, scala3Error))) + .get + } + } + // implicit - assertNoDiff( - compileErrors("EnumType[Option[ADT.Color]]"), - """|error: could not find implicit value for parameter et: magnolify.shared.EnumType[Option[magnolify.test.ADT.Color]] - |EnumType[Option[ADT.Color]] - | ^ - |""".stripMargin - ) + { + val error = compileErrors("EnumType[Option[ADT.Color]]") + val scala2Error = + """|error: could not find implicit value for parameter et: magnolify.shared.EnumType[Option[magnolify.test.ADT.Color]] + |EnumType[Option[ADT.Color]] + | ^ + |""".stripMargin + + val scala3Error = + """|error: + |No given instance of type magnolify.shared.EnumType[Option[magnolify.test.ADT.Color]] was found for parameter et of method apply in object EnumType. + |I found: + | + | magnolify.shared.EnumType.gen[Option[magnolify.test.ADT.Color]]( + | { + | final class $anon() extends Object(), Serializable { + | type MirroredMonoType = Option[magnolify.test.ADT.Color] + | } + | (new $anon():Object & Serializable) + | }.$asInstanceOf[ + | + | scala.deriving.Mirror.Sum{ + | type MirroredMonoType² = Option[magnolify.test.ADT.Color]; + | type MirroredType = Option[magnolify.test.ADT.Color]; + | type MirroredLabel = ("Option" : String); + | type MirroredElemTypes = (None.type, Some[magnolify.test.ADT.Color]); + | type MirroredElemLabels = (("None$" : String), ("Some" : String)) + | } + | + | ] + | ) + | + |But given instance gen in trait EnumTypeCompanionMacros1 does not match type magnolify.shared.EnumType[Option[magnolify.test.ADT.Color]] + | + |where: MirroredMonoType is a type in an anonymous class locally defined in class EnumTypeSuite which is an alias of Option[magnolify.test.ADT.Color] + | MirroredMonoType² is a type in trait Mirror with bounds""".stripMargin + " \n" + """|. + |EnumType[Option[ADT.Color]] + | ^ + |""".stripMargin + + if (Properties.versionNumberString.startsWith("2.12")) { + assertNoDiff(error, scala2Error) + } else { + // scala 3 uses 2.13 + Try(assertNoDiff(error, scala2Error)) + .orElse(Try(assertNoDiff(error, scala3Error))) + .get + } + } } test("JavaEnums CaseMapper") { diff --git a/test/src/test/scala/magnolify/shared/TestEnumType.scala b/test/src/test/scala/magnolify/shared/TestEnumType.scala index be86edf15..5ad7754cb 100644 --- a/test/src/test/scala/magnolify/shared/TestEnumType.scala +++ b/test/src/test/scala/magnolify/shared/TestEnumType.scala @@ -26,6 +26,6 @@ object TestEnumType { implicit val etScala: EnumType[ScalaEnums.Color.Type] = EnumType.scalaEnumType[ScalaEnums.Color.Type] implicit val etAdt: EnumType[ADT.Color] = - EnumType.adtEnumType[ADT.Color] + EnumType.gen[ADT.Color] } diff --git a/test/src/test/scala/magnolify/shims/ShimsSuite.scala b/test/src/test/scala/magnolify/shims/ShimsSuite.scala index bf4a79319..9c4986f4e 100644 --- a/test/src/test/scala/magnolify/shims/ShimsSuite.scala +++ b/test/src/test/scala/magnolify/shims/ShimsSuite.scala @@ -30,7 +30,11 @@ class ShimsSuite extends MagnolifySuite { fc: FactoryCompat[Int, C[Int]] ): Unit = { property(className[C[Int]]) { - Prop.forAll((xs: List[Int]) => fc.fromSpecific(xs).toList == xs) + Prop.forAll { (xs: List[Int]) => + val b = fc.newBuilder + b ++= xs + ti(b.result()) == xs + } } }