diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04cc2ba..7ae732c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: Continuous integration on: pull_request: - branches: [ master ] + branches: [ master, series/0.2.x ] jobs: ci: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..6ae89072 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of Conduct + +We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. + +Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. If you are being harassed, please contact us immediately so that we can support you. + +[Scala Code of Conduct]: https://www.scala-lang.org/conduct/ \ No newline at end of file diff --git a/README.md b/README.md index bad16401..f9eedc82 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ -# Ducktape +# ducktape [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.github.arainko/ducktape_3/badge.svg?style=flat-square)](https://maven-badges.herokuapp.com/maven-central/io.github.arainko/ducktape_3) -*Ducktape* is a library for boilerplate-less and configurable transformations between case classes/enums (sealed traits) for Scala 3. Directly inspired by [chimney](https://github.com/scalalandio/chimney). +*ducktape* is a library for boilerplate-less and configurable transformations between case classes and enums/sealed traits for Scala 3. Directly inspired by [chimney](https://github.com/scalalandio/chimney). If this project interests you, please drop a 🌟 - these things are worthless but give me a dopamine rush nonetheless. ### Installation ```scala -libraryDependencies += "io.github.arainko" %% "ducktape" % "0.1.1" +libraryDependencies += "io.github.arainko" %% "ducktape" % "0.1.3" ``` +NOTE: the [version scheme](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html) is set to `early-semver` ### Examples @@ -77,12 +78,13 @@ we want to transform into, otherwise a compiletime errors is issued. #### 3. *Case class to case class with config* As we established earlier, going from `Person` to `PersonButMoreFields` cannot happen automatically as the former -doesn't have the `socialSecurityNo` field, but it has all the other fields so it's almost there, we just have to nudge it a lil' bit. +doesn't have the `socialSecurityNo` field, but it has all the other fields - so it's almost there, we just have to nudge it a lil' bit. We can do so with field configurations in 3 ways: 1. Set a constant to a specific field with `Field.const` 2. Compute the value for a specific field by applying a function with `Field.computed` 3. Use a different field in its place - 'rename' it with `Field.renamed` + 4. Grab all matching fields from another case class with `Field.allMatching` ```scala import io.github.arainko.ducktape.* @@ -128,6 +130,20 @@ val withRename = // age = 20, // socialSecurityNo = "Jerry" // ) + +final case class FieldSource(lastName: String, socialSecurityNo: String) + +// 4. Grab and use all matching fields from a different case class (a compiletime error will be issued if none of the fields match) +val withAllMatchingFields = + person + .into[PersonButMoreFields] + .transform(Field.allMatching(FieldSource("SourcedLastName", "SOURCED-SSN"))) +// withAllMatchingFields: PersonButMoreFields = PersonButMoreFields( +// firstName = "Jerry", +// lastName = "SourcedLastName", +// age = 20, +// socialSecurityNo = "SOURCED-SSN" +// ) ``` In case we repeatedly apply configurations to the same field, the latest one is chosen: @@ -139,11 +155,12 @@ val withRepeatedConfig = .transform( Field.renamed(_.socialSecurityNo, _.firstName), Field.computed(_.socialSecurityNo, p => s"${p.firstName}-COMPUTED-SSN"), + Field.allMatching(FieldSource("SourcedLastName", "SOURCED-SSN")), Field.const(_.socialSecurityNo, "CONSTANT-SSN") ) // withRepeatedConfig: PersonButMoreFields = PersonButMoreFields( // firstName = "Jerry", -// lastName = "Smith", +// lastName = "SourcedLastName", // age = 20, // socialSecurityNo = "CONSTANT-SSN" // ) @@ -225,7 +242,7 @@ val person2: Person2 = person1.via(methodToExpand) ``` In this case, `ducktape` will match the fields from `Person` to parameter names of `methodToExpand` failing at compiletime if -a parameter cannot be matched (be it there's no name correspondence or a `Transformer` between types of two fields named the same isn't available): +a parameter cannot be matched (be it there's no name correspondence or a `Transformer` between types of two fields with the same name isn't available): ```scala def methodToExpandButOneMoreArg(lastName: String, age: Int, firstName: String, additionalArg: String): Person2 = @@ -279,7 +296,7 @@ val withRenamed = #### 7. Automatic wrapping and unwrapping of `AnyVal` Despite being a really flawed abstraction `AnyVal` is pretty prevalent in Scala 2 code that you may want to interop with -and `ducktape` is here to assist you. `Transformer` definitions for wrapping and uwrapping `AnyVals` +and `ducktape` is here to assist you. `Transformer` definitions for wrapping and uwrapping `AnyVals` are automatically available: ```scala @@ -322,13 +339,13 @@ val definedViaTransformer = Transformer .defineVia[TestClass](method) .build(Arg.const(_.additionalArg, List("const"))) -// definedViaTransformer: Transformer[TestClass, TestClassWithAdditionalList] = repl.MdocSession$MdocApp6$$Lambda$91804/0x00000001037e0c40@3fcb542c +// definedViaTransformer: Transformer[TestClass, TestClassWithAdditionalList] = repl.MdocSession$MdocApp6$$Lambda$18327/0x000000080304f608@5b3594f4 val definedTransformer = Transformer .define[TestClass, TestClassWithAdditionalList] .build(Field.const(_.additionalArg, List("const"))) -// definedTransformer: Transformer[TestClass, TestClassWithAdditionalList] = repl.MdocSession$MdocApp6$$Lambda$91805/0x00000001037e6440@538893e6 +// definedTransformer: Transformer[TestClass, TestClassWithAdditionalList] = repl.MdocSession$MdocApp6$$Lambda$18328/0x000000080304fa50@5655156 val transformedVia = definedViaTransformer.transform(testClass) // transformedVia: TestClassWithAdditionalList = TestClassWithAdditionalList( @@ -417,21 +434,21 @@ expands to: val AppliedBuilder_this: AppliedBuilder[Person, Person2] = into[Person](person)[Person2] { - val sourceValue$proxy9: Person = AppliedBuilder_this.inline$appliedTo + val source$proxy13: Person = AppliedBuilder_this.inline$appliedTo { val inside$2: Inside2 = new Inside2( - int = sourceValue$proxy9.inside.int, - str = sourceValue$proxy9.inside.str, + int = source$proxy13.inside.int, + str = source$proxy13.inside.str, inside = Some.apply[EvenMoreInside2]( - new EvenMoreInside2(str = sourceValue$proxy9.inside.inside.str, int = sourceValue$proxy9.inside.inside.int) + new EvenMoreInside2(str = source$proxy13.inside.inside.str, int = source$proxy13.inside.inside.int) ) ) - val collectionOfNumbers$2: List[Wrapped[Float]] = sourceValue$proxy9.collectionOfNumbers + val collectionOfNumbers$2: List[Wrapped[Float]] = source$proxy13.collectionOfNumbers .map[Wrapped[Float]]((src: Float) => new Wrapped[Float](src)) .to[List[Wrapped[Float]] & Iterable[Wrapped[Float]]](iterableFactory[Wrapped[Float]]) val str$2: Some[Wrapped[String]] = Some.apply[Wrapped[String]](Wrapped.apply[String]("ConstString!")) - val int$2: Wrapped[Int] = Wrapped.apply[Int](sourceValue$proxy9.int.+(100)) + val int$2: Wrapped[Int] = Wrapped.apply[Int](source$proxy13.int.+(100)) new Person2(int = int$2, str = str$2, inside = inside$2, collectionOfNumbers = collectionOfNumbers$2) }: Person2 }: Person2 @@ -504,9 +521,9 @@ expands to: }]] ({ - val source$proxy5: Person = AppliedViaBuilder_this.inline$source + val source$proxy15: Person = AppliedViaBuilder_this.inline$source - (AppliedViaBuilder_this.inline$function.apply(Wrapped.apply[Int](source$proxy5.int.+(100)), Some.apply[Wrapped[String]](Wrapped.apply[String]("ConstStr!")), new Inside2(int = source$proxy5.inside.int, str = source$proxy5.inside.str, inside = Some.apply[EvenMoreInside2](new EvenMoreInside2(str = source$proxy5.inside.inside.str, int = source$proxy5.inside.inside.int))), source$proxy5.collectionOfNumbers.map[Wrapped[Float]](((src: Float) => new Wrapped[Float](src))).to[List[Wrapped[Float]] & Iterable[Wrapped[Float]]](iterableFactory[Wrapped[Float]])): Person2) + (AppliedViaBuilder_this.inline$function.apply(Wrapped.apply[Int](source$proxy15.int.+(100)), Some.apply[Wrapped[String]](Wrapped.apply[String]("ConstStr!")), new Inside2(int = source$proxy15.inside.int, str = source$proxy15.inside.str, inside = Some.apply[EvenMoreInside2](new EvenMoreInside2(str = source$proxy15.inside.inside.str, int = source$proxy15.inside.inside.int))), source$proxy15.collectionOfNumbers.map[Wrapped[Float]](((src: Float) => new Wrapped[Float](src))).to[List[Wrapped[Float]] & Iterable[Wrapped[Float]]](iterableFactory[Wrapped[Float]])): Person2) }: Person2) } ``` diff --git a/build.sbt b/build.sbt index 87b2c191..c9ee152f 100644 --- a/build.sbt +++ b/build.sbt @@ -26,6 +26,13 @@ name := "ducktape" sonatypeRepository := "https://s01.oss.sonatype.org/service/local" sonatypeCredentialHost := "s01.oss.sonatype.org" +lazy val previousArtifacts = + Set( + "io.github.arainko" %% "ducktape" % "0.1.0", + "io.github.arainko" %% "ducktape" % "0.1.1", + "io.github.arainko" %% "ducktape" % "0.1.2" + ) + lazy val root = project .in(file(".")) @@ -38,7 +45,7 @@ lazy val ducktape = .settings( scalacOptions ++= List("-Xcheck-macros", "-no-indent", "-old-syntax", "-Xfatal-warnings", "-deprecation"), libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M7" % Test, - mimaPreviousArtifacts := Set("io.github.arainko" %% "ducktape" % "0.1.0", "io.github.arainko" %% "ducktape" % "0.1.1") + mimaPreviousArtifacts := previousArtifacts ) lazy val docs = diff --git a/docs/readme.md b/docs/readme.md index 8e2e96a7..d69a3179 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,8 +1,8 @@ -# Ducktape +# ducktape [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.github.arainko/ducktape_3/badge.svg?style=flat-square)](https://maven-badges.herokuapp.com/maven-central/io.github.arainko/ducktape_3) -*Ducktape* is a library for boilerplate-less and configurable transformations between case classes/enums (sealed traits) for Scala 3. Directly inspired by [chimney](https://github.com/scalalandio/chimney). +*ducktape* is a library for boilerplate-less and configurable transformations between case classes and enums/sealed traits for Scala 3. Directly inspired by [chimney](https://github.com/scalalandio/chimney). If this project interests you, please drop a 🌟 - these things are worthless but give me a dopamine rush nonetheless. @@ -10,6 +10,7 @@ If this project interests you, please drop a 🌟 - these things are worthless b ```scala libraryDependencies += "io.github.arainko" %% "ducktape" % "@VERSION@" ``` +NOTE: the [version scheme](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html) is set to `early-semver` ### Examples @@ -67,12 +68,13 @@ we want to transform into, otherwise a compiletime errors is issued. #### 3. *Case class to case class with config* As we established earlier, going from `Person` to `PersonButMoreFields` cannot happen automatically as the former -doesn't have the `socialSecurityNo` field, but it has all the other fields so it's almost there, we just have to nudge it a lil' bit. +doesn't have the `socialSecurityNo` field, but it has all the other fields - so it's almost there, we just have to nudge it a lil' bit. We can do so with field configurations in 3 ways: 1. Set a constant to a specific field with `Field.const` 2. Compute the value for a specific field by applying a function with `Field.computed` 3. Use a different field in its place - 'rename' it with `Field.renamed` + 4. Grab all matching fields from another case class with `Field.allMatching` ```scala mdoc:reset import io.github.arainko.ducktape.* @@ -99,6 +101,14 @@ val withRename = person .into[PersonButMoreFields] .transform(Field.renamed(_.socialSecurityNo, _.firstName)) + +final case class FieldSource(lastName: String, socialSecurityNo: String) + +// 4. Grab and use all matching fields from a different case class (a compiletime error will be issued if none of the fields match) +val withAllMatchingFields = + person + .into[PersonButMoreFields] + .transform(Field.allMatching(FieldSource("SourcedLastName", "SOURCED-SSN"))) ``` In case we repeatedly apply configurations to the same field, the latest one is chosen: @@ -111,6 +121,7 @@ val withRepeatedConfig = .transform( Field.renamed(_.socialSecurityNo, _.firstName), Field.computed(_.socialSecurityNo, p => s"${p.firstName}-COMPUTED-SSN"), + Field.allMatching(FieldSource("SourcedLastName", "SOURCED-SSN")), Field.const(_.socialSecurityNo, "CONSTANT-SSN") ) @@ -185,7 +196,7 @@ val person2: Person2 = person1.via(methodToExpand) ``` In this case, `ducktape` will match the fields from `Person` to parameter names of `methodToExpand` failing at compiletime if -a parameter cannot be matched (be it there's no name correspondence or a `Transformer` between types of two fields named the same isn't available): +a parameter cannot be matched (be it there's no name correspondence or a `Transformer` between types of two fields with the same name isn't available): ```scala mdoc:fail:silent def methodToExpandButOneMoreArg(lastName: String, age: Int, firstName: String, additionalArg: String): Person2 = @@ -224,7 +235,7 @@ val withRenamed = #### 7. Automatic wrapping and unwrapping of `AnyVal` Despite being a really flawed abstraction `AnyVal` is pretty prevalent in Scala 2 code that you may want to interop with -and `ducktape` is here to assist you. `Transformer` definitions for wrapping and uwrapping `AnyVals` +and `ducktape` is here to assist you. `Transformer` definitions for wrapping and uwrapping `AnyVals` are automatically available: ```scala mdoc:reset-object diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/BuilderConfig.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/BuilderConfig.scala index 1b21c3d8..bc657172 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/BuilderConfig.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/BuilderConfig.scala @@ -39,9 +39,20 @@ object Field { @implicitNotFound("Field.renamed is supported for product types only, but ${Dest} is not a product type.") ev3: Mirror.ProductOf[Dest] ): BuilderConfig[Source, Dest] = throw NotQuotedException("Field.renamed") + + @compileTimeOnly("'Field.allMatching' needs to be erased from the AST with a macro.") + def allMatching[Source, Dest, FieldSource]( + fieldSource: FieldSource + )(using + @implicitNotFound("Field.allMatching is supported for product types only, but ${Source} is not a product type.") + ev1: Mirror.ProductOf[Source], + @implicitNotFound("Field.allMatching is supported for product types only, but ${Dest} is not a product type.") + ev2: Mirror.ProductOf[Dest], + @implicitNotFound("Field.allMatching is supported for product types only, but ${FieldSource} is not a product type.") + ev3: Mirror.ProductOf[FieldSource] + ): BuilderConfig[Source, Dest] = throw NotQuotedException("Field.allMatching") } -//TODO: Slap a @compileTimeOnly on all things here object Case { @compileTimeOnly("'Case.const' needs to be erased from the AST with a macro.") diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala index 45fd2ad4..d2d764d4 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala @@ -90,10 +90,10 @@ object Transformer { given [Source, Dest >: Source]: Identity[Source, Dest] = Identity[Source, Dest] inline given forProducts[Source, Dest](using Mirror.ProductOf[Source], Mirror.ProductOf[Dest]): ForProduct[Source, Dest] = - ForProduct.make(DerivationMacros.deriveProductTransformer[Source, Dest]) + ForProduct.make(DerivedTransformers.product[Source, Dest]) inline given forCoproducts[Source, Dest](using Mirror.SumOf[Source], Mirror.SumOf[Dest]): ForCoproduct[Source, Dest] = - ForCoproduct.make(DerivationMacros.deriveCoproductTransformer[Source, Dest]) + ForCoproduct.make(DerivedTransformers.coproduct[Source, Dest]) given [Source, Dest](using Transformer[Source, Dest]): Transformer[Source, Option[Dest]] = from => Transformer[Source, Dest].transform.andThen(Some.apply)(from) @@ -112,8 +112,8 @@ object Transformer { ): Transformer[SourceCollection[Source], DestCollection[Dest]] = from => from.map(trans.transform).to(factory) inline given fromAnyVal[Source <: AnyVal, Dest]: FromAnyVal[Source, Dest] = - FromAnyVal.make(DerivationMacros.deriveFromAnyValTransformer[Source, Dest]) + FromAnyVal.make(DerivedTransformers.fromAnyVal[Source, Dest]) inline given toAnyVal[Source, Dest <: AnyVal]: ToAnyVal[Source, Dest] = - ToAnyVal.make(DerivationMacros.deriveToAnyValTransformer[Source, Dest]) + ToAnyVal.make(DerivedTransformers.toAnyVal[Source, Dest]) } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedBuilder.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedBuilder.scala index 38b78d18..382adda1 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedBuilder.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedBuilder.scala @@ -6,6 +6,6 @@ import io.github.arainko.ducktape.internal.macros.* final class AppliedBuilder[Source, Dest](appliedTo: Source) { inline def transform(inline config: BuilderConfig[Source, Dest]*): Dest = - TransformerMacros.transformConfigured(appliedTo, config) + Transformations.transformConfigured(appliedTo, config*) } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedViaBuilder.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedViaBuilder.scala index 1c0ec408..4b948a0d 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedViaBuilder.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/AppliedViaBuilder.scala @@ -15,7 +15,7 @@ final class AppliedViaBuilder[Source, Dest, Func, ArgSelector <: FunctionArgumen inline def transform( inline config: ArgBuilderConfig[Source, Dest, ArgSelector]* )(using Source: Mirror.ProductOf[Source]): Dest = - ProductTransformerMacros.viaConfigured[Source, Dest, Func, ArgSelector](source, function, config*) + Transformations.viaConfigured[Source, Dest, Func, ArgSelector](source, function, config*) } @@ -27,6 +27,6 @@ object AppliedViaBuilder { transparent inline def create[Source, Func](source: Source, inline func: Func)(using Func: FunctionMirror[Func]): Any = { val builder = instance[Source, Func.Return, Func, Nothing](source, func) - FunctionMacros.namedArguments(func, builder) + Functions.refineFunctionArguments(func, builder) } } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionBuilder.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionBuilder.scala index bad32e28..d84ac1f8 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionBuilder.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionBuilder.scala @@ -8,5 +8,5 @@ import scala.deriving.Mirror final class DefinitionBuilder[Source, Dest] { inline def build(inline config: BuilderConfig[Source, Dest]*): Transformer[Source, Dest] = - from => TransformerMacros.transformConfigured(from, config) + from => Transformations.transformConfigured(from, config*) } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionViaBuilder.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionViaBuilder.scala index 9932def4..903cbd8e 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionViaBuilder.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/builder/DefinitionViaBuilder.scala @@ -11,7 +11,7 @@ final class DefinitionViaBuilder[Source, Dest, Func, ArgSelector <: FunctionArgu inline def build( inline config: ArgBuilderConfig[Source, Dest, ArgSelector]* )(using Mirror.ProductOf[Source]): Transformer[Source, Dest] = - from => ProductTransformerMacros.viaConfigured[Source, Dest, Func, ArgSelector](from, function, config*) + from => Transformations.viaConfigured[Source, Dest, Func, ArgSelector](from, function, config*) } @@ -27,7 +27,7 @@ object DefinitionViaBuilder { extension [Source](partial: PartiallyApplied[Source]) { transparent inline def apply[Func](inline func: Func)(using Func: FunctionMirror[Func]): Any = { val builder = instance[Source, Func.Return, Func, Nothing](func) - FunctionMacros.namedArguments(func, builder) + Functions.refineFunctionArguments(func, builder) } } } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/function/FunctionMirror.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/function/FunctionMirror.scala index 48bb685f..157d72d4 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/function/FunctionMirror.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/function/FunctionMirror.scala @@ -16,5 +16,5 @@ object FunctionMirror extends FunctionMirror[Any => Any] { override type Return = Any - transparent inline given [F]: FunctionMirror[F] = FunctionMacros.createMirror[F] + transparent inline given [F]: FunctionMirror[F] = Functions.deriveMirror[F] } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformerMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala similarity index 60% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformerMacros.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala index a399c3e9..98e012e1 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformerMacros.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala @@ -1,28 +1,24 @@ package io.github.arainko.ducktape.internal.macros import io.github.arainko.ducktape.* +import io.github.arainko.ducktape.internal.modules.MaterializedConfiguration.* import io.github.arainko.ducktape.internal.modules.* import scala.deriving.* import scala.quoted.* -private[ducktape] final class CoproductTransformerMacros(using val quotes: Quotes) - extends Module, - CaseModule, - FieldModule, - SelectorModule, - MirrorModule, - ConfigurationModule { - import quotes.reflect.* - import MaterializedConfiguration.* +// Ideally should live in `modules` but due to problems with ProductTransformations and LiftTransformation +// is kept here for consistency +private[ducktape] object CoproductTransformations { def transform[Source: Type, Dest: Type]( sourceValue: Expr[Source], Source: Expr[Mirror.SumOf[Source]], Dest: Expr[Mirror.SumOf[Dest]] - ): Expr[Dest] = { + )(using Quotes): Expr[Dest] = { given Cases.Source = Cases.Source.fromMirror(Source) given Cases.Dest = Cases.Dest.fromMirror(Dest) + val ifBranches = singletonIfBranches[Source, Dest](sourceValue, Cases.source.value) ifStatement(ifBranches).asExprOf[Dest] } @@ -32,7 +28,9 @@ private[ducktape] final class CoproductTransformerMacros(using val quotes: Quote config: Expr[Seq[BuilderConfig[Source, Dest]]], Source: Expr[Mirror.SumOf[Source]], Dest: Expr[Mirror.SumOf[Dest]] - ): Expr[Dest] = { + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + given Cases.Source = Cases.Source.fromMirror(Source) given Cases.Dest = Cases.Dest.fromMirror(Dest) val materializedConfig = @@ -58,17 +56,17 @@ private[ducktape] final class CoproductTransformerMacros(using val quotes: Quote case ConfiguredCase(config, source) => config match { case Coproduct.Computed(tpe, function) => - val cond = source.tpe.asType match { + val cond = source.tpe match { case '[tpe] => '{ $sourceValue.isInstanceOf[tpe] } } - val castedSource = tpe.asType match { + val castedSource = tpe match { case '[tpe] => '{ $sourceValue.asInstanceOf[tpe] } } val value = '{ $function($castedSource) } cond.asTerm -> value.asTerm case Coproduct.Const(tpe, value) => - val cond = source.tpe.asType match { + val cond = source.tpe match { case '[tpe] => '{ $sourceValue.isInstanceOf[tpe] } } cond.asTerm -> value.asTerm @@ -81,20 +79,27 @@ private[ducktape] final class CoproductTransformerMacros(using val quotes: Quote private def singletonIfBranches[Source: Type, Dest: Type]( sourceValue: Expr[Source], sourceCases: List[Case] - )(using Cases.Dest) = { + )(using Quotes, Cases.Dest) = { + import quotes.reflect.* + sourceCases.map { source => source -> Cases.dest .get(source.name) - .getOrElse(abort(Failure.NoChildMapping(source.name, TypeRepr.of[Dest]))) + .getOrElse(Failure.abort(Failure.NoChildMapping(source.name, summon[Type[Dest]]))) }.map { (source, dest) => - val cond = source.tpe.asType match { + val cond = source.tpe match { case '[tpe] => '{ $sourceValue.isInstanceOf[tpe] } } - cond.asTerm -> dest.materializeSingleton.getOrElse(abort(Failure.CannotMaterializeSingleton(dest.tpe))) + + cond.asTerm -> + dest.materializeSingleton + .getOrElse(Failure.abort(Failure.CannotMaterializeSingleton(dest.tpe))) } } - private def ifStatement(branches: List[(Term, Term)]): Term = { + private def ifStatement(using Quotes)(branches: List[(quotes.reflect.Term, quotes.reflect.Term)]): quotes.reflect.Term = { + import quotes.reflect.* + branches match { case (p1, a1) :: xs => If(p1, a1, ifStatement(xs)) @@ -105,29 +110,3 @@ private[ducktape] final class CoproductTransformerMacros(using val quotes: Quote private case class ConfiguredCase(config: Coproduct, subcase: Case) } - -private[ducktape] object CoproductTransformerMacros { - inline def transform[Source, Dest](source: Source)(using - Source: Mirror.SumOf[Source], - Dest: Mirror.SumOf[Dest] - ): Dest = ${ transformMacro[Source, Dest]('source, 'Source, 'Dest) } - - def transformMacro[Source: Type, Dest: Type]( - source: Expr[Source], - Source: Expr[Mirror.SumOf[Source]], - Dest: Expr[Mirror.SumOf[Dest]] - )(using Quotes): Expr[Dest] = CoproductTransformerMacros().transform(source, Source, Dest) - - inline def transformConfigured[Source, Dest](source: Source, inline config: BuilderConfig[Source, Dest]*)(using - Source: Mirror.SumOf[Source], - Dest: Mirror.SumOf[Dest] - ): Dest = - ${ transformConfiguredMacro[Source, Dest]('source, 'config, 'Source, 'Dest) } - - def transformConfiguredMacro[Source: Type, Dest: Type]( - source: Expr[Source], - config: Expr[Seq[BuilderConfig[Source, Dest]]], - Source: Expr[Mirror.SumOf[Source]], - Dest: Expr[Mirror.SumOf[Dest]] - )(using Quotes): Expr[Dest] = CoproductTransformerMacros().transformConfigured(source, config, Source, Dest) -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DebugMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DebugMacros.scala index 90854611..76975b9d 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DebugMacros.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DebugMacros.scala @@ -15,9 +15,9 @@ private[ducktape] object DebugMacros { value } - inline def code[A](inline value: A): A = ${ codeCompiletimeMacro('value) } + inline def code[A](inline value: A): A = ${ codeMacro('value) } - def codeCompiletimeMacro[A: Type](value: Expr[A])(using Quotes): Expr[A] = { + def codeMacro[A: Type](value: Expr[A])(using Quotes): Expr[A] = { import quotes.reflect.* val struct = Printer.TreeShortCode.show(value.asTerm) report.info(struct) diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivationMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala similarity index 61% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivationMacros.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala index cc740e0e..54c91c41 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivationMacros.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala @@ -1,12 +1,14 @@ package io.github.arainko.ducktape.internal.macros import io.github.arainko.ducktape.* +import io.github.arainko.ducktape.internal.macros.{ CoproductTransformations, ProductTransformations } +import io.github.arainko.ducktape.internal.modules.* import scala.deriving.* import scala.quoted.* -private[ducktape] object DerivationMacros { - inline def deriveProductTransformer[Source, Dest](using +private[ducktape] object DerivedTransformers { + inline def product[Source, Dest](using Source: Mirror.ProductOf[Source], Dest: Mirror.ProductOf[Dest] ): Transformer[Source, Dest] = ${ deriveProductTransformerMacro('Source, 'Dest) } @@ -15,9 +17,9 @@ private[ducktape] object DerivationMacros { Source: Expr[Mirror.ProductOf[Source]], Dest: Expr[Mirror.ProductOf[Dest]] )(using Quotes): Expr[Transformer[Source, Dest]] = - '{ source => ${ ProductTransformerMacros.transformMacro[Source, Dest]('source, Source, Dest) } } + '{ source => ${ ProductTransformations.transform[Source, Dest]('source, Source, Dest) } } - inline def deriveCoproductTransformer[Source, Dest](using + inline def coproduct[Source, Dest](using Source: Mirror.SumOf[Source], Dest: Mirror.SumOf[Dest] ): Transformer[Source, Dest] = ${ deriveCoproductTransformerMacro[Source, Dest]('Source, 'Dest) } @@ -26,17 +28,17 @@ private[ducktape] object DerivationMacros { Source: Expr[Mirror.SumOf[Source]], Dest: Expr[Mirror.SumOf[Dest]] )(using Quotes): Expr[Transformer[Source, Dest]] = - '{ source => ${ CoproductTransformerMacros.transformMacro[Source, Dest]('source, Source, Dest) } } + '{ source => ${ CoproductTransformations.transform[Source, Dest]('source, Source, Dest) } } - inline def deriveToAnyValTransformer[Source, Dest <: AnyVal]: Transformer[Source, Dest] = + inline def toAnyVal[Source, Dest <: AnyVal]: Transformer[Source, Dest] = ${ deriveToAnyValTransformerMacro[Source, Dest] } def deriveToAnyValTransformerMacro[Source: Type, Dest <: AnyVal: Type](using Quotes): Expr[Transformer[Source, Dest]] = - '{ source => ${ ProductTransformerMacros.transformToAnyValMacro('source) } } + '{ source => ${ ProductTransformations.transformToAnyVal('source) } } - inline def deriveFromAnyValTransformer[Source <: AnyVal, Dest] = + inline def fromAnyVal[Source <: AnyVal, Dest] = ${ deriveFromAnyValTransformerMacro[Source, Dest] } def deriveFromAnyValTransformerMacro[Source <: AnyVal: Type, Dest: Type](using Quotes): Expr[Transformer[Source, Dest]] = - '{ source => ${ ProductTransformerMacros.transformFromAnyValMacro('source) } } + '{ source => ${ ProductTransformations.transformFromAnyVal('source) } } } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FunctionMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FunctionMacros.scala index d8f150da..95f9c169 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FunctionMacros.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FunctionMacros.scala @@ -5,15 +5,13 @@ import io.github.arainko.ducktape.internal.modules.* import scala.quoted.* -private[ducktape] final class FunctionMacros(using val quotes: Quotes) extends Module, SelectorModule, FieldModule, MirrorModule { - import FunctionMacros.* - import quotes.reflect.* +// Ideally should live in `modules` but due to problems with ProductTransformations and LiftTransformation +// is kept here for consistency +private[ducktape] object FunctionMacros { - private val cons = TypeRepr.of[*:] - private val emptyTuple = TypeRepr.of[EmptyTuple] - private val functionArguments = TypeRepr.of[FunctionArguments] + def deriveMirror[Func: Type](using Quotes): Expr[FunctionMirror[Func]] = { + import quotes.reflect.* - def createMirror[Func: Type]: Expr[FunctionMirror[Func]] = TypeRepr.of[Func] match { case tpe @ AppliedType(_, tpeArgs) if tpe.isFunctionType => val returnTpe = tpeArgs.last @@ -31,37 +29,29 @@ private[ducktape] final class FunctionMacros(using val quotes: Quotes) extends M case other => report.errorAndAbort(s"FunctionMirrors can only be created for functions. Got ${other.show} instead.") } + } + + def refineFunctionArguments[Func: Type, F[x <: FunctionArguments]: Type]( + function: Expr[Func], + initial: Expr[F[Nothing]] + )(using Quotes) = { + import quotes.reflect.* - def namedArguments[Func: Type, F[_ <: FunctionArguments]: Type](function: Expr[Func], initial: Expr[F[Nothing]]) = function.asTerm match { case func @ FunctionLambda(valDefs, _) => - refine(functionArguments, valDefs).asType match { + refine(TypeRepr.of[FunctionArguments], valDefs).asType match { case '[IsFuncArgs[args]] => '{ $initial.asInstanceOf[F[args]] } } case other => report.errorAndAbort(s"Failed to extract named arguments from ${other.show}") } + } - private def refine(tpe: TypeRepr, valDefs: List[ValDef]) = - valDefs.foldLeft(functionArguments)((tpe, valDef) => Refinement(tpe, valDef.name, valDef.tpt.tpe)) + private def refine(using Quotes)(tpe: quotes.reflect.TypeRepr, valDefs: List[quotes.reflect.ValDef]) = { + import quotes.reflect.* -} + valDefs.foldLeft(TypeRepr.of[FunctionArguments])((tpe, valDef) => Refinement(tpe, valDef.name, valDef.tpt.tpe)) + } -private[ducktape] object FunctionMacros { private type IsFuncArgs[A <: FunctionArguments] = A - - transparent inline def createMirror[F]: FunctionMirror[F] = ${ createMirrorMacro[F] } - - def createMirrorMacro[Func: Type](using Quotes): Expr[FunctionMirror[Func]] = - FunctionMacros().createMirror[Func] - - transparent inline def namedArguments[Func, F[_ <: FunctionArguments]]( - inline function: Func, - initial: F[Nothing] - )(using FunctionMirror[Func]) = ${ namedArgumentsMacro[Func, F]('function, 'initial) } - - def namedArgumentsMacro[Func: Type, F[_ <: FunctionArguments]: Type]( - function: Expr[Func], - initial: Expr[F[Nothing]] - )(using Quotes) = FunctionMacros().namedArguments[Func, F](function, initial) } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Functions.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Functions.scala new file mode 100644 index 00000000..c3e03e78 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Functions.scala @@ -0,0 +1,13 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.function.* +import io.github.arainko.ducktape.internal.macros.FunctionMacros + +private[ducktape] object Functions { + transparent inline def deriveMirror[Func]: FunctionMirror[Func] = ${ FunctionMacros.deriveMirror[Func] } + + transparent inline def refineFunctionArguments[Func, F[x <: FunctionArguments]]( + inline function: Func, + initial: F[Nothing] + )(using FunctionMirror[Func]): Any = ${ FunctionMacros.refineFunctionArguments[Func, F]('function, 'initial) } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/LiftTransformation.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/LiftTransformation.scala similarity index 95% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/LiftTransformation.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/LiftTransformation.scala index 85c5b03d..84b987c0 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/LiftTransformation.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/LiftTransformation.scala @@ -1,12 +1,13 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation +package io.github.arainko.ducktape.internal.macros import io.github.arainko.ducktape.Transformer -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.* +import io.github.arainko.ducktape.internal.modules.TransformerLambda.* +import io.github.arainko.ducktape.internal.modules.* import scala.collection.Factory import scala.quoted.* +//TODO: if this is moved to `modules` the compiler crashes, investigate further? private[ducktape] object LiftTransformation { def liftTransformation[A: Type, B: Type](transformer: Expr[Transformer[A, B]], appliedTo: Expr[A])(using Quotes): Expr[B] = diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformerMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformations.scala similarity index 57% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformerMacros.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformations.scala index 9fdb55bd..06e19382 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformerMacros.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ProductTransformations.scala @@ -2,36 +2,71 @@ package io.github.arainko.ducktape.internal.macros import io.github.arainko.ducktape.* import io.github.arainko.ducktape.function.* +import io.github.arainko.ducktape.internal.macros.LiftTransformation +import io.github.arainko.ducktape.internal.modules.MaterializedConfiguration.* import io.github.arainko.ducktape.internal.modules.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.LiftTransformation -import scala.collection.Factory import scala.deriving.* import scala.quoted.* -private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) - extends Module, - FieldModule, - CaseModule, - MirrorModule, - SelectorModule, - ConfigurationModule { - import quotes.reflect.* - import MaterializedConfiguration.* +//TODO: if this is moved to `modules` the compiler crashes, investigate further (?) +private[ducktape] object ProductTransformations { + + def transform[Source: Type, Dest: Type]( + sourceValue: Expr[Source], + Source: Expr[Mirror.ProductOf[Source]], + Dest: Expr[Mirror.ProductOf[Dest]] + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + + given Fields.Source = Fields.Source.fromMirror(Source) + given Fields.Dest = Fields.Dest.fromMirror(Dest) + + val transformerFields = fieldTransformations(sourceValue, Fields.dest.value) + + constructor(TypeRepr.of[Dest]) + .appliedToArgs(transformerFields.toList) + .asExprOf[Dest] + } + + def transformConfigured[Source: Type, Dest: Type]( + sourceValue: Expr[Source], + config: Expr[Seq[BuilderConfig[Source, Dest]]], + Source: Expr[Mirror.ProductOf[Source]], + Dest: Expr[Mirror.ProductOf[Dest]] + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + + given Fields.Source = Fields.Source.fromMirror(Source) + given Fields.Dest = Fields.Dest.fromMirror(Dest) + + val materializedConfig = MaterializedConfiguration.materializeProductConfig(config) + val nonConfiguredFields = Fields.dest.byName -- materializedConfig.map(_.destFieldName) + val transformedFields = fieldTransformations(sourceValue, nonConfiguredFields.values.toList) + val configuredFields = fieldConfigurations(materializedConfig, sourceValue) + + constructor(TypeRepr.of[Dest]) + .appliedToArgs(transformedFields ++ configuredFields) + .asExprOf[Dest] + } def via[Source: Type, Dest: Type, Func]( sourceValue: Expr[Source], function: Expr[Func], Func: Expr[FunctionMirror.Aux[Func, Dest]], Source: Expr[Mirror.ProductOf[Source]] - ): Expr[Dest] = function.asTerm match { - case func @ FunctionLambda(vals, _) => - given Fields.Source = Fields.Source.fromMirror(Source) - given Fields.Dest = Fields.Dest.fromValDefs(vals) - - val calls = fieldTransformations(sourceValue, Fields.dest.value).map(_.value) - Select.unique(func, "apply").appliedToArgs(calls).asExprOf[Dest] - case other => report.errorAndAbort(s"'via' is only supported on eta-expanded methods!") + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + + function.asTerm match { + case func @ FunctionLambda(vals, _) => + given Fields.Source = Fields.Source.fromMirror(Source) + given Fields.Dest = Fields.Dest.fromValDefs(vals) + + val calls = fieldTransformations(sourceValue, Fields.dest.value).map(_.value) + Select.unique(func, "apply").appliedToArgs(calls).asExprOf[Dest] + case other => report.errorAndAbort(s"'via' is only supported on eta-expanded methods!") + } } def viaConfigured[Source: Type, Dest: Type, Func: Type, ArgSelector <: FunctionArguments: Type]( @@ -39,7 +74,9 @@ private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) function: Expr[Func], config: Expr[Seq[ArgBuilderConfig[Source, Dest, ArgSelector]]], Source: Expr[Mirror.ProductOf[Source]] - ): Expr[Dest] = { + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + given Fields.Source = Fields.Source.fromMirror(Source) given Fields.Dest = Fields.Dest.fromFunctionArguments[ArgSelector] val materializedConfig = MaterializedConfiguration.materializeArgConfig(config) @@ -69,42 +106,11 @@ private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) .asExprOf[Dest] } - def transformConfigured[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - config: Expr[Seq[BuilderConfig[Source, Dest]]], - Source: Expr[Mirror.ProductOf[Source]], - Dest: Expr[Mirror.ProductOf[Dest]] - ): Expr[Dest] = { - given Fields.Source = Fields.Source.fromMirror(Source) - given Fields.Dest = Fields.Dest.fromMirror(Dest) - - val materializedConfig = MaterializedConfiguration.materializeProductConfig(config) - val nonConfiguredFields = Fields.dest.byName -- materializedConfig.map(_.destFieldName) - val transformedFields = fieldTransformations(sourceValue, nonConfiguredFields.values.toList) - val configuredFields = fieldConfigurations(materializedConfig, sourceValue) - - constructor(TypeRepr.of[Dest]) - .appliedToArgs(transformedFields ++ configuredFields) - .asExprOf[Dest] - } - - def transform[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - Source: Expr[Mirror.ProductOf[Source]], - Dest: Expr[Mirror.ProductOf[Dest]] - ): Expr[Dest] = { - given Fields.Source = Fields.Source.fromMirror(Source) - given Fields.Dest = Fields.Dest.fromMirror(Dest) - val transformerFields = fieldTransformations(sourceValue, Fields.dest.value) - - constructor(TypeRepr.of[Dest]) - .appliedToArgs(transformerFields.toList) - .asExprOf[Dest] - } - def transformFromAnyVal[Source <: AnyVal: Type, Dest: Type]( sourceValue: Expr[Source] - ): Expr[Dest] = { + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + val tpe = TypeRepr.of[Source] val fieldSymbol = tpe.typeSymbol.fieldMembers.headOption @@ -115,30 +121,38 @@ private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) def transformToAnyVal[Source: Type, Dest <: AnyVal: Type]( sourceValue: Expr[Source] - ): Expr[Dest] = + )(using Quotes): Expr[Dest] = { + import quotes.reflect.* + constructor(TypeRepr.of[Dest]) .appliedTo(sourceValue.asTerm) .asExprOf[Dest] + } private def fieldTransformations[Source: Type]( sourceValue: Expr[Source], fieldsToTransformInto: List[Field] - )(using Fields.Source) = + )(using Quotes, Fields.Source) = { + import quotes.reflect.* + fieldsToTransformInto.map { field => field -> Fields.source .get(field.name) - .getOrElse(abort(Failure.NoFieldMapping(field.name, TypeRepr.of[Source]))) + .getOrElse(Failure.abort(Failure.NoFieldMapping(field.name, Type.of[Source]))) }.map { (dest, source) => val call = resolveTransformation(sourceValue, source, dest) NamedArg(dest.name, call) } + } private def fieldConfigurations[Source: Type]( config: List[MaterializedConfiguration.Product], sourceValue: Expr[Source] - )(using Fields.Dest) = + )(using Quotes, Fields.Dest) = { + import quotes.reflect.* + config .map(cfg => Fields.dest.unsafeGet(cfg.destFieldName) -> cfg) .map { (field, cfg) => @@ -148,14 +162,21 @@ private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) case Product.Renamed(dest, source) => accessField(sourceValue, source).asExpr } - val castedCall = field.tpe.asType match { + val castedCall = field.tpe match { case '[fieldTpe] => call.asExprOf[fieldTpe] } NamedArg(field.name, castedCall.asTerm) } + } + + private def resolveTransformation[Source: Type]( + sourceValue: Expr[Source], + source: Field, + destination: Field + )(using Quotes) = { + import quotes.reflect.* - private def resolveTransformation[Source: Type](sourceValue: Expr[Source], source: Field, destination: Field)(using Quotes) = source.transformerTo(destination) match { // even though this is taken care of in LiftTransformation.liftTransformation // we need to do this here due to a compiler bug where multiple matches on a @@ -171,74 +192,25 @@ private[ducktape] final class ProductTransformerMacros(using val quotes: Quotes) val field = accessField(sourceValue, source.name).asExprOf[source] LiftTransformation.liftTransformation(transformer, field).asTerm } + } - private def accessField(value: Expr[Any], fieldName: String)(using Quotes) = Select.unique(value.asTerm, fieldName) + private def accessField(value: Expr[Any], fieldName: String)(using Quotes) = { + import quotes.reflect.* - private def constructor(tpe: TypeRepr)(using Quotes): Term = { - val (repr, constructor, tpeArgs) = tpe match { - case AppliedType(repr, reprArguments) => (repr, repr.typeSymbol.primaryConstructor, reprArguments) - case notApplied => (tpe, tpe.typeSymbol.primaryConstructor, Nil) - } + Select.unique(value.asTerm, fieldName) + } + + private def constructor(using Quotes)(tpe: quotes.reflect.TypeRepr): quotes.reflect.Term = { + import quotes.reflect.* + + val (repr, constructor, tpeArgs) = + tpe match { + case AppliedType(repr, reprArguments) => (repr, repr.typeSymbol.primaryConstructor, reprArguments) + case notApplied => (tpe, tpe.typeSymbol.primaryConstructor, Nil) + } New(Inferred(repr)) .select(constructor) .appliedToTypes(tpeArgs) } - -} - -private[ducktape] object ProductTransformerMacros { - def transformMacro[Source: Type, Dest: Type]( - source: Expr[Source], - Source: Expr[Mirror.ProductOf[Source]], - Dest: Expr[Mirror.ProductOf[Dest]] - )(using Quotes): Expr[Dest] = ProductTransformerMacros().transform(source, Source, Dest) - - inline def via[Source, Dest, Func](source: Source, inline function: Func)(using - Source: Mirror.ProductOf[Source], - Func: FunctionMirror.Aux[Func, Dest] - ): Dest = ${ viaMacro('source, 'function, 'Func, 'Source) } - - def viaMacro[Source: Type, Dest: Type, Func]( - source: Expr[Source], - function: Expr[Func], - Func: Expr[FunctionMirror.Aux[Func, Dest]], - Source: Expr[Mirror.ProductOf[Source]] - )(using Quotes) = - ProductTransformerMacros().via(source, function, Func, Source) - - inline def viaConfigured[Source, Dest, Func, ArgSelector <: FunctionArguments]( - source: Source, - inline function: Func, - inline config: ArgBuilderConfig[Source, Dest, ArgSelector]* - )(using Source: Mirror.ProductOf[Source]): Dest = - ${ viaConfiguredMacro[Source, Dest, Func, ArgSelector]('source, 'function, 'config, 'Source) } - - def viaConfiguredMacro[Source: Type, Dest: Type, Func: Type, ArgSelector <: FunctionArguments: Type]( - sourceValue: Expr[Source], - function: Expr[Func], - config: Expr[Seq[ArgBuilderConfig[Source, Dest, ArgSelector]]], - A: Expr[Mirror.ProductOf[Source]] - )(using Quotes) = - ProductTransformerMacros().viaConfigured[Source, Dest, Func, ArgSelector](sourceValue, function, config, A) - - inline def transformConfigured[Source, Dest](sourceValue: Source, inline config: BuilderConfig[Source, Dest]*)(using - Source: Mirror.ProductOf[Source], - Dest: Mirror.ProductOf[Dest] - ) = ${ transformConfiguredMacro('sourceValue, 'config, 'Source, 'Dest) } - - def transformConfiguredMacro[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - config: Expr[Seq[BuilderConfig[Source, Dest]]], - Source: Expr[Mirror.ProductOf[Source]], - Dest: Expr[Mirror.ProductOf[Dest]] - )(using Quotes) = - ProductTransformerMacros().transformConfigured(sourceValue, config, Source, Dest) - - def transformFromAnyValMacro[Source <: AnyVal: Type, Dest: Type](sourceValue: Expr[Source])(using Quotes): Expr[Dest] = - ProductTransformerMacros().transformFromAnyVal[Source, Dest](sourceValue) - - def transformToAnyValMacro[Source: Type, Dest <: AnyVal: Type](sourceValue: Expr[Source])(using Quotes): Expr[Dest] = - ProductTransformerMacros().transformToAnyVal[Source, Dest](sourceValue) - } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Transformations.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Transformations.scala new file mode 100644 index 00000000..313db943 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/Transformations.scala @@ -0,0 +1,48 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.* +import io.github.arainko.ducktape.function.* +import io.github.arainko.ducktape.internal.macros.{ CoproductTransformations, LiftTransformation } +import io.github.arainko.ducktape.internal.modules.* + +import scala.deriving.Mirror +import scala.quoted.* + +private[ducktape] object Transformations { + inline def via[Source, Dest, Func](source: Source, inline function: Func)(using + Source: Mirror.ProductOf[Source], + Func: FunctionMirror.Aux[Func, Dest] + ): Dest = ${ ProductTransformations.via('source, 'function, 'Func, 'Source) } + + inline def viaConfigured[Source, Dest, Func, ArgSelector <: FunctionArguments]( + source: Source, + inline function: Func, + inline config: ArgBuilderConfig[Source, Dest, ArgSelector]* + )(using Source: Mirror.ProductOf[Source]): Dest = + ${ ProductTransformations.viaConfigured[Source, Dest, Func, ArgSelector]('source, 'function, 'config, 'Source) } + + inline def liftFromTransformer[Source, Dest](source: Source)(using inline transformer: Transformer[Source, Dest]) = + ${ LiftTransformation.liftTransformation[Source, Dest]('transformer, 'source) } + + inline def transformConfigured[Source, Dest](source: Source, inline config: BuilderConfig[Source, Dest]*) = + ${ transformConfiguredMacro[Source, Dest]('source, 'config) } + + private def transformConfiguredMacro[Source: Type, Dest: Type]( + sourceValue: Expr[Source], + config: Expr[Seq[BuilderConfig[Source, Dest]]] + )(using Quotes): Expr[Dest] = + mirrorOf[Source] + .zip(mirrorOf[Dest]) + .collect { + case '{ $source: Mirror.ProductOf[Source] } -> '{ $dest: Mirror.ProductOf[Dest] } => + ProductTransformations.transformConfigured(sourceValue, config, source, dest) + case '{ $source: Mirror.SumOf[Source] } -> '{ $dest: Mirror.SumOf[Dest] } => + CoproductTransformations.transformConfigured(sourceValue, config, source, dest) + } + .getOrElse( + quotes.reflect.report + .errorAndAbort("Configured transformations are supported for Product -> Product and Coproduct -> Coproduct.") + ) + + private def mirrorOf[A: Type](using Quotes) = Expr.summon[Mirror.Of[A]] +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/TransformerMacros.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/TransformerMacros.scala deleted file mode 100644 index 9a04b87b..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/TransformerMacros.scala +++ /dev/null @@ -1,42 +0,0 @@ -package io.github.arainko.ducktape.internal.macros - -import io.github.arainko.ducktape.* -import io.github.arainko.ducktape.internal.modules.* - -import scala.deriving.* -import scala.quoted.* - -private[ducktape] final class TransformerMacros(using val quotes: Quotes) extends Module { - import quotes.reflect.* - - def transformConfigured[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - config: Expr[Seq[BuilderConfig[Source, Dest]]] - ) = - mirrorOf[Source] - .zip(mirrorOf[Dest]) - .collect { - case '{ $source: Mirror.ProductOf[Source] } -> '{ $dest: Mirror.ProductOf[Dest] } => - ProductTransformerMacros.transformConfiguredMacro(sourceValue, config, source, dest) - case '{ $source: Mirror.SumOf[Source] } -> '{ $dest: Mirror.SumOf[Dest] } => - CoproductTransformerMacros.transformConfiguredMacro(sourceValue, config, source, dest) - } - .getOrElse( - report.errorAndAbort("Configured transformations are supported for Product -> Product and Coproduct -> Coproduct.") - ) - -} - -private[ducktape] object TransformerMacros { - - inline def transformConfigured[Source, Dest]( - sourceValue: Source, - inline config: Seq[BuilderConfig[Source, Dest]] - ): Dest = ${ transformConfiguredMacro('sourceValue, 'config) } - - def transformConfiguredMacro[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - config: Expr[Seq[BuilderConfig[Source, Dest]]] - )(using Quotes) = - TransformerMacros().transformConfigured(sourceValue, config) -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala new file mode 100644 index 00000000..501fe4e0 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala @@ -0,0 +1,19 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.quoted.* + +private[ducktape] final case class Case( + val name: String, + val tpe: Type[?], + val ordinal: Int +) { + def materializeSingleton(using Quotes): Option[quotes.reflect.Term] = { + import quotes.reflect.* + + val typeRepr = TypeRepr.of(using tpe) + + Option.when(typeRepr.isSingleton) { + typeRepr match { case TermRef(a, b) => Ident(TermRef(a, b)) } + } + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/CaseModule.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/CaseModule.scala deleted file mode 100644 index 7cc9bf6f..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/CaseModule.scala +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import scala.deriving.* -import scala.quoted.* - -private[ducktape] trait CaseModule { self: Module & MirrorModule => - import quotes.reflect.* - - sealed trait Cases { - export byName.get - - val value: List[Case] - - val byName: Map[String, Case] = value.map(c => c.name -> c).toMap - } - - object Cases { - - def source(using sourceCases: Cases.Source): Cases.Source = sourceCases - def dest(using destCases: Cases.Dest): Cases.Dest = destCases - - final case class Source(value: List[Case]) extends Cases - object Source extends CasesCompanion[Source] - - final case class Dest(value: List[Case]) extends Cases - object Dest extends CasesCompanion[Dest] - } - - protected trait CasesCompanion[CasesSubtype <: Cases] { - def apply(cases: List[Case]): CasesSubtype - - final def fromMirror[A: Type](mirror: Expr[Mirror.SumOf[A]]): CasesSubtype = { - val materializedMirror = MaterializedMirror.createOrAbort(mirror) - - val cases = materializedMirror.mirroredElemLabels - .zip(materializedMirror.mirroredElemTypes) - .zipWithIndex - .map { case name -> tpe -> ordinal => Case(name, tpe, ordinal) } - - apply(cases) - } - } - - final case class Case(name: String, tpe: TypeRepr, ordinal: Int) { - def materializeSingleton: Option[Term] = - Option.when(tpe.isSingleton) { - tpe match { case TermRef(a, b) => Ident(TermRef(a, b)) } - } - } -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala new file mode 100644 index 00000000..fb38c7b6 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala @@ -0,0 +1,38 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.deriving.Mirror +import scala.quoted.* + +private[ducktape] sealed trait Cases { + export byName.get + + val value: List[Case] + + val byName: Map[String, Case] = value.map(c => c.name -> c).toMap +} + +object Cases { + def source(using sourceCases: Cases.Source): Cases.Source = sourceCases + def dest(using destCases: Cases.Dest): Cases.Dest = destCases + + final case class Source(value: List[Case]) extends Cases + object Source extends CasesCompanion[Source] + + final case class Dest(value: List[Case]) extends Cases + object Dest extends CasesCompanion[Dest] + + sealed abstract class CasesCompanion[CasesSubtype <: Cases] { + def apply(cases: List[Case]): CasesSubtype + + final def fromMirror[A: Type](mirror: Expr[Mirror.SumOf[A]])(using Quotes): CasesSubtype = { + val materializedMirror = MaterializedMirror.createOrAbort(mirror) + + val cases = materializedMirror.mirroredElemLabels + .zip(materializedMirror.mirroredElemTypes) + .zipWithIndex + .map { case name -> tpe -> ordinal => Case(name, tpe.asType, ordinal) } + + apply(cases) + } + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/ConfigurationModule.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/ConfigurationModule.scala deleted file mode 100644 index 0a9b7877..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/ConfigurationModule.scala +++ /dev/null @@ -1,138 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import io.github.arainko.ducktape.function.FunctionArguments -import io.github.arainko.ducktape.{ Case => CaseConfig, Field => FieldConfig, _ } - -import scala.quoted.* - -private[ducktape] trait ConfigurationModule { self: Module & SelectorModule & MirrorModule & FieldModule & CaseModule => - import quotes.reflect.* - - sealed trait MaterializedConfiguration - - object MaterializedConfiguration { - enum Product extends MaterializedConfiguration { - val destFieldName: String - - case Const(destFieldName: String, value: Expr[Any]) - case Computed(destFieldName: String, fuction: Expr[Any => Any]) - case Renamed(destFieldName: String, sourceFieldName: String) - } - - enum Coproduct extends MaterializedConfiguration { - val tpe: TypeRepr - - case Computed(tpe: TypeRepr, function: Expr[Any => Any]) - case Const(tpe: TypeRepr, value: Expr[Any]) - } - - def materializeProductConfig[Source, Dest]( - config: Expr[Seq[BuilderConfig[Source, Dest]]] - )(using Fields.Source, Fields.Dest): List[Product] = - Varargs - .unapply(config) - .getOrElse(abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Field))) - .map(materializeSingleProductConfig) - .groupBy(_.destFieldName) - .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only - .toList - - def materializeArgConfig[Source, Dest, ArgSelector <: FunctionArguments]( - config: Expr[Seq[ArgBuilderConfig[Source, Dest, ArgSelector]]] - )(using Fields.Source, Fields.Dest): List[Product] = - Varargs - .unapply(config) - .getOrElse(abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Arg))) - .map(materializeSingleArgConfig) - .groupBy(_.destFieldName) - .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only - .toList - - def materializeCoproductConfig[Source, Dest]( - config: Expr[Seq[BuilderConfig[Source, Dest]]] - )(using Cases.Source, Cases.Dest): List[Coproduct] = - Varargs - .unapply(config) - .getOrElse(abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Case))) - .map(materializeSingleCoproductConfig) - .groupBy(_.tpe.fullName) // TODO: Ths is probably not the best way to do this (?) - .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only - .toList - - private def materializeSingleProductConfig[Source, Dest]( - config: Expr[BuilderConfig[Source, Dest]] - )(using Fields.Source, Fields.Dest) = - config match { - case '{ - FieldConfig.const[source, dest, fieldType, actualType]( - $selector, - $value - )(using $ev1, $ev2, $ev3) - } => - val name = Selectors.fieldName(Fields.dest, selector) - Product.Const(name, value) - - case '{ - FieldConfig.computed[source, dest, fieldType, actualType]( - $selector, - $function - )(using $ev1, $ev2, $ev3) - } => - val name = Selectors.fieldName(Fields.dest, selector) - Product.Computed(name, function.asInstanceOf[Expr[Any => Any]]) - - case '{ - FieldConfig.renamed[source, dest, sourceFieldType, destFieldType]( - $destSelector, - $sourceSelector - )(using $ev1, $ev2, $ev3) - } => - val destFieldName = Selectors.fieldName(Fields.dest, destSelector) - val sourceFieldName = Selectors.fieldName(Fields.source, sourceSelector) - Product.Renamed(destFieldName, sourceFieldName) - - case other => abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Field)) - } - - private def materializeSingleCoproductConfig[Source, Dest](config: Expr[BuilderConfig[Source, Dest]]) = - config match { - case '{ CaseConfig.computed[sourceSubtype].apply[source, dest]($function)(using $ev1, $ev2, $ev3) } => - Coproduct.Computed(TypeRepr.of[sourceSubtype], function.asInstanceOf[Expr[Any => Any]]) - - case '{ CaseConfig.const[sourceSubtype].apply[source, dest]($value)(using $ev1, $ev2, $ev3) } => - Coproduct.Const(TypeRepr.of[sourceSubtype], value) - - case other => abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Case)) - } - - private def materializeSingleArgConfig[Source, Dest, ArgSelector <: FunctionArguments]( - config: Expr[ArgBuilderConfig[Source, Dest, ArgSelector]] - )(using Fields.Source, Fields.Dest): Product = - config match { - case '{ - type argSelector <: FunctionArguments - Arg.const[source, dest, argType, actualType, `argSelector`]($selector, $const)(using $ev1, $ev2) - } => - val argName = Selectors.argName(Fields.dest, selector) - Product.Const(argName, const) - - case '{ - type argSelector <: FunctionArguments - Arg.computed[source, dest, argType, actualType, `argSelector`]($selector, $function)(using $ev1, $ev2) - } => - val argName = Selectors.argName(Fields.dest, selector) - Product.Computed(argName, function.asInstanceOf[Expr[Any => Any]]) - - case '{ - type argSelector <: FunctionArguments - Arg.renamed[source, dest, argType, fieldType, `argSelector`]($destSelector, $sourceSelector)(using $ev1, $ev2) - } => - val argName = Selectors.argName(Fields.dest, destSelector) - val fieldName = Selectors.fieldName(Fields.source, sourceSelector) - Product.Renamed(argName, fieldName) - - case other => abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Arg)) - } - - } -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala new file mode 100644 index 00000000..274a90ef --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala @@ -0,0 +1,156 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.quoted.* + +private[ducktape] sealed trait Failure { + def position(using Quotes): quotes.reflect.Position = + quotes.reflect.Position.ofMacroExpansion + + def render(using Quotes): String +} + +private[ducktape] object Failure { + + def abort(failure: Failure)(using Quotes): Nothing = + quotes.reflect.report.errorAndAbort(failure.render, failure.position) + + private given (using quotes: Quotes): quotes.reflect.Printer[quotes.reflect.Tree] = + quotes.reflect.Printer.TreeShortCode + + private given (using quotes: Quotes): quotes.reflect.Printer[quotes.reflect.TypeRepr] = + quotes.reflect.Printer.TypeReprShortCode + + enum ConfigType { + final def name: "field" | "case" | "arg" = + this match { + case Field => "field" + case Case => "case" + case Arg => "arg" + } + + case Field, Case, Arg + } + + final case class MirrorMaterialization(mirroredType: Type[?], notFoundTypeMemberName: String) extends Failure { + + override final def render(using Quotes): String = { + import quotes.reflect.* + + s""" + |Mirror materialization for ${mirroredType.show} failed. + |Member type not found: '$notFoundTypeMemberName'. + """.stripMargin + } + } + + final case class InvalidFieldSelector( + selector: Expr[Any], + sourceTpe: Type[?], + suggestedFields: List[Suggestion] + ) extends Failure { + override final def position(using Quotes): quotes.reflect.Position = selector.pos + + override final def render(using Quotes): String = + s""" + |'${selector.show}' is not a valid field selector for ${sourceTpe.show}. + |Try one of these: ${Suggestion.renderAll(suggestedFields)} + """.stripMargin + } + + enum InvalidArgSelector extends Failure { + override final def position(using Quotes): quotes.reflect.Position = + this match { + case NotFound(selector, _, _) => selector.pos + case NotAnArgSelector(selector, _) => selector.pos + } + + override final def render(using Quotes): String = + this match { + case NotFound(_, argName, suggestedArgs) => + s""" + |'_.$argName' is not a valid argument selector. + |Try one of these: ${Suggestion.renderAll(suggestedArgs)} + """.stripMargin + case NotAnArgSelector(_, suggestedArgs) => + s""" + |Not a valid argument selector. + |Try one of these: ${Suggestion.renderAll(suggestedArgs)} + """.stripMargin + } + + case NotFound(selector: Expr[Any], argumentName: String, suggestedArgs: List[Suggestion]) + + case NotAnArgSelector(selector: Expr[Any], suggestedArgs: List[Suggestion]) + } + + final case class UnsupportedConfig(config: Expr[Any], configFor: ConfigType) extends Failure { + private def fieldOrArgSuggestions(fieldOrArg: Failure.ConfigType.Arg.type | Failure.ConfigType.Field.type) = { + val capitalized = fieldOrArg.name.capitalize + Suggestion.all( + s"""${capitalized}.const(_.${fieldOrArg}Name, "value")""", + s"""${capitalized}.computed(_.${fieldOrArg}Name, source => source.value)""", + s"""${capitalized}.renamed(_.${fieldOrArg}Name1, _.${fieldOrArg}Name2)""" + ) + } + + private val caseSuggestions = Suggestion.all( + """Case.const[SourceSubtype.type](SourceSubtype.value)""", + """Case.computed[SourceSubtype.type](source => source.value)""" + ) + + private val suggestions = + configFor match { + case field: Failure.ConfigType.Field.type => fieldOrArgSuggestions(field) + case arg: Failure.ConfigType.Arg.type => fieldOrArgSuggestions(arg) + case Failure.ConfigType.Case => caseSuggestions + } + + override final def position(using Quotes) = config.pos + + override final def render(using Quotes): String = + s""" + |'${config.show}' is not a supported $configFor configuration expression. + |Try one of these: ${Suggestion.renderAll(suggestions)} + | + |Please note that you HAVE to use these directly as variadic arguments (not through a proxy method, + |not with the splash operator (eg. Seq()*) etc.). + """.stripMargin + } + + final case class NoFieldMapping(fieldName: String, sourceType: Type[?]) extends Failure { + override final def render(using Quotes): String = s"No field named '$fieldName' found in ${sourceType.show}" + } + + final case class NoChildMapping(childName: String, destinationType: Type[?]) extends Failure { + override final def render(using Quotes): String = s"No child named '$childName' found in ${destinationType.show}" + } + + final case class CannotMaterializeSingleton(tpe: Type[?]) extends Failure { + private def suggestions(using Quotes) = Suggestion.all(s"${tpe.show} is not a singleton type") + + override final def render(using Quotes): String = + s""" + |Cannot materialize singleton for ${tpe.show}. + |Possible causes: ${Suggestion.renderAll(suggestions)} + """.stripMargin + } + + final case class FieldSourceMatchesNoneOfDestFields(config: Expr[Any], fieldSourceTpe: Type[?], destTpe: Type[?]) + extends Failure { + + override def position(using Quotes): quotes.reflect.Position = config.pos + override def render(using Quotes): String = + s""" + |None of the fields from ${fieldSourceTpe.show} match any of the fields from ${destTpe.show}.""".stripMargin + } + + extension (tpe: Type[?]) { + private def show(using Quotes): String = quotes.reflect.TypeRepr.of(using tpe).show + } + + extension (expr: Expr[Any]) { + private def show(using Quotes): String = quotes.reflect.asTerm(expr).show + + private def pos(using Quotes): quotes.reflect.Position = quotes.reflect.asTerm(expr).pos + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Field.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Field.scala new file mode 100644 index 00000000..39b0da50 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Field.scala @@ -0,0 +1,24 @@ +package io.github.arainko.ducktape.internal.modules + +import io.github.arainko.ducktape.Transformer + +import scala.quoted.* + +private[ducktape] final class Field(val name: String, val tpe: Type[?]) { + def transformerTo(that: Field)(using Quotes): Expr[Transformer[?, ?]] = { + import quotes.reflect.* + + (tpe -> that.tpe) match { + case '[src] -> '[dest] => + Implicits.search(TypeRepr.of[Transformer[src, dest]]) match { + case success: ImplicitSearchSuccess => success.tree.asExprOf[Transformer[src, dest]] + case err: ImplicitSearchFailure => report.errorAndAbort(err.explanation) + } + } + } + + def <:<(that: Field)(using Quotes): Boolean = { + import quotes.reflect.* + TypeRepr.of(using tpe) <:< TypeRepr.of(using that.tpe) + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FieldModule.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FieldModule.scala deleted file mode 100644 index d2b231d9..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FieldModule.scala +++ /dev/null @@ -1,78 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import io.github.arainko.ducktape.Transformer -import io.github.arainko.ducktape.function.FunctionArguments - -import scala.compiletime.* -import scala.deriving.* -import scala.quoted.* - -private[ducktape] trait FieldModule { self: Module & MirrorModule => - import quotes.reflect.* - - sealed trait Fields { - export byName.{ apply => unsafeGet, contains => containsFieldWithName, get } - - val value: List[Field] - - val byName: Map[String, Field] = value.map(f => f.name -> f).toMap - } - - object Fields { - def source(using sourceFields: Fields.Source): Fields.Source = sourceFields - def dest(using destFields: Fields.Dest): Fields.Dest = destFields - - final case class Source(value: List[Field]) extends Fields - object Source extends FieldsCompanion[Source] - - final case class Dest(value: List[Field]) extends Fields - object Dest extends FieldsCompanion[Dest] - } - - protected trait FieldsCompanion[FieldsSubtype <: Fields] { - - def apply(fields: List[Field]): FieldsSubtype - - final def fromMirror[A: Type](mirror: Expr[Mirror.ProductOf[A]]): FieldsSubtype = { - val materializedMirror = MaterializedMirror.createOrAbort(mirror) - - val fields = materializedMirror.mirroredElemLabels - .zip(materializedMirror.mirroredElemTypes) - .map(Field.apply) - - apply(fields) - } - - final def fromFunctionArguments[ArgSelector <: FunctionArguments: Type]: FieldsSubtype = { - val fields = List.unfold(TypeRepr.of[ArgSelector]) { state => - PartialFunction.condOpt(state) { - case Refinement(parent, name, fieldTpe) => Field(name, fieldTpe) -> parent - } - } - apply(fields.reverse) - } - - final def fromValDefs(valDefs: List[ValDef]): FieldsSubtype = { - val fields = valDefs.map(vd => Field(vd.name, vd.tpt.tpe)) - apply(fields) - } - - } - - final case class Field(name: String, tpe: TypeRepr) { - - def transformerTo(that: Field): Expr[Transformer[?, ?]] = - (tpe.asType -> that.tpe.asType) match { - case '[src] -> '[dest] => - Implicits.search(TypeRepr.of[Transformer[src, dest]]) match { - case success: ImplicitSearchSuccess => success.tree.asExprOf[Transformer[src, dest]] - case err: ImplicitSearchFailure => report.errorAndAbort(err.explanation) - } - } - - } - - extension (companion: Suggestion.type) { - def fromFields(fields: Fields): List[Suggestion] = fields.value.map(f => Suggestion(s"_.${f.name}")) - } -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Fields.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Fields.scala new file mode 100644 index 00000000..19c41d8f --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Fields.scala @@ -0,0 +1,56 @@ +package io.github.arainko.ducktape.internal.modules + +import io.github.arainko.ducktape.function.FunctionArguments + +import scala.deriving.Mirror +import scala.quoted.* + +private[ducktape] sealed trait Fields { + export byName.{ apply => unsafeGet, contains => containsFieldWithName, get } + + val value: List[Field] + + val byName: Map[String, Field] = value.map(f => f.name -> f).toMap +} + +private[ducktape] object Fields { + def source(using sourceFields: Fields.Source): Fields.Source = sourceFields + def dest(using destFields: Fields.Dest): Fields.Dest = destFields + + final case class Source(value: List[Field]) extends Fields + object Source extends FieldsCompanion[Source] + + final case class Dest(value: List[Field]) extends Fields + object Dest extends FieldsCompanion[Dest] + + sealed abstract class FieldsCompanion[FieldsSubtype <: Fields] { + + def apply(fields: List[Field]): FieldsSubtype + + final def fromMirror[A: Type](mirror: Expr[Mirror.ProductOf[A]])(using Quotes): FieldsSubtype = { + val materializedMirror = MaterializedMirror.createOrAbort(mirror) + + val fields = materializedMirror.mirroredElemLabels + .zip(materializedMirror.mirroredElemTypes) + .map((name, tpe) => Field(name, tpe.asType)) + apply(fields) + } + + final def fromFunctionArguments[ArgSelector <: FunctionArguments: Type](using Quotes): FieldsSubtype = { + import quotes.reflect.* + + val fields = List.unfold(TypeRepr.of[ArgSelector]) { state => + PartialFunction.condOpt(state) { + case Refinement(parent, name, fieldTpe) => Field(name, fieldTpe.asType) -> parent + } + } + apply(fields.reverse) + } + + final def fromValDefs(using Quotes)(valDefs: List[quotes.reflect.ValDef]): FieldsSubtype = { + val fields = valDefs.map(vd => Field(vd.name, vd.tpt.tpe.asType)) + apply(fields) + } + + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FunctionLambda.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FunctionLambda.scala new file mode 100644 index 00000000..0fd979d0 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/FunctionLambda.scala @@ -0,0 +1,15 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.quoted.* + +private[ducktape] object FunctionLambda { + def unapply(using Quotes)(arg: quotes.reflect.Term): Option[(List[quotes.reflect.ValDef], quotes.reflect.Term)] = { + import quotes.reflect.* + + arg match { + case Inlined(_, _, Lambda(vals, term)) => Some(vals -> term) + case Inlined(_, _, nested) => FunctionLambda.unapply(nested) + case _ => None + } + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/MakeTransformer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MakeTransformer.scala similarity index 73% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/MakeTransformer.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MakeTransformer.scala index 368129dc..91de772b 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/MakeTransformer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MakeTransformer.scala @@ -1,6 +1,4 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation - -import io.github.arainko.ducktape.internal.modules.liftTransformation.{ Uninlined, Untyped } +package io.github.arainko.ducktape.internal.modules import scala.quoted.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedConfiguration.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedConfiguration.scala new file mode 100644 index 00000000..b570ebd2 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedConfiguration.scala @@ -0,0 +1,156 @@ +package io.github.arainko.ducktape.internal.modules + +import io.github.arainko.ducktape.function.FunctionArguments +import io.github.arainko.ducktape.{ Case => CaseConfig, Field => FieldConfig, * } + +import scala.quoted.* + +private[ducktape] sealed trait MaterializedConfiguration + +private[ducktape] object MaterializedConfiguration { + enum Product extends MaterializedConfiguration { + val destFieldName: String + + case Const(destFieldName: String, value: Expr[Any]) + case Computed(destFieldName: String, fuction: Expr[Any => Any]) + case Renamed(destFieldName: String, sourceFieldName: String) + } + + enum Coproduct extends MaterializedConfiguration { + val tpe: Type[?] + + case Computed(tpe: Type[?], function: Expr[Any => Any]) + case Const(tpe: Type[?], value: Expr[Any]) + } + + def materializeProductConfig[Source, Dest]( + config: Expr[Seq[BuilderConfig[Source, Dest]]] + )(using Quotes, Fields.Source, Fields.Dest): List[Product] = + Varargs + .unapply(config) + .getOrElse(Failure.abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Field))) + .flatMap(materializeSingleProductConfig) + .groupBy(_.destFieldName) + .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only + .toList + + def materializeArgConfig[Source, Dest, ArgSelector <: FunctionArguments]( + config: Expr[Seq[ArgBuilderConfig[Source, Dest, ArgSelector]]] + )(using Quotes, Fields.Source, Fields.Dest): List[Product] = + Varargs + .unapply(config) + .getOrElse(Failure.abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Arg))) + .map(materializeSingleArgConfig) + .groupBy(_.destFieldName) + .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only + .toList + + def materializeCoproductConfig[Source, Dest]( + config: Expr[Seq[BuilderConfig[Source, Dest]]] + )(using Quotes, Cases.Source, Cases.Dest): List[Coproduct] = + Varargs + .unapply(config) + .getOrElse(Failure.abort(Failure.UnsupportedConfig(config, Failure.ConfigType.Case))) + .map(materializeSingleCoproductConfig) + .groupBy(_.tpe.fullName) // TODO: This is probably not the best way to do this (?) + .map((_, fieldConfigs) => fieldConfigs.last) // keep the last applied field config only + .toList + + private def materializeSingleProductConfig[Source, Dest]( + config: Expr[BuilderConfig[Source, Dest]] + )(using Quotes, Fields.Source, Fields.Dest) = { + import quotes.reflect.* + + config match { + case '{ + FieldConfig.const[source, dest, fieldType, actualType]( + $selector, + $value + )(using $ev1, $ev2, $ev3) + } => + val name = Selectors.fieldName(Fields.dest, selector) + Product.Const(name, value) :: Nil + + case '{ + FieldConfig.computed[source, dest, fieldType, actualType]( + $selector, + $function + )(using $ev1, $ev2, $ev3) + } => + val name = Selectors.fieldName(Fields.dest, selector) + Product.Computed(name, function.asInstanceOf[Expr[Any => Any]]) :: Nil + + case '{ + FieldConfig.renamed[source, dest, sourceFieldType, destFieldType]( + $destSelector, + $sourceSelector + )(using $ev1, $ev2, $ev3) + } => + val destFieldName = Selectors.fieldName(Fields.dest, destSelector) + val sourceFieldName = Selectors.fieldName(Fields.source, sourceSelector) + Product.Renamed(destFieldName, sourceFieldName) :: Nil + + case config @ '{ FieldConfig.allMatching[source, dest, fieldSource]($fieldSource)(using $ev1, $ev2, $fieldSourceMirror) } => + val fieldSourceFields = Fields.Source.fromMirror(fieldSourceMirror) + val fieldSourceTerm = fieldSource.asTerm + val materializedConfig = + fieldSourceFields.value.flatMap { sourceField => + Fields.dest.byName + .get(sourceField.name) + .filter(sourceField <:< _) + .map(field => Product.Const(field.name, accessField(fieldSourceTerm, field))) + } + + if (materializedConfig.isEmpty) + Failure.abort(Failure.FieldSourceMatchesNoneOfDestFields(config, summon[Type[fieldSource]], summon[Type[dest]])) + else materializedConfig + case other => Failure.abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Field)) + } + } + + private def materializeSingleCoproductConfig[Source, Dest](config: Expr[BuilderConfig[Source, Dest]])(using Quotes) = + config match { + case '{ CaseConfig.computed[sourceSubtype].apply[source, dest]($function)(using $ev1, $ev2, $ev3) } => + Coproduct.Computed(summon[Type[sourceSubtype]], function.asInstanceOf[Expr[Any => Any]]) + + case '{ CaseConfig.const[sourceSubtype].apply[source, dest]($value)(using $ev1, $ev2, $ev3) } => + Coproduct.Const(summon[Type[sourceSubtype]], value) + + case other => Failure.abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Case)) + } + + private def materializeSingleArgConfig[Source, Dest, ArgSelector <: FunctionArguments]( + config: Expr[ArgBuilderConfig[Source, Dest, ArgSelector]] + )(using Quotes, Fields.Source, Fields.Dest): Product = + config match { + case '{ + type argSelector <: FunctionArguments + Arg.const[source, dest, argType, actualType, `argSelector`]($selector, $const)(using $ev1, $ev2) + } => + val argName = Selectors.argName(Fields.dest, selector) + Product.Const(argName, const) + + case '{ + type argSelector <: FunctionArguments + Arg.computed[source, dest, argType, actualType, `argSelector`]($selector, $function)(using $ev1, $ev2) + } => + val argName = Selectors.argName(Fields.dest, selector) + Product.Computed(argName, function.asInstanceOf[Expr[Any => Any]]) + + case '{ + type argSelector <: FunctionArguments + Arg.renamed[source, dest, argType, fieldType, `argSelector`]($destSelector, $sourceSelector)(using $ev1, $ev2) + } => + val argName = Selectors.argName(Fields.dest, destSelector) + val fieldName = Selectors.fieldName(Fields.source, sourceSelector) + Product.Renamed(argName, fieldName) + + case other => Failure.abort(Failure.UnsupportedConfig(other, Failure.ConfigType.Arg)) + } + + private def accessField(using Quotes)(value: quotes.reflect.Term, field: Field) = { + import quotes.reflect.* + Select.unique(value, field.name).asExpr + } + +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedMirror.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedMirror.scala new file mode 100644 index 00000000..fc4a0bef --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MaterializedMirror.scala @@ -0,0 +1,70 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.annotation.tailrec +import scala.deriving.Mirror +import scala.quoted.* + +private[ducktape] final class MaterializedMirror[Q <: Quotes & Singleton] private (using val quotes: Q)( + val mirroredType: quotes.reflect.TypeRepr, + val mirroredMonoType: quotes.reflect.TypeRepr, + val mirroredElemTypes: List[quotes.reflect.TypeRepr], + val mirroredLabel: String, + val mirroredElemLabels: List[String] +) + +// Lifted from shapeless 3: +// https://github.com/typelevel/shapeless-3/blob/main/modules/deriving/src/main/scala/shapeless3/deriving/internals/reflectionutils.scala +private[ducktape] object MaterializedMirror { + + def createOrAbort[A: Type](mirror: Expr[Mirror.Of[A]])(using Quotes): MaterializedMirror[quotes.type] = + create(mirror).fold(memberName => Failure.abort(Failure.MirrorMaterialization(summon, memberName)), identity) + + private def create(mirror: Expr[Mirror])(using Quotes): Either[String, MaterializedMirror[quotes.type]] = { + import quotes.reflect.* + + val mirrorTpe = mirror.asTerm.tpe.widen + for { + mirroredType <- findMemberType(mirrorTpe, "MirroredType") + mirroredMonoType <- findMemberType(mirrorTpe, "MirroredMonoType") + mirroredElemTypes <- findMemberType(mirrorTpe, "MirroredElemTypes") + mirroredLabel <- findMemberType(mirrorTpe, "MirroredLabel") + mirroredElemLabels <- findMemberType(mirrorTpe, "MirroredElemLabels") + } yield { + val elemTypes = tupleTypeElements(mirroredElemTypes) + val ConstantType(StringConstant(label)) = mirroredLabel: @unchecked + val elemLabels = tupleTypeElements(mirroredElemLabels).map { case ConstantType(StringConstant(l)) => l } + MaterializedMirror(mirroredType, mirroredMonoType, elemTypes, label, elemLabels) + } + } + + private def tupleTypeElements(using Quotes)(tp: quotes.reflect.TypeRepr): List[quotes.reflect.TypeRepr] = { + import quotes.reflect.* + + @tailrec def loop(tp: TypeRepr, acc: List[TypeRepr]): List[TypeRepr] = tp match { + case AppliedType(pairTpe, List(hd: TypeRepr, tl: TypeRepr)) => loop(tl, hd :: acc) + case _ => acc + } + loop(tp, Nil).reverse + } + + private def low(using Quotes)(tp: quotes.reflect.TypeRepr): quotes.reflect.TypeRepr = { + import quotes.reflect.* + + tp match { + case tp: TypeBounds => tp.low + case tp => tp + } + } + + private def findMemberType(using Quotes)(tp: quotes.reflect.TypeRepr, name: String): Either[String, quotes.reflect.TypeRepr] = { + import quotes.reflect.* + + tp match { + case Refinement(_, `name`, tp) => Right(low(tp)) + case Refinement(parent, _, _) => findMemberType(parent, name) + case AndType(left, right) => findMemberType(left, name).orElse(findMemberType(right, name)) + case _ => Left(name) + } + } + +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MirrorModule.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MirrorModule.scala deleted file mode 100644 index 856b7db5..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/MirrorModule.scala +++ /dev/null @@ -1,61 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import scala.annotation.tailrec -import scala.deriving.Mirror -import scala.quoted.* - -// Lifted from shapeless 3: -// https://github.com/typelevel/shapeless-3/blob/main/modules/deriving/src/main/scala/shapeless3/deriving/internals/reflectionutils.scala -private[ducktape] trait MirrorModule { self: Module => - import quotes.reflect.* - - final case class MaterializedMirror private ( - mirroredType: TypeRepr, - mirroredMonoType: TypeRepr, - mirroredElemTypes: List[TypeRepr], - mirroredLabel: String, - mirroredElemLabels: List[String] - ) - - object MaterializedMirror { - def createOrAbort[A: Type](mirror: Expr[Mirror.Of[A]]): MaterializedMirror = - create(mirror).fold(memberName => abort(Failure.MirrorMaterialization(TypeRepr.of[A], memberName)), identity) - - private def create(mirror: Expr[Mirror]): Either[String, MaterializedMirror] = { - val mirrorTpe = mirror.asTerm.tpe.widen - for { - mirroredType <- findMemberType(mirrorTpe, "MirroredType") - mirroredMonoType <- findMemberType(mirrorTpe, "MirroredMonoType") - mirroredElemTypes <- findMemberType(mirrorTpe, "MirroredElemTypes") - mirroredLabel <- findMemberType(mirrorTpe, "MirroredLabel") - mirroredElemLabels <- findMemberType(mirrorTpe, "MirroredElemLabels") - } yield { - val elemTypes = tupleTypeElements(mirroredElemTypes) - val ConstantType(StringConstant(label)) = mirroredLabel: @unchecked - val elemLabels = tupleTypeElements(mirroredElemLabels).map { case ConstantType(StringConstant(l)) => l } - MaterializedMirror(mirroredType, mirroredMonoType, elemTypes, label, elemLabels) - } - } - } - - private def tupleTypeElements(tp: TypeRepr): List[TypeRepr] = { - @tailrec def loop(tp: TypeRepr, acc: List[TypeRepr]): List[TypeRepr] = tp match { - case AppliedType(pairTpe, List(hd: TypeRepr, tl: TypeRepr)) => loop(tl, hd :: acc) - case _ => acc - } - loop(tp, Nil).reverse - } - - private def low(tp: TypeRepr): TypeRepr = tp match { - case tp: TypeBounds => tp.low - case tp => tp - } - - private def findMemberType(tp: TypeRepr, name: String): Either[String, TypeRepr] = - tp match { - case Refinement(_, `name`, tp) => Right(low(tp)) - case Refinement(parent, _, _) => findMemberType(parent, name) - case AndType(left, right) => findMemberType(left, name).orElse(findMemberType(right, name)) - case _ => Left(name) - } -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Module.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Module.scala deleted file mode 100644 index 550ae02b..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Module.scala +++ /dev/null @@ -1,161 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import io.github.arainko.ducktape.{ Arg, Transformer } - -import scala.deriving.* -import scala.quoted.* - -private[ducktape] trait Module { - val quotes: Quotes - - given Quotes = quotes - - import quotes.reflect.* - - given Printer[TypeRepr] = Printer.TypeReprShortCode - given Printer[Tree] = Printer.TreeShortCode - - def mirrorOf[A: Type]: Option[Expr[Mirror.Of[A]]] = Expr.summon[Mirror.Of[A]] - - def abort(error: Failure): Nothing = - report.errorAndAbort(error.render, error.position) - - extension (tpe: TypeRepr) { - def fullName: String = tpe.show(using Printer.TypeReprCode) - } - - opaque type Suggestion = String - - object Suggestion { - def apply(text: String): Suggestion = text - - def all(head: String, tail: String*): List[Suggestion] = head :: tail.toList - - /** - * Prepends a newline, adds a '|' (to work with .stripPrefix) and a bullet point character to each suggestion. - */ - def renderAll(suggestions: List[Suggestion]): String = - suggestions.mkString("\n| • ", "\n| • ", "") - } - - sealed trait Failure { - def position: Position = Position.ofMacroExpansion - - def render: String - } - - object Failure { - enum ConfigType { - final def name: String = - this match { - case Field => "field" - case Case => "case" - case Arg => "arg" - } - - case Field, Case, Arg - } - - final case class MirrorMaterialization(mirroredType: TypeRepr, notFoundTypeMemberName: String) extends Failure { - - def render: String = - s""" - |Mirror materialization for ${mirroredType.show} failed. - |Member type not found: '$notFoundTypeMemberName'. - """.stripMargin - } - - final case class InvalidFieldSelector( - selector: Expr[Any], - sourceTpe: TypeRepr, - suggestedFields: List[Suggestion] - ) extends Failure { - override def position: Position = selector.asTerm.pos - - def render: String = - s""" - |'${selector.asTerm.show}' is not a valid field selector for ${sourceTpe.show}. - |Try one of these: ${Suggestion.renderAll(suggestedFields)} - """.stripMargin - } - - enum InvalidArgSelector extends Failure { - override def position: Position = - this match { - case NotFound(selector, _, _) => selector.asTerm.pos - case NotAnArgSelector(selector, _) => selector.asTerm.pos - } - - final def render = this match { - case NotFound(_, argName, suggestedArgs) => - s""" - |'_.$argName' is not a valid argument selector. - |Try one of these: ${Suggestion.renderAll(suggestedArgs)} - """.stripMargin - case NotAnArgSelector(_, suggestedArgs) => - s""" - |Not a valid argument selector. - |Try one of these: ${Suggestion.renderAll(suggestedArgs)} - """.stripMargin - } - - case NotFound(selector: Expr[Any], argumentName: String, suggestedArgs: List[Suggestion]) - - case NotAnArgSelector(selector: Expr[Any], suggestedArgs: List[Suggestion]) - } - - final case class UnsupportedConfig(config: Expr[Any], configFor: ConfigType) extends Failure { - private def fieldOrArgSuggestions(fieldOrArg: Failure.ConfigType.Arg.type | Failure.ConfigType.Field.type) = { - val capitalized = fieldOrArg.name.capitalize - Suggestion.all( - s"""${capitalized}.const(_.${fieldOrArg}Name, "value")""", - s"""${capitalized}.computed(_.${fieldOrArg}Name, source => source.value)""", - s"""${capitalized}.renamed(_.${fieldOrArg}Name1, _.${fieldOrArg}Name2)""" - ) - } - - private val caseSuggestions = Suggestion.all( - """Case.const[SourceSubtype.type](SourceSubtype.value)""", - """Case.computed[SourceSubtype.type](source => source.value)""" - ) - - private val suggestions = - configFor match { - case field: Failure.ConfigType.Field.type => fieldOrArgSuggestions(field) - case arg: Failure.ConfigType.Arg.type => fieldOrArgSuggestions(arg) - case Failure.ConfigType.Case => caseSuggestions - } - - override def position = config.asTerm.pos - - def render: String = - s""" - |'${config.asTerm.show}' is not a supported $configFor configuration expression. - |Try one of these: ${Suggestion.renderAll(suggestions)} - | - |Please note that you HAVE to use these directly as variadic arguments (not through a proxy method, - |not with the splash operator (eg. Seq()*) etc.). - """.stripMargin - } - - final case class NoFieldMapping(fieldName: String, sourceType: TypeRepr) extends Failure { - def render = s"No field named '$fieldName' found in ${sourceType.show}" - } - - final case class NoChildMapping(childName: String, destinationType: TypeRepr) extends Failure { - def render: String = s"No child named '$childName' found in ${destinationType.show}" - } - - final case class CannotMaterializeSingleton(tpe: TypeRepr) extends Failure { - private val suggestions = Suggestion.all(s"${tpe.show} is not a singleton type") - - def render: String = - s""" - |Cannot materialize singleton for ${tpe.show}. - |Possible causes: ${Suggestion.renderAll(suggestions)} - """.stripMargin - } - - } - -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Replacer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Replacer.scala similarity index 88% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Replacer.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Replacer.scala index c246b288..414c8389 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Replacer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Replacer.scala @@ -1,6 +1,4 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation - -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda +package io.github.arainko.ducktape.internal.modules import scala.quoted.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/SelectorModule.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/SelectorModule.scala deleted file mode 100644 index 15bdb817..00000000 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/SelectorModule.scala +++ /dev/null @@ -1,72 +0,0 @@ -package io.github.arainko.ducktape.internal.modules - -import io.github.arainko.ducktape.function.* - -import scala.deriving.* -import scala.quoted.* -import scala.util.NotGiven - -private[ducktape] trait SelectorModule { self: Module & MirrorModule & FieldModule => - import quotes.reflect.* - - object Selectors { - def fieldName[From: Type, FieldType]( - validFields: Fields, - selector: Expr[From => FieldType] - ): String = - selector match { - case FieldSelector(fieldName) if validFields.containsFieldWithName(fieldName) => - fieldName - case other => - abort(Failure.InvalidFieldSelector(other, TypeRepr.of[From], Suggestion.fromFields(validFields))) - } - - def argName[ArgType: Type, ArgSelector <: FunctionArguments]( - validArgs: Fields, - selector: Expr[ArgSelector => ArgType] - ): String = - selector.asTerm match { - case ArgSelector(argumentName) if validArgs.containsFieldWithName(argumentName) => - argumentName - case ArgSelector(argumentName) => - abort(Failure.InvalidArgSelector.NotFound(selector, argumentName, Suggestion.fromFields(validArgs))) - case other => - abort(Failure.InvalidArgSelector.NotAnArgSelector(selector, Suggestion.fromFields(validArgs))) - } - - } - - object FunctionLambda { - def unapply(arg: Term): Option[(List[ValDef], Term)] = - arg match { - case Inlined(_, _, Lambda(vals, term)) => Some((vals, term)) - case Inlined(_, _, nested) => FunctionLambda.unapply(nested) - case _ => None - } - } - - private object FieldSelector { - def unapply(arg: Expr[Any]): Option[String] = - PartialFunction.condOpt(arg.asTerm) { - case Lambda(_, Select(Ident(_), fieldName)) => fieldName - } - } - - private object ArgSelector { - def unapply(arg: Term): Option[String] = - PartialFunction.condOpt(arg) { - case Lambda(_, FunctionArgumentSelector(argumentName)) => argumentName - } - } - - private object FunctionArgumentSelector { - def unapply(arg: Term): Option[String] = - PartialFunction.condOpt(arg.asExpr) { - case '{ - type argSelector <: FunctionArguments - ($args: `argSelector`).selectDynamic($selectedArg).$asInstanceOf$[tpe] - } => - selectedArg.valueOrAbort - } - } -} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Selectors.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Selectors.scala new file mode 100644 index 00000000..0f49305b --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Selectors.scala @@ -0,0 +1,66 @@ +package io.github.arainko.ducktape.internal.modules + +import io.github.arainko.ducktape.function.FunctionArguments + +import scala.quoted.* + +private[ducktape] object Selectors { + def fieldName[From: Type, FieldType]( + validFields: Fields, + selector: Expr[From => FieldType] + )(using Quotes): String = { + selector match { + case FieldSelector(fieldName) if validFields.containsFieldWithName(fieldName) => + fieldName + case other => + Failure.abort(Failure.InvalidFieldSelector(other, summon, Suggestion.fromFields(validFields))) + } + } + + def argName[ArgType: Type, ArgSelector <: FunctionArguments]( + validArgs: Fields, + selector: Expr[ArgSelector => ArgType] + )(using Quotes): String = { + import quotes.reflect.* + + selector.asTerm match { + case ArgSelector(argumentName) if validArgs.containsFieldWithName(argumentName) => + argumentName + case ArgSelector(argumentName) => + Failure.abort(Failure.InvalidArgSelector.NotFound(selector, argumentName, Suggestion.fromFields(validArgs))) + case other => + Failure.abort(Failure.InvalidArgSelector.NotAnArgSelector(selector, Suggestion.fromFields(validArgs))) + } + } + + private object FieldSelector { + def unapply(arg: Expr[Any])(using Quotes): Option[String] = { + import quotes.reflect.* + + PartialFunction.condOpt(arg.asTerm) { + case Lambda(_, Select(Ident(_), fieldName)) => fieldName + } + } + } + + private object ArgSelector { + def unapply(using Quotes)(arg: quotes.reflect.Term): Option[String] = { + import quotes.reflect.* + + PartialFunction.condOpt(arg) { + case Lambda(_, FunctionArgumentSelector(argumentName)) => argumentName + } + } + } + + private object FunctionArgumentSelector { + def unapply(using Quotes)(arg: quotes.reflect.Term): Option[String] = + PartialFunction.condOpt(arg.asExpr) { + case '{ + type argSelector <: FunctionArguments + ($args: `argSelector`).selectDynamic($selectedArg).$asInstanceOf$[tpe] + } => + selectedArg.valueOrAbort + } + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Suggesion.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Suggesion.scala new file mode 100644 index 00000000..6e7cb2fb --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Suggesion.scala @@ -0,0 +1,19 @@ +package io.github.arainko.ducktape.internal.modules + +import io.github.arainko.ducktape.internal.modules.Fields + +private[ducktape] opaque type Suggestion = String + +private[ducktape] object Suggestion { + def apply(text: String): Suggestion = text + + def all(head: String, tail: String*): List[Suggestion] = head :: tail.toList + + def fromFields(fields: Fields): List[Suggestion] = fields.value.map(f => Suggestion(s"_.${f.name}")) + + /** + * Prepends a newline, adds a '|' (to work with .stripPrefix) and a bullet point character to each suggestion. + */ + def renderAll(suggestions: List[Suggestion]): String = + suggestions.mkString("\n| • ", "\n| • ", "") +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerInvocation.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerInvocation.scala similarity index 83% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerInvocation.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerInvocation.scala index d7a6dab5..4dfd043e 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerInvocation.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerInvocation.scala @@ -1,9 +1,9 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation +package io.github.arainko.ducktape.internal.modules import io.github.arainko.ducktape.Transformer -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda import scala.quoted.* + private[ducktape] object TransformerInvocation { def unapply(using Quotes)(term: quotes.reflect.Term): Option[(TransformerLambda[quotes.type], quotes.reflect.Term)] = { import quotes.reflect.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerLambda.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerLambda.scala similarity index 93% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerLambda.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerLambda.scala index f00851b1..ba7f92e1 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/TransformerLambda.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/TransformerLambda.scala @@ -1,8 +1,7 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation +package io.github.arainko.ducktape.internal.modules import io.github.arainko.ducktape.Transformer -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.* +import io.github.arainko.ducktape.internal.modules.TransformerLambda.* import scala.quoted.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Uninlined.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Uninlined.scala similarity index 81% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Uninlined.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Uninlined.scala index afc117ec..7c87e98f 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Uninlined.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Uninlined.scala @@ -1,4 +1,4 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation +package io.github.arainko.ducktape.internal.modules import scala.quoted.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Untyped.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Untyped.scala similarity index 80% rename from ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Untyped.scala rename to ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Untyped.scala index b149b8b8..6a8ecd9e 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/liftTransformation/Untyped.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Untyped.scala @@ -1,4 +1,4 @@ -package io.github.arainko.ducktape.internal.modules.liftTransformation +package io.github.arainko.ducktape.internal.modules import scala.quoted.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/extensions.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/extensions.scala new file mode 100644 index 00000000..0629f327 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/extensions.scala @@ -0,0 +1,11 @@ +package io.github.arainko.ducktape.internal.modules + +import scala.quoted.* + +extension (tpe: Type[?]) { + private[ducktape] def fullName(using Quotes): String = { + import quotes.reflect.* + + TypeRepr.of(using tpe).show(using Printer.TypeReprCode) + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/syntax.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/syntax.scala index f548638b..ba8b7429 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/syntax.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/syntax.scala @@ -3,7 +3,7 @@ package io.github.arainko.ducktape import io.github.arainko.ducktape.builder.* import io.github.arainko.ducktape.function.* import io.github.arainko.ducktape.internal.macros.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.LiftTransformation +import io.github.arainko.ducktape.internal.modules.* import scala.deriving.Mirror @@ -22,5 +22,5 @@ extension [Source](value: Source) { inline def via[Func](inline function: Func)(using Func: FunctionMirror[Func], Source: Mirror.ProductOf[Source] - ): Func.Return = ProductTransformerMacros.via(value, function) + ): Func.Return = Transformations.via(value, function) } diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/DucktapeSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/DucktapeSuite.scala index 9e4e0e6d..522476f4 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/DucktapeSuite.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/DucktapeSuite.scala @@ -8,7 +8,7 @@ trait DucktapeSuite extends FunSuite { } transparent inline def assertFailsToCompileWith(inline code: String)(expected: String) = { - val errors = compiletime.testing.typeCheckErrors(code).map(_.message).mkString("\n") + val errors = compiletime.testing.typeCheckErrors(code).map(_.message).mkString("\n").strip() assertEquals(errors, expected) } } diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/builder/AppliedBuilderSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/builder/AppliedBuilderSuite.scala index aa00d9bd..a2ee43f7 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/builder/AppliedBuilderSuite.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/builder/AppliedBuilderSuite.scala @@ -73,7 +73,80 @@ class AppliedBuilderSuite extends DucktapeSuite { }("Cannot prove that Int <:< String.") } + test("Field.allMatching fills in missing fields") { + final case class Empty() + final case class FieldSource(str: String, int: Int) + + val initial = Empty() + val fieldSource = FieldSource("sourced-str", 1) + + val actual = initial.into[TestClass].transform(Field.allMatching(fieldSource)) + + val expected = TestClass("sourced-str", 1) + + assertEquals(actual, expected) + } + + test("Field.allMatching gets all the matching fields from a field source and overwrites existing ones") { + final case class FieldSource(str: String, additionalArg: List[String]) + + val initial = TestClass("str", 1) + val fieldSource = FieldSource("sourced-str", List("sourced-list")) + + val actual = initial.into[TestClassWithAdditionalList].transform(Field.allMatching(fieldSource)) + + val expected = TestClassWithAdditionalList(1, "sourced-str", List("sourced-list")) + + assertEquals(actual, expected) + } + + test("Field.allMatching only fills in fields that match by name and by type") { + final case class FieldSource(str: Int, additionalArg: List[String]) + + val initial = TestClass("str", 1) + val fieldSource = FieldSource(1, List("sourced-list")) + + val actual = initial.into[TestClassWithAdditionalList].transform(Field.allMatching(fieldSource)) + + val expected = TestClassWithAdditionalList(1, "str", List("sourced-list")) + + assertEquals(actual, expected) + } + + test("Field.allMatching works with fields that match by name and are a subtype of the expected type") { + final case class Source(int: Int, str: String) + final case class FieldSource(number: Integer, list: List[String]) + final case class Dest(int: Int, str: String, list: Seq[String], number: Number) + + val initial = Source(1, "str") + val fieldSource = FieldSource(1, List("sourced-list")) + + val actual = initial.into[Dest].transform(Field.allMatching(fieldSource)) + + val expected = Dest(1, "str", List("sourced-list"), 1) + + assertEquals(actual, expected) + } + + test("Field.allMatching reports a compiletime failure when none of the fields match") { + final case class Source(int: Int, str: String, list: List[String]) + final case class FieldSource(int: Long, str: CharSequence, list: Vector[String]) + + assertFailsToCompileWith { + """ + val source = Source(1, "str", List("list-str")) + val fieldSource = FieldSource(1L, "char-seq", Vector("vector-str")) + + source.into[Source].transform(Field.allMatching(fieldSource)) + """ + }("None of the fields from FieldSource match any of the fields from Source.") + } + test("The last applied field config is the picked one") { + final case class FieldSource(additionalArg: String) + + val fieldSource = FieldSource("str-sourced") + val expected = TestClassWithAdditionalString(1, "str", "str-computed") val actual = @@ -82,6 +155,7 @@ class AppliedBuilderSuite extends DucktapeSuite { .transform( Field.const(_.additionalArg, "FIRST"), Field.renamed(_.additionalArg, _.str), + Field.allMatching(fieldSource), Field.computed(_.additionalArg, _.str + "-computed") ) diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/macros/MakeTransformerChecker.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/macros/MakeTransformerChecker.scala index 57a691c9..45a96849 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/macros/MakeTransformerChecker.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/macros/MakeTransformerChecker.scala @@ -1,7 +1,7 @@ package io.github.arainko.ducktape.macros import io.github.arainko.ducktape.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.MakeTransformer +import io.github.arainko.ducktape.internal.modules.MakeTransformer import scala.quoted.* object MakeTransformerChecker { diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/macros/TransformerLambdaChecker.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/macros/TransformerLambdaChecker.scala index ce1a41ec..f1f2992f 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/macros/TransformerLambdaChecker.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/macros/TransformerLambdaChecker.scala @@ -1,8 +1,8 @@ package io.github.arainko.ducktape.macros import io.github.arainko.ducktape.* -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda -import io.github.arainko.ducktape.internal.modules.liftTransformation.TransformerLambda.* +import io.github.arainko.ducktape.internal.modules.TransformerLambda +import io.github.arainko.ducktape.internal.modules.TransformerLambda.* import scala.quoted.* diff --git a/project/build.properties b/project/build.properties index 8b9a0b0a..46e43a97 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.0 +sbt.version=1.8.2 diff --git a/project/plugins.sbt b/project/plugins.sbt index fa52d5b5..5fba9e88 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") -addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.9") -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.6") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.3") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") +addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.11") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.7") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "2.1.0")