Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,23 @@ private[ducktape] object PathSelector {
_,
Typed(term, tpe @ Applied(TypeIdent("Elem"), _))
) =>
recurse(acc.prepended(Path.Segment.TupleElement(tpe.tpe.asType, index)), tree)
Logger.debug(s"Matching positional tuple .apply($index)")

recurse(acc.prepended(Path.Segment.TupleElement(tpe.tpe.widen.simplified.asType, index)), tree)

case tr @ Inlined(
Some(
Apply(
Apply(TypeApply(Select(Ident("NamedTuple"), "apply"), List(namesTpe, _)), List(tree)),
List(Literal(IntConstant(idx)))
)
),
_,
tpe
) =>
Logger.debug(s"Matching named tuple field access (index: ${idx}), ${namesTpe.show}")
val name = Tuples.unrollStrings(namesTpe.tpe)(idx) // .apply on a List... not great but eh
recurse(acc.prepended(Path.Segment.Field(tpe.tpe.asType, name)), tree)

case Inlined(_, _, tree) =>
Logger.debug("Matched 'Inlined', recursing...")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package io.github.arainko.ducktape.internal

import io.github.arainko.ducktape.internal.*
import io.github.arainko.ducktape.internal.Structure.*
import io.github.arainko.ducktape.internal.Structure.Product.Kind

import scala.annotation.{ tailrec, unused }
import scala.annotation.unused
import scala.collection.immutable.VectorMap
import scala.deriving.Mirror
import scala.quoted.*
Expand Down Expand Up @@ -36,17 +37,16 @@ private[ducktape] object Structure {

private var cachedDefaults: Map[String, Expr[Any]] = null

// TODO: check return Map.empty when named tuple
def defaults(using Quotes): Map[String, Expr[Any]] =
if cachedDefaults != null then cachedDefaults
else {
cachedDefaults = Defaults.of(this)
cachedDefaults = if kind == Kind.CaseClass then Defaults.of(this) else Map.empty
cachedDefaults
}
}

object Product {
enum Kind {
enum Kind derives Debug {
case CaseClass
case NamedTuple(erasedTupleTpe: Type[?])
}
Expand Down Expand Up @@ -136,11 +136,15 @@ private[ducktape] object Structure {

case tpe @ '[Any *: scala.Tuple] if !tpe.repr.isTupleN => // let plain tuples be caught later on
val elements =
tupleTypeElements(tpe).zipWithIndex.map { (tpe, idx) =>
tpe.asType match {
case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.TupleElement(Type.of[tpe], idx)))
Tuples
.unroll(tpe)
.zipWithIndex
.map { (tpe, idx) =>
tpe.asType match {
case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.TupleElement(Type.of[tpe], idx)))
}
}
}.toVector
.toVector
Structure.Tuple(Type.of[A], path, elements, isPlain = false)

case tpe =>
Expand Down Expand Up @@ -174,7 +178,9 @@ private[ducktape] object Structure {
}
} if tpe.repr.isTupleN =>
val structures =
tupleTypeElements(Type.of[types]).zipWithIndex
Tuples
.unroll(Type.of[types])
.zipWithIndex
.map((tpe, idx) =>
tpe.asType match {
case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.TupleElement(Type.of[tpe], idx)))
Expand All @@ -186,52 +192,39 @@ private[ducktape] object Structure {

case '{
$m: Mirror.Product {
type MirroredLabel = "NamedTuple"
type MirroredElemLabels = labels
type MirroredElemTypes = types
}
} if Type.of[A].repr.dealias.typeSymbol.fullName == "scala.NamedTuple$.NamedTuple" =>
} =>

val typeElems = tupleTypeElements(Type.of[types])
val normalizedErasedTupleTpe = rollupTuple(typeElems.toVector)
val typeElems = Tuples.unroll(Type.of[types])
val structures =
typeElems
.zip(constStringTuple(TypeRepr.of[labels]))
.zip(Tuples.unrollStrings(TypeRepr.of[labels]))
.map((tpe, name) =>
name -> (tpe.asType match {
case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.Field(Type.of[tpe], name)))
})
)
.to(VectorMap)

Structure.Product(Type.of[A], path, structures, Structure.Product.Kind.NamedTuple(normalizedErasedTupleTpe))

