diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ConfigInstructionRefiner.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ConfigInstructionRefiner.scala index aac48a1d..9c4ac60e 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ConfigInstructionRefiner.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ConfigInstructionRefiner.scala @@ -1,5 +1,6 @@ package io.github.arainko.ducktape.internal +import io.github.arainko.ducktape.* import io.github.arainko.ducktape.internal.Configuration.* private[ducktape] object ConfigInstructionRefiner { diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Context.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Context.scala index e83b6dcc..a6a1259d 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Context.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Context.scala @@ -18,7 +18,7 @@ private[ducktape] object Context { transparent inline def current(using ctx: Context): ctx.type = ctx case class PossiblyFallible[G[+x]]( - wrapperType: WrapperType.Wrapped[G], + wrapperType: WrapperType[G], transformationSite: TransformationSite, summoner: Summoner.PossiblyFallible[G], mode: TransformationMode[G] diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/FallibleTransformations.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/FallibleTransformations.scala index e2bc05b0..76e32923 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/FallibleTransformations.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/FallibleTransformations.scala @@ -19,7 +19,7 @@ private[ducktape] object FallibleTransformations { configs: Expr[Seq[Field.Fallible[F, A, B] | Case.Fallible[F, A, B]]] )(using Quotes): Expr[F[B]] = { given Context.PossiblyFallible[F]( - WrapperType.Wrapped(Type.of[F]), + WrapperType.create[F], TransformationSite.fromStringExpr(transformationSite), Summoner.PossiblyFallible[F], TransformationMode.create(F) @@ -50,7 +50,7 @@ private[ducktape] object FallibleTransformations { configs: Expr[Seq[Field.Fallible[F, A, Args] | Case.Fallible[F, A, Args]]] )(using Quotes): Expr[F[B]] = { given Context.PossiblyFallible[F]( - WrapperType.Wrapped(Type.of[F]), + WrapperType.create[F], TransformationSite.fromStringExpr(transformationSite), Summoner.PossiblyFallible[F], TransformationMode.create(F) @@ -92,7 +92,7 @@ private[ducktape] object FallibleTransformations { import quotes.reflect.* given Context.PossiblyFallible[F]( - WrapperType.Wrapped(Type.of[F]), + WrapperType.create[F], TransformationSite.Transformation, Summoner.PossiblyFallible[F], TransformationMode.create(F) diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Plan.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Plan.scala index 63faae78..862d2b30 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Plan.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Plan.scala @@ -84,13 +84,13 @@ private[ducktape] object Plan { ) extends Plan[Nothing, Nothing] case class BetweenFallibleNonFallible[+E <: Erroneous]( - source: Structure.Wrappped[?], + source: Structure.Wrapped[?], dest: Structure, plan: Plan[E, Nothing] ) extends Plan[E, Fallible] case class BetweenFallibles[+E <: Erroneous]( - source: Structure.Wrappped[?], + source: Structure.Wrapped[?], dest: Structure, mode: TransformationMode.FailFast[?], plan: Plan[E, Fallible] diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Planner.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Planner.scala index 3ac2b3a3..90bc26a1 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Planner.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Planner.scala @@ -61,6 +61,22 @@ private[ducktape] object Planner { recurse(source, paramStruct) ) + // Wrapped(WrapperType.Optional) is isomorphic to Optional + // scalafmt: { maxColumn = 150 } + case (source @ Wrapped(_, WrapperType.Optional, _, srcUnderlying)) -> (dest @ Wrapped(_, WrapperType.Optional, _, destUnderlying)) => + Plan.BetweenOptions( + Structure.Optional.fromWrapped(source), + Structure.Optional.fromWrapped(dest), + recurse(srcUnderlying, destUnderlying) + ) + + case source -> (dest @ Wrapped(_, WrapperType.Optional, _, underlying)) => + Plan.BetweenNonOptionOption( + source, + Structure.Optional.fromWrapped(dest), + recurse(source, underlying) + ) + case (source @ Collection(_, _, srcParamStruct)) -> (dest @ Collection(_, _, destParamStruct)) => Plan.BetweenCollections( source, @@ -246,8 +262,7 @@ private[ducktape] object Planner { boundary[Plan.Error | plan.type]: var owner = Symbol.spliceOwner while (!owner.isNoSymbol) { - if owner == transformerSymbol then - boundary.break(Plan.Error.from(plan, ErrorMessage.LoopingTransformerDetected, None)) + if owner == transformerSymbol then boundary.break(Plan.Error.from(plan, ErrorMessage.LoopingTransformerDetected, None)) owner = owner.maybeOwner } plan @@ -260,7 +275,7 @@ private[ducktape] object Planner { structs: (Structure, Structure) )(using Quotes, Depth, Context.Of[F]): Option[Plan[Erroneous, F]] = PartialFunction.condOpt(Context.current *: structs) { - case (ctx: Context.PossiblyFallible[f], source @ Wrappped(tpe, path, underlying), dest) => + case (ctx: Context.PossiblyFallible[f], source @ Wrapped(tpe, _, path, underlying), dest) => // needed for the recurse call to return Plan[Erroneous, Nothing] given Context.Total = ctx.toTotal val plan = Plan.BetweenFallibleNonFallible( @@ -283,7 +298,7 @@ private[ducktape] object Planner { PartialFunction.condOpt(Context.current *: structs) { case ( ctx @ Context.PossiblyFallible(_, _, _, mode: TransformationMode.FailFast[f]), - source @ Wrappped(tpe, path, underlying), + source @ Wrapped(tpe, _, path, underlying), dest ) => ctx.reifyPlan[F] { @@ -296,13 +311,8 @@ private[ducktape] object Planner { } case ( - ctx @ Context.PossiblyFallible( - WrapperType.Wrapped(given Type[f]), - _, - _, - TransformationMode.Accumulating(mode, Some(localMode)) - ), - source @ Wrappped(tpe, path, underlying), + ctx @ Context.PossiblyFallible(_, _, _, TransformationMode.Accumulating(mode, Some(localMode))), + source @ Wrapped(tpe, _, path, underlying), dest ) => ctx.reifyPlan[F] { @@ -313,7 +323,6 @@ private[ducktape] object Planner { recurse(underlying, dest) ) } - } } } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Structure.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Structure.scala index eb5f4e61..69da0100 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Structure.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/Structure.scala @@ -52,6 +52,11 @@ private[ducktape] object Structure { case class Optional(tpe: Type[? <: Option[?]], path: Path, paramStruct: Structure) extends Structure + object Optional { + def fromWrapped(wrapped: Wrapped[Option]): Optional = + Optional(wrapped.tpe, wrapped.path, wrapped.underlying) + } + case class Collection(tpe: Type[? <: Iterable[?]], path: Path, paramStruct: Structure) extends Structure case class Singleton(tpe: Type[?], path: Path, name: String, value: Expr[Any]) extends Structure @@ -60,7 +65,7 @@ private[ducktape] object Structure { case class ValueClass(tpe: Type[? <: AnyVal], path: Path, paramTpe: Type[?], paramFieldName: String) extends Structure - case class Wrappped[F[+x]](tpe: Type[? <: F[Any]], path: Path, underlying: Structure) extends Structure + case class Wrapped[F[+x]](tpe: Type[? <: F[Any]], wrapper: WrapperType[F], path: Path, underlying: Structure) extends Structure case class Lazy private (tpe: Type[?], path: Path, private val deferredStruct: () => Structure) extends Structure { lazy val struct: Structure = deferredStruct() @@ -95,10 +100,11 @@ private[ducktape] object Structure { case tpe @ '[Nothing] => Structure.Ordinary(tpe, path) - case WrapperType(wrapper: WrapperType.Wrapped[f], '[underlying]) => - @unused given Type[f] = wrapper.wrapperTpe - Structure.Wrappped( + case WrapperType(wrapper: WrapperType[f], '[underlying]) => + @unused given Type[f] = wrapper.wrapper + Structure.Wrapped( Type.of[f[underlying]], + wrapper, path, Structure.of[underlying](path.appended(Path.Segment.Element(Type.of[underlying]))) ) @@ -117,7 +123,7 @@ private[ducktape] object Structure { case tpe @ '[Any *: scala.Tuple] if !tpe.repr.isTupleN => // let plain tuples be caught later on val elements = - tupleTypeElements(tpe.repr.dealias).zipWithIndex.map { (tpe, idx) => + tupleTypeElements(tpe).zipWithIndex.map { (tpe, idx) => tpe.asType match { case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.TupleElement(Type.of[tpe], idx))) } @@ -155,7 +161,7 @@ private[ducktape] object Structure { } } if tpe.repr.isTupleN => val structures = - tupleTypeElements(TypeRepr.of[types]).zipWithIndex + tupleTypeElements(Type.of[types]).zipWithIndex .map((tpe, idx) => tpe.asType match { case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.TupleElement(Type.of[tpe], idx))) @@ -172,7 +178,7 @@ private[ducktape] object Structure { } } => val structures = - tupleTypeElements(TypeRepr.of[types]) + tupleTypeElements(Type.of[types]) .zip(constStringTuple(TypeRepr.of[labels])) .map((tpe, name) => name -> (tpe.asType match { @@ -189,7 +195,7 @@ private[ducktape] object Structure { } } => val structures = - tupleTypeElements(TypeRepr.of[types]) + tupleTypeElements(Type.of[types]) .zip(constStringTuple(TypeRepr.of[labels])) .map((tpe, name) => name -> (tpe.asType match { case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.Case(Type.of[tpe]))) }) @@ -209,21 +215,27 @@ private[ducktape] object Structure { private def constantString[Const <: String: Type](using Quotes) = Type.valueOfConstant[Const].get - private def tupleTypeElements(using Quotes)(tp: quotes.reflect.TypeRepr): List[quotes.reflect.TypeRepr] = { - import quotes.reflect.* + private def tupleTypeElements(tpe: Type[?])(using Quotes): List[quotes.reflect.TypeRepr] = { + @tailrec def loop(using Quotes)(curr: Type[?], acc: List[quotes.reflect.TypeRepr]): List[quotes.reflect.TypeRepr] = { + import quotes.reflect.* - @tailrec def loop(curr: TypeRepr, acc: List[TypeRepr]): List[TypeRepr] = curr match { - case AppliedType(pairTpe, head :: tail :: Nil) => - loop(tail, head :: acc) - case _ => + case '[head *: tail] => + loop(Type.of[tail], TypeRepr.of[head] :: acc) + case '[EmptyTuple] => acc + case other => + report.errorAndAbort( + s"Unexpected type (${other.repr.show}) encountered when extracting tuple type elems. This is a bug in ducktape." + ) } - loop(tp, Nil).reverse + } + + loop(tpe, Nil).reverse } private def constStringTuple(using Quotes)(tp: quotes.reflect.TypeRepr): List[String] = { import quotes.reflect.* - tupleTypeElements(tp).map { case ConstantType(StringConstant(l)) => l } + tupleTypeElements(tp.asType).map { case ConstantType(StringConstant(l)) => l } } } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/WrapperType.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/WrapperType.scala index c88f4603..28fc1868 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/WrapperType.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/WrapperType.scala @@ -1,13 +1,35 @@ package io.github.arainko.ducktape.internal +import io.github.arainko.ducktape.internal.Debug.AST + import scala.annotation.unused import scala.quoted.* -private[ducktape] sealed trait WrapperType { - def unapply(tpe: Type[?])(using Quotes): Option[(WrapperType, Type[?])] +private[ducktape] sealed trait WrapperType[F[+x]] { + def wrapper(using Quotes): Type[F] + + def unapply(tpe: Type[?])(using Quotes): Option[(WrapperType[F], Type[?])] } private[ducktape] object WrapperType { + def create[F[+x]: Type](using Quotes): WrapperType[F] = { + import quotes.reflect.* + + Type.of[F[Any]] match { + case '[Option[a]] => + Optional.asInstanceOf[WrapperType[F]] + case other => + Wrapped(Type.of[F]) + } + } + + given Debug[WrapperType[?]] with { + def astify(self: WrapperType[?])(using Quotes): AST = + import quotes.reflect.* + self match + case Optional => Debug.AST.Text(s"WrapperType[Option]") + case Wrapped(wrapperTpe) => Debug.AST.Text(s"WrapperType[${wrapperTpe.repr.show(using Printer.TypeReprShortCode)}]") + } def unapply(using Quotes, Context)(tpe: Type[?]) = Context.current match { @@ -15,12 +37,22 @@ private[ducktape] object WrapperType { case Context.Total(_) => None } - case object Absent extends WrapperType { - override def unapply(tpe: Type[? <: AnyKind])(using Quotes): Option[(WrapperType, Type[?])] = None + case object Optional extends WrapperType[Option] { + + def wrapper(using Quotes): Type[Option] = Type.of[Option] + + override def unapply(tpe: Type[? <: AnyKind])(using Quotes): Option[(WrapperType[Option], Type[?])] = { + tpe match { + case '[Option[underlying]] => Some(this -> Type.of[underlying]) + case _ => None + } + } } - final case class Wrapped[F[+x]](wrapperTpe: Type[F]) extends WrapperType { - override def unapply(tpe: Type[? <: AnyKind])(using Quotes): Option[(WrapperType, Type[?])] = { + final case class Wrapped[F[+x]] private[WrapperType] (wrapperTpe: Type[F]) extends WrapperType[F] { + def wrapper(using Quotes): Type[F] = wrapperTpe + + override def unapply(tpe: Type[? <: AnyKind])(using Quotes): Option[(WrapperType[F], Type[?])] = { @unused given Type[F] = wrapperTpe tpe match case '[F[underlying]] => Some(this -> Type.of[underlying]) diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue187Suite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue187Suite.scala new file mode 100644 index 00000000..be9e1fbe --- /dev/null +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue187Suite.scala @@ -0,0 +1,94 @@ +package io.github.arainko.ducktape.issues +import io.github.arainko.ducktape.* + +class Issue187Suite extends DucktapeSuite { + test("BetweenNonOptionOption works when Mode[Option] is in scope") { + + given Transformer.Fallible[Option, Int, String] = a => Some(a.toString) + + case class Source(int: Int, str: String) + case class Dest(int: Option[String], str: Option[String]) + + Mode.FailFast.option.locally { + val source = Source(1, "str") + val expected = Dest(Some("1"), Some("str")) + + assertTransformsFallible( + source, + Some(expected) + ) + assertEachEquals( + source.fallibleVia(Dest.apply), + Transformer.defineVia[Source](Dest.apply).fallible.build().transform(source) + )(Some(expected)) + } + } + + test("BetweenOptions works when Mode[Option] is in scope") { + given Transformer.Fallible[Option, Int, String] = a => Some(a.toString) + + case class Source(int: Option[Int]) + case class Dest(int: Option[String]) + + Mode.FailFast.option.locally { + val source = Source(Some(1)) + val expected = Dest(Some("1")) + + assertTransformsFallible( + source, + Some(expected) + ) + assertEachEquals( + source.fallibleVia(Dest.apply), + Transformer.defineVia[Source](Dest.apply).fallible.build().transform(source) + )(Some(expected)) + + } + } + + test("Fallible transformation for an Option works when Mode[Option] is in scope") { + given Transformer.Fallible[Option, Int, String] = a => Some(a.toString) + + case class Source(int: Option[Int]) + case class Dest(int: String) + + Mode.FailFast.option.locally { + val source = Source(Some(1)) + val expected = Dest("1") + + assertTransformsFallible( + source, + Some(expected) + ) + assertEachEquals( + source.fallibleVia(Dest.apply), + Transformer.defineVia[Source](Dest.apply).fallible.build().transform(source) + )(Some(expected)) + } + } + + test("Option-unwrapping works") { + case class Dest(int1: Int, int2: Int, int3: Int, int4: Int) + + Mode.FailFast.option.locally { + val source = + ( + Some(1), + Some(2), + Some(3), + Some(4) + ) + + val expected = Dest(1, 2, 3, 4) + + assertTransformsFallible( + source, + Some(expected) + ) + assertEachEquals( + source.fallibleVia(Dest.apply), + Transformer.defineVia[source.type](Dest.apply).fallible.build().transform(source) + )(Some(expected)) + } + } +} diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue190Suite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue190Suite.scala new file mode 100644 index 00000000..a967b971 --- /dev/null +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/issues/Issue190Suite.scala @@ -0,0 +1,25 @@ +package io.github.arainko.ducktape.issues + +import io.github.arainko.ducktape.* + +class Issue190Suite extends DucktapeSuite { + + test("transforming from concatenated together tuples works (bound to an intermediate val)") { + case class Big(int1: Int, int2: Int, int3: Int, int4: Int, int5: Int, int6: Int, int7: Int, int8: Int) + + val one = (1, 2, 3, 4) + val two = (5, 6, 7, 8) + + val joined = one ++ two + + val expected = Big(1, 2, 3, 4, 5, 6, 7, 8) + + assertTransforms(joined, expected) + assertEachEquals( + joined.via(Big.apply), + joined.intoVia(Big.apply).transform(), + Transformer.defineVia[joined.type](Big.apply).build().transform(joined) + )(expected) + } + +}