diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 241181ed4..cebee1379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,18 @@ jobs: if: matrix.scala == '2.13' && matrix.project == 'rootJVM' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' docs/mdoc + - name: Run Scala Next Tests (jvm) + if: matrix.java == 'temurin@25' && matrix.scala == '3' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestJVM/test + + - name: Run Scala Next Tests (js) + if: matrix.java == 'temurin@25' && matrix.scala == '3' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestJS/test + + - name: Run Scala Next Tests (native) + if: matrix.java == 'temurin@25' && matrix.scala == '3' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestNative/test + - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) run: mkdir -p unsafe/.js/target generic/.jvm/target law/.js/target macro/.jvm/target unsafe/.native/target state/.jvm/target core/native/target unsafe/.jvm/target macro/.native/target core/js/target macro/.js/target law/.jvm/target generic/.js/target core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target generic/.native/target project/target @@ -297,7 +309,7 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: monocle-test_2.13 monocle-test_3 monocle-test_native0.5_2.13 monocle-test_native0.5_3 rootjs_2.13 rootjs_3 monocle-docs_2.13 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 monocle-test_sjs1_2.13 monocle-test_sjs1_3 monocle-example_2.13 monocle-example_3 monocle-bench_2.13 monocle-bench_3 + modules-ignore: monocle-test_2.13 monocle-test_3 monocle-test_native0.5_2.13 monocle-test_native0.5_3 rootjs_2.13 rootjs_3 monocle-docs_2.13 scalanexttest_3 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 scalanexttest_native0.5_3 scalanexttest_sjs1_3 monocle-test_sjs1_2.13 monocle-test_sjs1_3 monocle-example_2.13 monocle-example_3 monocle-bench_2.13 monocle-bench_3 configs-ignore: test scala-tool scala-doc-tool test-internal validate-steward: diff --git a/build.sbt b/build.sbt index cca453e6f..624b062b3 100644 --- a/build.sbt +++ b/build.sbt @@ -22,11 +22,20 @@ inThisBuild( scalaVersion := scala2Version, crossScalaVersions := Seq(scala2Version, scala3Version), tlCiScalafmtCheck := true, - githubWorkflowBuild += WorkflowStep.Sbt( - List("docs/mdoc"), - name = Some("Run documentation"), - cond = Some(s"matrix.scala == '2.13' && matrix.project == 'rootJVM'") - ), + githubWorkflowBuild ++= + Vector( + WorkflowStep.Sbt( + List("docs/mdoc"), + name = Some("Run documentation"), + cond = Some(s"matrix.scala == '2.13' && matrix.project == 'rootJVM'") + ) + ) ++ scalaNextTest.projects.map { case (platform, project) => + WorkflowStep.Sbt( + List(s"${project.id}/test"), + name = Some(s"Run Scala Next Tests (${platform.identifier})"), + cond = Some(s"matrix.java == 'temurin@25' && matrix.scala == '3'") + ) + }, githubWorkflowJavaVersions := Seq( JavaSpec.temurin("11"), JavaSpec.temurin("25") @@ -38,7 +47,6 @@ inThisBuild( ) ) ) - lazy val kindProjector = "org.typelevel" % "kind-projector" % "0.13.4" cross CrossVersion.full lazy val buildSettings = Seq( @@ -105,9 +113,10 @@ lazy val buildSettings = Seq( } ) -lazy val catsVersion = "2.13.0" -lazy val scala2Version = "2.13.18" -lazy val scala3Version = "3.3.8" +lazy val catsVersion = "2.13.0" +lazy val scala2Version = "2.13.18" +lazy val scala3Version = "3.3.8" +lazy val scalaNextVersion = "3.8.4" lazy val cats = Def.setting("org.typelevel" %%% "cats-core" % catsVersion) lazy val catsFree = Def.setting("org.typelevel" %%% "cats-free" % catsVersion) @@ -303,6 +312,18 @@ lazy val test = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) ) +lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .dependsOn(test % "test->test") + .jvmSettings(monocleJvmSettings) + .jsSettings(monocleJsSettings) + .nativeSettings(monocleNativeSettings) + .enablePlugins(NoPublishPlugin) + .settings( + crossScalaVersions := Seq(scalaNextVersion), + libraryDependencies ++= Seq(munitDiscipline.value), + scalacOptions --= Seq("-release:8", "-Ykind-projector") + ) + lazy val bench = project .dependsOn(core.jvm, generic.jvm, macros.jvm) .settings(moduleName := "monocle-bench") diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/FocusBase.scala b/core/shared/src/main/scala-3/monocle/internal/focus/FocusBase.scala index 8a68bbaca..eec8ec599 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/FocusBase.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/FocusBase.scala @@ -1,6 +1,7 @@ package monocle.internal.focus -import scala.quoted.Quotes +import scala.quoted.* +import scala.annotation.tailrec private[focus] trait FocusBase { val macroContext: Quotes @@ -11,6 +12,8 @@ private[focus] trait FocusBase { type TypeRepr = macroContext.reflect.TypeRepr type Position = macroContext.reflect.Position + import macroContext.reflect.* + case class LambdaConfig(argName: String, lambdaBody: Term) enum FocusAction { @@ -22,6 +25,14 @@ private[focus] trait FocusBase { fromCompanion: Term, toType: TypeRepr ) + case SelectNamedTupleField( + fieldName: String, + fromDescription: NamedTuples.Description, + toType: TypeRepr, + // storing the whole NamedTuples helper class might feel weird but it's the best way one can describe that this focus action is only possible + // when named tuples are actually supported (i.e. on Scala > 3.7.x) + namedTuples: NamedTuples + ) case KeywordSome(toType: TypeRepr) case KeywordAs(fromType: TypeRepr, toType: TypeRepr) case KeywordEach(fromType: TypeRepr, toType: TypeRepr, eachInstance: Term) @@ -34,6 +45,8 @@ private[focus] trait FocusBase { s"SelectField($fieldName, ${fromType.show}, ${fromTypeArgs.map(_.show)}, ${toType.show})" case SelectOnlyField(fieldName, fromType, fromTypeArgs, _, toType) => s"SelectOnlyField($fieldName, ${fromType.show}, ${fromTypeArgs.map(_.show)}, ..., ${toType.show})" + case SelectNamedTupleField(fieldName, fromType, toType, _) => + s"SelectNamedTupleField($fieldName, ${fromType.show}, ${toType.show})" case KeywordSome(toType) => s"KeywordSome(${toType.show})" case KeywordAs(fromType, toType) => s"KeywordAs(${fromType.show}, ${toType.show})" case KeywordEach(fromType, toType, _) => s"KeywordEach(${fromType.show}, ${toType.show}, ...)" @@ -58,4 +71,108 @@ private[focus] trait FocusBase { } type FocusResult[+A] = Either[FocusError, A] + + // unappliedNamedTuple is the type lambda [Names, Values] =>> NamedTuple[Names, Values], used to harvest its type symbol later on + final class NamedTuples private (private val unappliedNamedTuple: TypeRepr, val companion: Symbol) { + def isNamedTuple(tpe: TypeRepr) = + tpe.dealias.typeSymbol == unappliedNamedTuple.typeSymbol + + // a call to NamedTuple.toTuple[Names <: Tuple, Values <: Tuple](tup: NamedTuple.NamedTuple[Names, Values]): Values + def toTuple(term: Term, description: NamedTuples.Description) = + Select + .unique(Ident(companion.termRef), "toTuple") + .appliedToTypes(description.namesTpe :: description.valuesTpe :: Nil) + .appliedTo(term) + + def accessFieldByName(term: Term, action: FocusAction.SelectNamedTupleField): Term = { + val idxOfName = action.fromDescription.names.indexOf(action.fieldName) + val asTuple = toTuple(term, action.fromDescription) + unsafeAccessFieldByIndex(asTuple, action.fromDescription, idxOfName) + } + + def reconstructWithUpdatedField(from: Term, action: FocusAction.SelectNamedTupleField, updatedValue: Term) = { + val updatedFieldIdx = action.fromDescription.names.indexOf(action.fieldName) + val asTuple = toTuple(from, action.fromDescription) + val values = + Vector.tabulate(action.fromDescription.values.size) { idx => + if (idx == updatedFieldIdx) updatedValue.asExpr + else unsafeAccessFieldByIndex(asTuple, action.fromDescription, idx).asExpr + } + construct(action.fromDescription, values) + } + + // NamedTuple >: Tuple so to 'construct' a named tuple we can just upcast an ordinary Tuple to a NamedTuple + def construct(description: NamedTuples.Description, values: Seq[Expr[Any]]): Term = + Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType)) + + // there's a chance that we're operating on a non-normalized (non TupleN) tuple (for example when N is > 22 or when using NamedTuple.From) + // in which case we need to fall back to using Product methods since TupleXXL <: scala.Product and '*:' (tuple cons) <: Product AND doesn't get _N accessors + private def unsafeAccessFieldByIndex(asTuple: Term, description: NamedTuples.Description, index: Int) = { + val tupleAccessor = s"_${index + 1}" + + if (asTuple.tpe.typeSymbol.fieldMember(tupleAccessor).exists) { + Select.unique(asTuple, tupleAccessor) + } else { + val tpeAtIndex = description.values(index) + (asTuple.asExpr, tpeAtIndex.asType) match { + case '{ $prod: scala.Product } -> '[tpe] => + '{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm + } + } + } + + def describe(sourceType: TypeRepr): Option[NamedTuples.Description] = + sourceType.dealias.simplified match { + case tpe @ AppliedType(_, namesTpe :: valuesTpe :: Nil) if isNamedTuple(tpe) => + Some( + NamedTuples.Description( + unrollStrings(namesTpe), + unroll(valuesTpe), + sourceType, + namesTpe, + valuesTpe + ) + ) + case _ => None + } + + private def unrollStrings(tp: TypeRepr): Vector[String] = + unroll(tp).map { case ConstantType(StringConstant(l)) => l } + + private def unroll(tpe: TypeRepr): Vector[TypeRepr] = { + @tailrec def loop(curr: Type[?], acc: Vector[TypeRepr]): Vector[TypeRepr] = + curr match { + case '[head *: tail] => + loop(Type.of[tail], acc.appended(TypeRepr.of[head])) + case '[EmptyTuple] => + acc + } + + loop(tpe.asType, Vector.empty) + } + + } + + object NamedTuples { + val Support: Option[NamedTuples] = { + val companion = Symbol.requiredModule("scala.NamedTuple") + + companion + .declaredType("NamedTuple") + .headOption + .map(sym => NamedTuples(sym.typeRef, companion)) + } + + case class Description private[NamedTuples] ( + names: Vector[String], + values: Vector[TypeRepr], + sourceType: TypeRepr, + namesTpe: TypeRepr, + valuesTpe: TypeRepr + ) { + def show: String = + s"Description($names, ${values.map(_.show)}, ${sourceType.show}, ${namesTpe.show}, ${valuesTpe.show})" + } + } + } diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/GeneratorLoop.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/GeneratorLoop.scala index 6a2cccbe3..d0fc6b1ef 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/GeneratorLoop.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/GeneratorLoop.scala @@ -2,6 +2,7 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase import monocle.internal.focus.features.selectfield.SelectFieldGenerator +import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldGenerator import monocle.internal.focus.features.selectonlyfield.SelectOnlyFieldGenerator import monocle.internal.focus.features.some.SomeGenerator import monocle.internal.focus.features.as.AsGenerator @@ -16,6 +17,7 @@ private[focus] trait AllFeatureGenerators extends FocusBase with SelectFieldGenerator with SelectOnlyFieldGenerator + with SelectNamedTupleFieldGenerator with SomeGenerator with AsGenerator with EachGenerator @@ -38,14 +40,15 @@ private[focus] trait GeneratorLoop { private def generateActionCode(action: FocusAction): Term = action match { - case a: FocusAction.SelectField => generateSelectField(a) - case a: FocusAction.SelectOnlyField => generateSelectOnlyField(a) - case a: FocusAction.KeywordSome => generateSome(a) - case a: FocusAction.KeywordAs => generateAs(a) - case a: FocusAction.KeywordEach => generateEach(a) - case a: FocusAction.KeywordAt => generateAt(a) - case a: FocusAction.KeywordIndex => generateIndex(a) - case a: FocusAction.KeywordWithDefault => generateWithDefault(a) + case a: FocusAction.SelectField => generateSelectField(a) + case a: FocusAction.SelectOnlyField => generateSelectOnlyField(a) + case a: FocusAction.SelectNamedTupleField => generateSelectNamedTupleField(a) + case a: FocusAction.KeywordSome => generateSome(a) + case a: FocusAction.KeywordAs => generateAs(a) + case a: FocusAction.KeywordEach => generateEach(a) + case a: FocusAction.KeywordAt => generateAt(a) + case a: FocusAction.KeywordIndex => generateIndex(a) + case a: FocusAction.KeywordWithDefault => generateWithDefault(a) } private def composeOptics(lens1: Term, lens2: Term): FocusResult[Term] = diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserBase.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserBase.scala index e7f8fafef..10315aed3 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserBase.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserBase.scala @@ -15,7 +15,9 @@ private[focus] trait ParserBase { def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] } + // the '.simplified' call here is needed because otherwise if an unreduced match type arrives at this call site we're greeted with a compiler barf, like: + // 'Cannot get type of value [...]' (note that this is especially important for terms that describe a named tuple field access which is typed as 'Elem[NamedTuple[N, V], n.type]' which IS a match type). def getType(code: Term): TypeRepr = - code.tpe.widen.dealias + code.tpe.widen.dealias.simplified } diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserLoop.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserLoop.scala index fcdd9f741..142e1b88f 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserLoop.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserLoop.scala @@ -2,6 +2,7 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase import monocle.internal.focus.features.selectfield.SelectFieldParser +import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldParser import monocle.internal.focus.features.selectonlyfield.SelectOnlyFieldParser import monocle.internal.focus.features.some.SomeParser import monocle.internal.focus.features.as.AsParser @@ -16,6 +17,7 @@ private[focus] trait AllFeatureParsers with KeywordParserBase with SelectFieldParser with SelectOnlyFieldParser + with SelectNamedTupleFieldParser with SomeParser with AsParser with EachParser @@ -58,6 +60,9 @@ private[focus] trait ParserLoop { case SelectField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) case SelectField(Left(error)) => Left(error) + case SelectNamedTupleField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) + case SelectNamedTupleField(Left(error)) => Left(error) + case unexpected => FocusError.UnexpectedCodeStructure(unexpected.toString).asResult } loop(RemainingCode(config.lambdaBody), Nil) diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/SelectParserBase.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/SelectParserBase.scala index 316308453..f177331bc 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/SelectParserBase.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/SelectParserBase.scala @@ -10,7 +10,7 @@ private[focus] trait SelectParserBase extends ParserBase { // Match on a term that is an instance of a case class object CaseClass { def unapply(term: Term): Option[Term] = - term.tpe.classSymbol.flatMap { sym => + term.tpe.simplified.classSymbol.flatMap { sym => Option.when(sym.flags.is(Flags.Case))(term) } } diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldGenerator.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldGenerator.scala new file mode 100644 index 000000000..abcc2836f --- /dev/null +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldGenerator.scala @@ -0,0 +1,41 @@ +package monocle.internal.focus.features.selectfield + +import monocle.internal.focus.FocusBase +import monocle.Lens +import monocle.Iso +import scala.quoted.Quotes +import scala.quoted.Expr + +private[focus] trait SelectNamedTupleFieldGenerator { + this: FocusBase => + + import macroContext.reflect.* + + def generateSelectNamedTupleField(action: FocusAction.SelectNamedTupleField): Term = { + def generateGetter(from: Term): Term = action.namedTuples.accessFieldByName(from, action) + + def generateSetter(from: Term, to: Term): Term = + action.namedTuples.reconstructWithUpdatedField(from, action, to) + + def generateReverseGet(to: Expr[Any]): Term = + action.namedTuples.construct(action.fromDescription, Vector(to)) + + (action.fromDescription.sourceType.asType, action.toType.asType) match { + case ('[f], '[t]) => + if (action.fromDescription.values.size == 1) { + '{ + Iso.apply[f, t]((from: f) => ${ generateGetter('from.asTerm).asExprOf[t] })((to: t) => + ${ generateReverseGet('to).asExprOf[f] } + ) + }.asTerm + } else { + '{ + Lens.apply[f, t]((from: f) => ${ generateGetter('from.asTerm).asExprOf[t] })((to: t) => + (from: f) => ${ generateSetter('from.asTerm, 'to.asTerm).asExprOf[f] } + ) + }.asTerm + } + } + + } +} diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldParser.scala new file mode 100644 index 000000000..50bb95cc8 --- /dev/null +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldParser.scala @@ -0,0 +1,32 @@ +package monocle.internal.focus.features.selectfield + +import monocle.internal.focus.FocusBase +import monocle.internal.focus.features.SelectParserBase + +private[focus] trait SelectNamedTupleFieldParser { + this: FocusBase & SelectParserBase => + + import this.macroContext.reflect.* + + object SelectNamedTupleField extends FocusParser { + + def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] = + NamedTuples.Support.flatMap { namedTuples => + term match { + // the compiler expands a call like 'someNamedTuple.someField' to a call on NamedTuple.apply[Names, Values](someNamedTuple)(idx) where idx == index of 'someField' in the Names tuple + case Apply( + Apply(TypeApply(Select(ident, "apply"), _), remainingCode :: Nil), + Literal(IntConstant(fieldIndex)) :: Nil + ) if ident.symbol == namedTuples.companion => + for { + description <- namedTuples.describe(getType(remainingCode)) + fieldType <- description.values.lift(fieldIndex) + fieldName <- description.names.lift(fieldIndex) + action = FocusAction.SelectNamedTupleField(fieldName, description, fieldType, namedTuples) + } yield Right(RemainingCode(remainingCode) -> action) + case _ => None + } + } + } + +} diff --git a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala new file mode 100644 index 000000000..94413152b --- /dev/null +++ b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala @@ -0,0 +1,330 @@ +package monocle.focus + +import monocle.Focus.* +import monocle.Focus +import monocle.Iso + +final class NamedTupleFocusTest extends munit.FunSuite { + + test("Applied focus returning an Optional in nested named tuples") { + // works when type of address is widened from Some to Option + // the macro cannot find a matching overload otherwise - note that this also happens in a normal .focus invocation on case classes when a type is described as Some instead of Option + val elise = (name = "Elise", address = Option((streetNumber = 12, postcode = "high street"))) + + val streetNumber = elise.focus(_.address.some.streetNumber).getOption + val newElise = elise.focus(_.address.some.streetNumber).replace(50) + + assertEquals(streetNumber, Some(12)) + assertEquals(newElise, (name = "Elise", address = Some((streetNumber = 50, postcode = "high street")))) + } + + test("Applied focus returning an Optional with a named tuple inside a case class") { + case class User(name: String, address: Option[Address]) + type Address = (streetNumber: Int, postcode: String) + + val elise = User("Elise", Some((streetNumber = 12, postcode = "high street"))) + + val streetNumber = elise.focus(_.address.some.streetNumber).getOption + val newElise = elise.focus(_.address.some.streetNumber).replace(50) + + assertEquals(streetNumber, Some(12)) + assertEquals(newElise, User("Elise", Some((50, "high street")))) + } + + test("Applied focus returning an Optional with a case class inside a named tuple") { + case class Address(streetNumber: Int, postcode: String) + + val elise = (name = "Elise", address = Option(Address(12, "high street"))) + + val streetNumber = elise.focus(_.address.some.streetNumber).getOption + val newElise = elise.focus(_.address.some.streetNumber).replace(50) + + assertEquals(streetNumber, Some(12)) + assertEquals(newElise, (name = "Elise", address = Some(Address(50, "high street")))) + } + + test("Applied focus returning an Optional with NamedTuple.From") { + case class User[A](name: String, address: A) + case class Address(streetNumber: Int, postcode: String) + + type Bob = NamedTuple.From[User[Option[NamedTuple.From[Address]]]] + + val bob: Bob = ( + name = "Bob", + address = Option(streetNumber = 5, postcode = "Bob St") + ) + + val streetNumber = bob.focus(_.address.some.streetNumber).getOption + val newBob = bob.focus(_.address.some.streetNumber).replace(77) + + assertEquals(streetNumber, Some(5)) + assertEquals( + newBob, + ( + name = "Bob", + address = Option(streetNumber = 77, postcode = "Bob St") + ) + ) + } + + test("Applied focus returning a Lens in nested named tuples") { + val bob = ( + name = "Bob", + address = (streetNumber = 5, postcode = "Bob St") + ) + + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) + + assertEquals(streetNumber, 5) + assertEquals(newBob, (name = "Bob", address = (streetNumber = 77, postcode = "Bob St"))) + } + + test("Applied focus returning a Lens with a named tuple inside a case class") { + case class User(name: String, address: Address) + type Address = (streetNumber: Int, postcode: String) + + val bob = User("Bob", (streetNumber = 5, postcode = "Bob St")) + + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) + + assertEquals(streetNumber, 5) + assertEquals(newBob, User("Bob", (streetNumber = 77, postcode = "Bob St"))) + } + + test("Applied focus returning a Lens with a case class inside a named tuple") { + case class Address(streetNumber: Int, postcode: String) + type User = (name: String, address: Address) + val bob: User = ( + name = "Bob", + address = Address(5, "Bob St") + ) + + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) + + assertEquals(streetNumber, 5) + assertEquals(newBob, (name = "Bob", address = Address(77, "Bob St"))) + } + + test("Applied focus returning a Lens with NamedTuple.From") { + case class User[A](name: String, address: A) + case class Address(streetNumber: Int, postcode: String) + + type Bob = NamedTuple.From[User[NamedTuple.From[Address]]] + + val bob: Bob = ( + name = "Bob", + address = (streetNumber = 5, postcode = "Bob St") + ) + + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) + + assertEquals(streetNumber, 5) + assertEquals( + newBob, + ( + name = "Bob", + address = (streetNumber = 77, postcode = "Bob St") + ) + ) + + } + + test("Each on a named tuple field") { + type School = (name: String, students: List[Student]) + type Student = (firstName: String, lastName: String, yearLevel: Int) + + val school: School = ( + name = "Sparkvale Primary School", + students = List( + (firstName = "Arlen", lastName = "Appleby", yearLevel = 5), + (firstName = "Bob", lastName = "Bobson", yearLevel = 6), + (firstName = "Carol", lastName = "Cornell", yearLevel = 7) + ) + ) + + val studentNames = Focus[School](_.students.each.firstName) + + assertEquals(studentNames.getAll(school), List("Arlen", "Bob", "Carol")) + } + + type Fub = (bab: Int) + def Fub(bab: Int): Fub = (bab = bab) + + type Bar = (fub: Fub) + def Bar(fub: Fub): Bar = (fub = fub) + + type Foo = (bar: Option[Bar]) + def Foo(bar: Option[Bar]) = (bar = bar) + + type Qux = (foo: Either[String, Foo], moo: Map[Int, Fub]) + def Qux(foo: Either[String, Foo], moo: Map[Int, Fub]) = (foo = foo, moo = moo) + + type Animal = (name: String) + def Animal(name: String): Animal = (name = name) + + type Owner = (pet: Animal) + def Owner(pet: Animal): Owner = (pet = pet) + + type Shop = (owner: Owner) + def Shop(owner: Owner): Shop = (owner = owner) + + type Box[A] = (a: A) + def Box[A](a: A): Box[A] = (a = a) + + type MultiBox[A, B] = (a: A, b: B) + def MultiBox[A, B](a: A, b: B): MultiBox[A, B] = (a = a, b = b) + + type HigherBox[F[_], A] = (fa: F[A]) + def HigherBox[F[_], A](fa: F[A]): HigherBox[F, A] = (fa = fa) + + type UnionBox[A, B] = (aOrB: A | B) + def UnionBox[A, B](aOrB: A | B): UnionBox[A, B] = (aOrB = aOrB) + + type ConstraintBox[A <: AnyVal] = (a: A) + def ConstraintBox[A <: AnyVal](a: A): ConstraintBox[A] = (a = a) + + test("Single field access") { + assertEquals( + Focus[Animal](_.name).get(Animal("Bob")), + "Bob" + ) + } + + test("Single field access (no type alias)") { + assertEquals( + Focus[(name: String)](_.name).get(Animal("Bob")), + "Bob" + ) + } + + test("Nested field access") { + assertEquals( + Focus[Shop](_.owner.pet.name).get(Shop(Owner(Animal("Fred")))), + "Fred" + ) + } + + test("Type parameter field access") { + assertEquals( + Focus[Box[String]](_.a).get(Box("Hello")), + "Hello" + ) + } + + test("Type parameter set field") { + assertEquals( + Focus[Box[Int]](_.a).replace(111)(Box(222)), + Box(111) + ) + } + + test("Nested type parameter set field") { + assertEquals( + Focus[Box[Box[String]]](_.a.a).replace("hello")(Box(Box("ok"))), + Box(Box("hello")) + ) + } + + test("Multiple type parameters get field") { + assertEquals( + Focus[MultiBox[Int, Boolean]](_.b).get(MultiBox(222, true)), + true + ) + } + + test("Multiple type parameters set field") { + assertEquals( + Focus[MultiBox[String, Int]](_.a).replace("abc")(MultiBox("whatevs", 222)), + MultiBox("abc", 222) + ) + } + + test("Higher kinded type parameter get field") { + assertEquals( + Focus[HigherBox[Option, Int]](_.fa).get(HigherBox(Some(23))), + Some(23) + ) + } + + test("Single field should be an Iso") { + val iso: Iso[Animal, String] = Focus[Animal](_.name) + assertEquals(iso.get(Animal("Bob")), "Bob") + assertEquals(iso.reverseGet("Bob"), Animal("Bob")) + } + + test("Type alias for parameterised type access") { + type CC[T] = (t: T, i: Int) + type CCInt = CC[Int] + val cc: CCInt = (t = 2, i = 3) + + assertEquals(Focus[CCInt](_.i).get(cc), 3) + assertEquals(Focus[CCInt](_.t).get(cc), 2) + } + + test("Focus on a XXL named tuple works") { + val bigTuple = ( + field0 = 0, + field1 = 1, + field2 = 2, + field3 = 3, + field4 = 4, + field5 = 5, + field6 = 6, + field7 = 7, + field8 = 8, + field9 = 9, + field10 = 10, + field11 = 11, + field12 = 12, + field13 = 13, + field14 = 14, + field15 = 15, + field16 = 16, + field17 = 17, + field18 = 18, + field19 = 19, + field20 = 20, + field21 = 21, + field22 = 22, + field23 = 23, + field24 = 24 + ) + + val expected = ( + field0 = 0, + field1 = 1, + field2 = 2, + field3 = 3, + field4 = 4, + field5 = 5, + field6 = 6, + field7 = 7, + field8 = 8, + field9 = 9, + field10 = 100, + field11 = 11, + field12 = 12, + field13 = 13, + field14 = 14, + field15 = 15, + field16 = 16, + field17 = 17, + field18 = 18, + field19 = 19, + field20 = 20, + field21 = 21, + field22 = 22, + field23 = 23, + field24 = 24 + ) + + val updated = bigTuple.focus(_.field10).replace(100) + + assertEquals(updated, expected) + } + +} diff --git a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleLawTest.scala b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleLawTest.scala new file mode 100644 index 000000000..7add0e6a3 --- /dev/null +++ b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleLawTest.scala @@ -0,0 +1,25 @@ +package monocle.focus + +import monocle.MonocleSuite +import monocle.law.discipline.LensTests +import monocle.law.discipline.IsoTests +import monocle.Focus +import org.scalacheck.Arbitrary +import cats.Eq +import cats.syntax.all.* + +final class NamedTupleLawTest extends MonocleSuite { + + given namedTupleArbitrary[Names <: Tuple, Values <: Tuple](using + Values: Arbitrary[Values] + ): Arbitrary[NamedTuple.NamedTuple[Names, Values]] = + Arbitrary(Values.arbitrary.map(identity)) + + given namedTupleEq[Names <: Tuple, Values <: Tuple](using + Values: Eq[Values] + ): Eq[NamedTuple.NamedTuple[Names, Values]] = + Values.contramap(_.toTuple) + + checkAll("Focus named tuple field", LensTests(Focus[(a: Int, b: String)](_.a))) + checkAll("Focus single-field named tuple", IsoTests(Focus[(a: Int)](_.a))) +}