case '{
$m: Mirror.Product {
type MirroredElemLabels = labels
type MirroredElemTypes = types
}
} =>
val structures =
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.Field(Type.of[tpe], name)))
})
)
.to(VectorMap)
val kind =
if Type.of[A].repr.dealias.typeSymbol.fullName == "scala.NamedTuple$.NamedTuple" then {
val normalizedErasedTupleTpe = Tuples.rollup(typeElems.toVector)
Structure.Product.Kind.NamedTuple(normalizedErasedTupleTpe)
} else Structure.Product.Kind.CaseClass

Structure.Product(Type.of[A], path, structures, Structure.Product.Kind.CaseClass)
Structure.Product(Type.of[A], path, structures, kind)
case '{
$m: Mirror.Sum {
type MirroredElemLabels = labels
type MirroredElemTypes = types
}
} =>
val structures =
tupleTypeElements(Type.of[types])
.zip(constStringTuple(TypeRepr.of[labels]))
Tuples
.unroll(Type.of[types])
.zip(Tuples.unrollStrings(TypeRepr.of[labels]))
.map((tpe, name) =>
name -> (tpe.asType match { case '[tpe] => Lazy.of[tpe](path.appended(Path.Segment.Case(Type.of[tpe]))) })
)
Expand All @@ -249,48 +242,4 @@ private[ducktape] object Structure {
}

private def constantString[Const <: String: Type](using Quotes) = Type.valueOfConstant[Const].get

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.*

curr match {
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(tpe, Nil).reverse
}

private def constStringTuple(using Quotes)(tp: quotes.reflect.TypeRepr): List[String] = {
import quotes.reflect.*
tupleTypeElements(tp.asType).map { case ConstantType(StringConstant(l)) => l }
}

private def rollupTuple(using Quotes)(elements: Vector[quotes.reflect.TypeRepr]) = {
import quotes.reflect.*

elements.size match {
case 0 => Type.of[EmptyTuple]
case 1 =>
elements.head.asType.match { case '[tpe] => Type.of[Tuple1[tpe]] }
case size if size <= 22 =>
defn
.TupleClass(size)
.typeRef
.appliedTo(elements.toList)
.asType
case _ =>
val TupleCons = TypeRepr.of[*:]
val tpe = elements.foldRight(TypeRepr.of[EmptyTuple])((curr, acc) => TupleCons.appliedTo(curr :: acc :: Nil))
tpe.asType
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.github.arainko.ducktape.internal

import scala.annotation.tailrec
import scala.quoted.*

private[ducktape] object Tuples {
def unroll(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.*

curr match {
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(tpe, Nil).reverse
}

def unrollStrings(using Quotes)(tp: quotes.reflect.TypeRepr): List[String] = {
import quotes.reflect.*
unroll(tp.asType).map { case ConstantType(StringConstant(l)) => l }
}

def rollup(using Quotes)(elements: Vector[quotes.reflect.TypeRepr]) = {
import quotes.reflect.*

elements.size match {
case 0 => Type.of[EmptyTuple]
case 1 =>
elements.head.asType.match { case '[tpe] => Type.of[Tuple1[tpe]] }
case size if size <= 22 =>
defn
.TupleClass(size)
.typeRef
.appliedTo(elements.toList)
.asType
case _ =>
val TupleCons = TypeRepr.of[*:]
val tpe = elements.foldRight(TypeRepr.of[EmptyTuple])((curr, acc) => TupleCons.appliedTo(curr :: acc :: Nil))
tpe.asType
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,39 @@ class NamedTupleSuite extends DucktapeSuite {
(INT = 1, STR = "str")
)(Field.modifyDestNames(_.toLowerCase))
}

test("path selectors on named tuples work") {
assertTransformsConfigured(
(toplevel = (level1 = (level2 = 1))),
(toplevel = (level1 = (level2 = 1, field = 2)))
)(
Field.const(_.toplevel.level1.field, 2)
)
}

test("path selectors on named tuples inside case classes work") {
case class Source(field1: (field2: (field3: Int)))
case class Dest(field1: (field2: (field3: Int, additionalField: Int)))

assertTransformsConfigured(
Source((field2 = (field3 = 3))),
Dest((field2 = (field3 = 3, additionalField = 1)))
)(
Field.const(_.field1.field2.additionalField, 1)
)
}

test("Field.allMatching with a named tuple source works") {
case class Empty()
case class TestClass(str: String, int: Int)

val fieldSource = (str = "sourced-str", int = 1)

assertTransformsConfigured(
Empty(),
TestClass("sourced-str", 1)
)(
Field.allMatching(fieldSource)
)
}
}