From 531f0845ae314fdac004f788e5ee5bd3d61e9ca6 Mon Sep 17 00:00:00 2001 From: arainko Date: Thu, 16 Jul 2026 23:34:50 +0200 Subject: [PATCH 01/25] setup build for editing --- build.sbt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index 6432b413d..9c05dc7cf 100644 --- a/build.sbt +++ b/build.sbt @@ -19,7 +19,7 @@ inThisBuild( Developer(username, fullName, s"@$username", url(s"https://github.com/$username")) }, run / fork := true, - scalaVersion := scala2Version, + scalaVersion := scala3Version, crossScalaVersions := Seq(scala2Version, scala3Version), tlCiScalafmtCheck := true, githubWorkflowBuild += WorkflowStep.Sbt( @@ -125,12 +125,14 @@ lazy val munitDiscipline = Def.setting("org.typelevel" %%% "discipline-munit" % lazy val macroVersion = "2.1.1" lazy val scalajsSettings = Seq( - Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-maxSize", "8", "-minSuccessfulTests", "50") + Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-maxSize", "8", "-minSuccessfulTests", "50"), + bspEnabled := false ) lazy val scalaNativeSettings = Seq( evictionErrorLevel := Level.Warn, - tlMimaPreviousVersions := Set.empty + tlMimaPreviousVersions := Set.empty, + bspEnabled := false ) lazy val monocleSettings = buildSettings From 95ea5ac58be970a36600f9d84e0e5a42acc1fa80 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 17 Jul 2026 18:57:30 +0200 Subject: [PATCH 02/25] add scalaNextTests, start sketching out an implementation --- build.sbt | 28 ++++++++++-- .../selectfield/SelectFieldParser.scala | 16 +++++++ .../focus/NamedTupleAppliedFocusTest.scala | 45 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala diff --git a/build.sbt b/build.sbt index 9c05dc7cf..4a93a365e 100644 --- a/build.sbt +++ b/build.sbt @@ -106,9 +106,10 @@ lazy val buildSettings = Seq( } ) -lazy val catsVersion = "2.13.0" -lazy val scala2Version = "2.13.18" -lazy val scala3Version = "3.3.7" +lazy val catsVersion = "2.13.0" +lazy val scala2Version = "2.13.18" +lazy val scala3Version = "3.3.7" +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) @@ -285,6 +286,27 @@ lazy val test = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) ) +lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .dependsOn(core, law, state, unsafe, macros) + .jvmSettings( + monocleJvmSettings ++ Seq( + scalacOptions --= Seq("-release:8", "-Ykind-projectors"), + scalacOptions ++= Seq("-Xkind-projector") + ) + ) + .jsSettings(monocleJsSettings) + .nativeSettings(monocleNativeSettings) + .enablePlugins(NoPublishPlugin) + .settings( + scalaVersion := scalaNextVersion, + crossScalaVersions := Seq(scalaNextVersion), + libraryDependencies ++= Seq( + cats.value, + catsLaws.value, + munitDiscipline.value + ) + ) + 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/features/selectfield/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index 9745c33d9..1d598b4e7 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -2,6 +2,8 @@ package monocle.internal.focus.features.selectfield import monocle.internal.focus.FocusBase import monocle.internal.focus.features.SelectParserBase +import scala.quoted.Type +import scala.quoted.Quotes private[focus] trait SelectFieldParser { this: FocusBase & SelectParserBase => @@ -28,4 +30,18 @@ private[focus] trait SelectFieldParser { getFieldType(fromType, fieldName, pos).flatMap { toType => Right(FocusAction.SelectField(fieldName, fromType, getSuppliedTypeArgs(fromType), toType)) } + // 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: Type[?]) { + def isNamedTuple(tpe: Type[?]) = + TypeRepr.of(using tpe).dealias.typeSymbol == TypeRepr.of(using unappliedNamedTuple).typeSymbol + } + + object NamedTuples { + def create: Option[NamedTuples] = + Symbol + .requiredModule("scala.NamedTuple") + .declaredType("NamedTuple") + .headOption + .map(sym => NamedTuples(sym.typeRef.asType)) + } } diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala new file mode 100644 index 000000000..cda5147e1 --- /dev/null +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -0,0 +1,45 @@ +package monocle.focus + +import monocle.Focus +import monocle.Focus.* + +final class AppliedFocusTest extends munit.FunSuite { + + test("Applied focus returning an Optional") { + // type User = (name: String, address: Option[Address]) + // type Address = (streetNumber: Int, postcode: String) + val elise = (name = "Elise", address = 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, ("Elise", Some((50, "high street")))) + } + + // + // test("Applied focus returning an Optional") { + // case class User(name: String, address: Option[Address]) + // case class Address(streetNumber: Int, postcode: String) + // val elise = User("Elise", Some(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, User("Elise", Some(Address(50, "high street")))) + // } + // + // test("Applied focus returning a Lens") { + // case class User(name: String, address: Address) + // case class Address(streetNumber: Int, postcode: String) + // + // val bob = User("Bob", Address(5, "Bob St")) + // + // val streetNumber = bob.focus(_.address.streetNumber).get + // val newBob = bob.focus(_.address.streetNumber).replace(77) + // + // assertEquals(streetNumber, 5) + // assertEquals(newBob, User("Bob", Address(77, "Bob St"))) + // } +} From aba706cf520efc241b8f94349532c91083174fff Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 17 Jul 2026 19:16:31 +0200 Subject: [PATCH 03/25] inlined nodes seem to be stripped out smh --- .../focus/features/SelectParserBase.scala | 16 ++++++++++++++++ .../features/selectfield/SelectFieldParser.scala | 13 ------------- 2 files changed, 16 insertions(+), 13 deletions(-) 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..b10641a3f 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 @@ -1,6 +1,7 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase +import scala.quoted.Type private[focus] trait SelectParserBase extends ParserBase { this: FocusBase => @@ -15,6 +16,21 @@ private[focus] trait SelectParserBase extends ParserBase { } } + // 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: Type[?]) { + def isNamedTuple(tpe: Type[?]) = + TypeRepr.of(using tpe).dealias.typeSymbol == TypeRepr.of(using unappliedNamedTuple).typeSymbol + } + + object NamedTuples { + def create: Option[NamedTuples] = + Symbol + .requiredModule("scala.NamedTuple") + .declaredType("NamedTuple") + .headOption + .map(sym => NamedTuples(sym.typeRef.asType)) + } + def getSuppliedTypeArgs(fromType: TypeRepr): List[TypeRepr] = fromType match { case AppliedType(_, argTypeReprs) => argTypeReprs diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index 1d598b4e7..70c4af84b 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -30,18 +30,5 @@ private[focus] trait SelectFieldParser { getFieldType(fromType, fieldName, pos).flatMap { toType => Right(FocusAction.SelectField(fieldName, fromType, getSuppliedTypeArgs(fromType), toType)) } - // 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: Type[?]) { - def isNamedTuple(tpe: Type[?]) = - TypeRepr.of(using tpe).dealias.typeSymbol == TypeRepr.of(using unappliedNamedTuple).typeSymbol - } - object NamedTuples { - def create: Option[NamedTuples] = - Symbol - .requiredModule("scala.NamedTuple") - .declaredType("NamedTuple") - .headOption - .map(sym => NamedTuples(sym.typeRef.asType)) - } } From c208903918fc0930ff1f7d754518dbef93522c9b Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 17 Jul 2026 21:41:37 +0200 Subject: [PATCH 04/25] rough POC of name parsing out of named tuples --- .../selectfield/SelectFieldParser.scala | 35 +++++++++++++++++++ .../focus/NamedTupleAppliedFocusTest.scala | 22 +++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index 70c4af84b..ef48349a3 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -4,6 +4,7 @@ import monocle.internal.focus.FocusBase import monocle.internal.focus.features.SelectParserBase import scala.quoted.Type import scala.quoted.Quotes +import scala.annotation.tailrec private[focus] trait SelectFieldParser { this: FocusBase & SelectParserBase => @@ -20,6 +21,21 @@ private[focus] trait SelectFieldParser { val remainingCodeWithAction = action.map(a => (RemainingCode(remainingCode), a)) Some(remainingCodeWithAction) + // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) + case Apply( + Apply( + TypeApply(Select(Ident("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, _)), + remainingCode :: Nil + ), + Literal(IntConstant(idx)) :: Nil + ) => + val names = unrollStrings(namesTpe.tpe) + report.errorAndAbort(s"hit a named tuple access! Field: ${names(idx)}") + // Logger.debug(s"Matching NamedTuple#apply at index ($idx)") + // val names = TupleTypes.unrollStrings(namesTpe.tpe) + // widen here because we're dealing with a singleton type of the lambda param, eg. '_$4' + // recurse(acc.prepended(Path.Segment.Field(tree.tpe.widen.asType, names(idx))), tree) + case Select(remainingCode, fieldName) => Some(FocusError.NotACaseClass(remainingCode.tpe.widen.show, fieldName, term.pos).asResult) case _ => None @@ -31,4 +47,23 @@ private[focus] trait SelectFieldParser { Right(FocusAction.SelectField(fieldName, fromType, getSuppliedTypeArgs(fromType), toType)) } + // TODO: unroll to something that has good index access + private def unrollStrings(tp: TypeRepr): List[String] = + unroll(tp.asType).map { case ConstantType(StringConstant(l)) => l } + + def unroll(tpe: Type[?]): List[TypeRepr] = { + @tailrec def loop(curr: Type[?], acc: List[TypeRepr]): List[TypeRepr] = + curr match { + case '[head *: tail] => + loop(Type.of[tail], TypeRepr.of[head] :: acc) + case '[EmptyTuple] => + acc + case other => + report.errorAndAbort( + s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." + ) + } + + loop(tpe, Nil).reverse + } } diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index cda5147e1..ec32f6e44 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -5,16 +5,30 @@ import monocle.Focus.* final class AppliedFocusTest extends munit.FunSuite { + // test("Applied focus returning an Optional") { + // // type User = (name: String, address: Option[Address]) + // // type Address = (streetNumber: Int, postcode: String) + // val elise = (name = "Elise", address = 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, ("Elise", Some((50, "high street")))) + // } + + // test named tuple within a case class test("Applied focus returning an Optional") { - // type User = (name: String, address: Option[Address]) - // type Address = (streetNumber: Int, postcode: String) - val elise = (name = "Elise", address = Some((streetNumber = 12, postcode = "high street"))) + case class User(name: String, address: Option[Address]) + type Address = (streetNumber: Int, postcode: String) + + val elise = User("Elise", Some((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, ("Elise", Some((50, "high street")))) + assertEquals(newElise, User("Elise", Some((50, "high street")))) } // From 52e581c38a9a2e63b3a5b431ffd8b57f6b8e453f Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sun, 19 Jul 2026 18:12:25 +0200 Subject: [PATCH 05/25] implement all the stuff inside NamedTuples --- .../monocle/internal/focus/FocusBase.scala | 4 +- .../focus/features/GeneratorLoop.scala | 19 +++--- .../internal/focus/features/ParserLoop.scala | 5 ++ .../focus/features/SelectParserBase.scala | 64 +++++++++++++++++-- .../selectfield/SelectFieldParser.scala | 35 ---------- .../SelectNamedTupleFieldParser.scala | 53 +++++++++++++++ .../SelectedNamedTupleFieldGenerator.scala | 29 +++++++++ .../focus/NamedTupleAppliedFocusTest.scala | 2 + 8 files changed, 162 insertions(+), 49 deletions(-) create mode 100644 core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldParser.scala create mode 100644 core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala 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..f29b27ac7 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 @@ -22,6 +22,7 @@ private[focus] trait FocusBase { fromCompanion: Term, toType: TypeRepr ) + case SelectNamedTupleField(fieldName: String, fromType: TypeRepr, toType: TypeRepr) case KeywordSome(toType: TypeRepr) case KeywordAs(fromType: TypeRepr, toType: TypeRepr) case KeywordEach(fromType: TypeRepr, toType: TypeRepr, eachInstance: Term) @@ -34,6 +35,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}, ...)" @@ -42,7 +45,6 @@ private[focus] trait FocusBase { case KeywordWithDefault(toType, _) => s"KeywordWithDefault(${toType.show}, ...)" } } - enum FocusError { case NotACaseClass(className: String, fieldName: String, pos: Position) case NotAConcreteClass(className: String) 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..e4876ae5e 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 @@ -11,11 +11,13 @@ import monocle.internal.focus.features.index.IndexGenerator import monocle.internal.focus.features.withdefault.WithDefaultGenerator import monocle.Iso import scala.quoted.Type +import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldGenerator 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/ParserLoop.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/ParserLoop.scala index fcdd9f741..c48cd8288 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 @@ -9,6 +9,7 @@ import monocle.internal.focus.features.each.EachParser import monocle.internal.focus.features.at.AtParser import monocle.internal.focus.features.index.IndexParser import monocle.internal.focus.features.withdefault.WithDefaultParser +import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldParser private[focus] trait AllFeatureParsers extends FocusBase @@ -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 b10641a3f..19681783c 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 @@ -2,6 +2,7 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase import scala.quoted.Type +import scala.quoted.Expr private[focus] trait SelectParserBase extends ParserBase { this: FocusBase => @@ -17,18 +18,71 @@ private[focus] trait SelectParserBase extends ParserBase { } // 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: Type[?]) { + final class NamedTuples private (private val unappliedNamedTuple: Type[?], private val companion: Symbol) { def isNamedTuple(tpe: Type[?]) = TypeRepr.of(using tpe).dealias.typeSymbol == TypeRepr.of(using unappliedNamedTuple).typeSymbol + + // 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, description: NamedTuples.Description, fieldName: String): Option[Term] = { + val idxOfName = description.names.indexOf(fieldName) + Option.when(idxOfName != -1) { + val asTuple = toTuple(term, description) + unsafeAccessFieldByIndex(asTuple, description.values, idxOfName) + } + } + + // 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 doesn't get _N accessors + private def unsafeAccessFieldByIndex(asTuple: Term, valueTpes: Vector[TypeRepr], index: Int) = { + val tupleAccessor = s"_${index + 1}" + + if (asTuple.tpe.typeSymbol.fieldMember(tupleAccessor).exists) { + Select.unique(asTuple, tupleAccessor) + } else { + val tpeAtIndex = valueTpes(index) + (asTuple.asExpr, tpeAtIndex.asType) match { + case '{ $prod: scala.Product } -> '[tpe] => + '{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm + } + } + } + + def reconstruct(from: Term, description: NamedTuples.Description, fieldToUpdate: String, updatedValue: Term) = { + val updatedFieldIdx = description.names.indexOf(fieldToUpdate) + val asTuple = toTuple(from, description) + val values = 0 + .until(description.values.size) + .map(idx => + if (idx == updatedFieldIdx) updatedValue.asExpr + else unsafeAccessFieldByIndex(asTuple, description.values, idx).asExpr + ) + Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType)) + } } object NamedTuples { - def create: Option[NamedTuples] = - Symbol - .requiredModule("scala.NamedTuple") + def create: Option[NamedTuples] = { + val companion = Symbol.requiredModule("scala.NamedTuple") + + companion .declaredType("NamedTuple") .headOption - .map(sym => NamedTuples(sym.typeRef.asType)) + .map(sym => NamedTuples(sym.typeRef.asType, companion)) + } + + case class Description( + names: Vector[String], + values: Vector[TypeRepr], + sourceType: TypeRepr, + namesTpe: TypeRepr, + valuesTpe: TypeRepr + ) } def getSuppliedTypeArgs(fromType: TypeRepr): List[TypeRepr] = diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index ef48349a3..494705ade 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -20,22 +20,6 @@ private[focus] trait SelectFieldParser { val action = getFieldAction(fromType, fieldName, term.pos) val remainingCodeWithAction = action.map(a => (RemainingCode(remainingCode), a)) Some(remainingCodeWithAction) - - // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) - case Apply( - Apply( - TypeApply(Select(Ident("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, _)), - remainingCode :: Nil - ), - Literal(IntConstant(idx)) :: Nil - ) => - val names = unrollStrings(namesTpe.tpe) - report.errorAndAbort(s"hit a named tuple access! Field: ${names(idx)}") - // Logger.debug(s"Matching NamedTuple#apply at index ($idx)") - // val names = TupleTypes.unrollStrings(namesTpe.tpe) - // widen here because we're dealing with a singleton type of the lambda param, eg. '_$4' - // recurse(acc.prepended(Path.Segment.Field(tree.tpe.widen.asType, names(idx))), tree) - case Select(remainingCode, fieldName) => Some(FocusError.NotACaseClass(remainingCode.tpe.widen.show, fieldName, term.pos).asResult) case _ => None @@ -47,23 +31,4 @@ private[focus] trait SelectFieldParser { Right(FocusAction.SelectField(fieldName, fromType, getSuppliedTypeArgs(fromType), toType)) } - // TODO: unroll to something that has good index access - private def unrollStrings(tp: TypeRepr): List[String] = - unroll(tp.asType).map { case ConstantType(StringConstant(l)) => l } - - def unroll(tpe: Type[?]): List[TypeRepr] = { - @tailrec def loop(curr: Type[?], acc: List[TypeRepr]): List[TypeRepr] = - curr match { - case '[head *: tail] => - loop(Type.of[tail], TypeRepr.of[head] :: acc) - case '[EmptyTuple] => - acc - case other => - report.errorAndAbort( - s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." - ) - } - - loop(tpe, Nil).reverse - } } 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..644734062 --- /dev/null +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldParser.scala @@ -0,0 +1,53 @@ +package monocle.internal.focus.features.selectfield + +import monocle.internal.focus.FocusBase +import monocle.internal.focus.features.SelectParserBase +import scala.quoted.Type +import scala.quoted.Quotes +import scala.annotation.tailrec + +private[focus] trait SelectNamedTupleFieldParser { + this: FocusBase & SelectParserBase => + + import this.macroContext.reflect.* + + object SelectNamedTupleField extends FocusParser { + + def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] = term match { + // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) + case Apply( + Apply( + TypeApply(Select(Ident("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, valueTpes)), + remainingCode :: Nil + ), + Literal(IntConstant(idx)) :: Nil + ) => + val names = unrollStrings(namesTpe.tpe) + val fromType = getType(remainingCode) + val fieldType = unroll(valueTpes.tpe.asType)(idx) + Some(Right(RemainingCode(remainingCode) -> FocusAction.SelectNamedTupleField(names(idx), fromType, fieldType))) + + case _ => None + } + } + + // TODO: handle errors with an either later on + private def unrollStrings(tp: TypeRepr): Vector[String] = + unroll(tp.asType).map { case ConstantType(StringConstant(l)) => l } + + private def unroll(tpe: Type[?]): 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 + case other => + report.errorAndAbort( + s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." + ) + } + + loop(tpe, Vector.empty) + } +} diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala new file mode 100644 index 000000000..4b1079290 --- /dev/null +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala @@ -0,0 +1,29 @@ +package monocle.internal.focus.features.selectfield + +import monocle.internal.focus.FocusBase +import monocle.Lens +import scala.quoted.Quotes + +private[focus] trait SelectNamedTupleFieldGenerator { + this: FocusBase => + + import macroContext.reflect.* + + def generateSelectNamedTupleField(action: FocusAction.SelectNamedTupleField): Term = { + + def generateGetter(from: Term): Term = ??? + + def generateSetter(from: Term, to: Term): Term = ??? + + // (fromType.asType, toType.asType) match { + // case ('[f], '[t]) => + // '{ + // 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/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index ec32f6e44..4b67173fe 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -24,6 +24,8 @@ final class AppliedFocusTest extends munit.FunSuite { val elise = User("Elise", Some((12, "high street"))) + NamedTuple.apply(1) + val streetNumber = elise.focus(_.address.some.streetNumber).getOption val newElise = elise.focus(_.address.some.streetNumber).replace(50) From bff16f07ef9964f0774fad3ca48e2939b8ee118b Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sun, 19 Jul 2026 21:56:15 +0200 Subject: [PATCH 06/25] half-working POC --- .../monocle/internal/focus/FocusBase.scala | 121 +++++++++++++++++- .../focus/features/SelectParserBase.scala | 69 +--------- .../SelectNamedTupleFieldParser.scala | 64 ++++----- .../SelectedNamedTupleFieldGenerator.scala | 24 ++-- .../focus/NamedTupleAppliedFocusTest.scala | 39 +++--- 5 files changed, 177 insertions(+), 140 deletions(-) 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 f29b27ac7..88bce8e7a 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.annotation.tailrec +import scala.quoted.* 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,7 +25,12 @@ private[focus] trait FocusBase { fromCompanion: Term, toType: TypeRepr ) - case SelectNamedTupleField(fieldName: String, fromType: TypeRepr, toType: TypeRepr) + case SelectNamedTupleField( + fieldName: String, + from: NamedTuples.Description, + toType: TypeRepr, + namedTuples: NamedTuples + ) case KeywordSome(toType: TypeRepr) case KeywordAs(fromType: TypeRepr, toType: TypeRepr) case KeywordEach(fromType: TypeRepr, toType: TypeRepr, eachInstance: Term) @@ -35,8 +43,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 SelectNamedTupleField(fieldName, fromType, toType, _) => + s"SelectNamedTupleField($fieldName, ${fromType.toString()}, ${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}, ...)" @@ -60,4 +68,109 @@ 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, private val companion: Symbol) { + def isNamedTuple(tpe: TypeRepr) = + tpe.dealias.typeSymbol == unappliedNamedTuple.typeSymbol + + // 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, description: NamedTuples.Description, fieldName: String): Option[Term] = { + val idxOfName = description.names.indexOf(fieldName) + Option.when(idxOfName != -1) { + val asTuple = toTuple(term, description) + unsafeAccessFieldByIndex(asTuple, description.values, idxOfName) + } + } + + // 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 doesn't get _N accessors + private def unsafeAccessFieldByIndex(asTuple: Term, valueTpes: Vector[TypeRepr], index: Int) = { + val tupleAccessor = s"_${index + 1}" + + if (asTuple.tpe.typeSymbol.fieldMember(tupleAccessor).exists) { + Select.unique(asTuple, tupleAccessor) + } else { + val tpeAtIndex = valueTpes(index) + (asTuple.asExpr, tpeAtIndex.asType) match { + case '{ $prod: scala.Product } -> '[tpe] => + '{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm + } + } + } + + def reconstruct(from: Term, description: NamedTuples.Description, fieldToUpdate: String, updatedValue: Term) = { + val updatedFieldIdx = description.names.indexOf(fieldToUpdate) + val asTuple = toTuple(from, description) + val values = 0 + .until(description.values.size) + .map(idx => + if (idx == updatedFieldIdx) updatedValue.asExpr + else unsafeAccessFieldByIndex(asTuple, description.values, idx).asExpr + ) + Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType)) + } + + 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 + } + + // TODO: handle errors with an either later on + 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 + case other => + report.errorAndAbort( + s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." + ) + } + + loop(tpe.asType, Vector.empty) + } + + } + + object NamedTuples { + def create: 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 + ) + } + } 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 19681783c..85d11ffd5 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 @@ -3,6 +3,7 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase import scala.quoted.Type import scala.quoted.Expr +import scala.annotation.tailrec private[focus] trait SelectParserBase extends ParserBase { this: FocusBase => @@ -17,74 +18,6 @@ private[focus] trait SelectParserBase extends ParserBase { } } - // 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: Type[?], private val companion: Symbol) { - def isNamedTuple(tpe: Type[?]) = - TypeRepr.of(using tpe).dealias.typeSymbol == TypeRepr.of(using unappliedNamedTuple).typeSymbol - - // 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, description: NamedTuples.Description, fieldName: String): Option[Term] = { - val idxOfName = description.names.indexOf(fieldName) - Option.when(idxOfName != -1) { - val asTuple = toTuple(term, description) - unsafeAccessFieldByIndex(asTuple, description.values, idxOfName) - } - } - - // 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 doesn't get _N accessors - private def unsafeAccessFieldByIndex(asTuple: Term, valueTpes: Vector[TypeRepr], index: Int) = { - val tupleAccessor = s"_${index + 1}" - - if (asTuple.tpe.typeSymbol.fieldMember(tupleAccessor).exists) { - Select.unique(asTuple, tupleAccessor) - } else { - val tpeAtIndex = valueTpes(index) - (asTuple.asExpr, tpeAtIndex.asType) match { - case '{ $prod: scala.Product } -> '[tpe] => - '{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm - } - } - } - - def reconstruct(from: Term, description: NamedTuples.Description, fieldToUpdate: String, updatedValue: Term) = { - val updatedFieldIdx = description.names.indexOf(fieldToUpdate) - val asTuple = toTuple(from, description) - val values = 0 - .until(description.values.size) - .map(idx => - if (idx == updatedFieldIdx) updatedValue.asExpr - else unsafeAccessFieldByIndex(asTuple, description.values, idx).asExpr - ) - Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType)) - } - } - - object NamedTuples { - def create: Option[NamedTuples] = { - val companion = Symbol.requiredModule("scala.NamedTuple") - - companion - .declaredType("NamedTuple") - .headOption - .map(sym => NamedTuples(sym.typeRef.asType, companion)) - } - - case class Description( - names: Vector[String], - values: Vector[TypeRepr], - sourceType: TypeRepr, - namesTpe: TypeRepr, - valuesTpe: TypeRepr - ) - } - def getSuppliedTypeArgs(fromType: TypeRepr): List[TypeRepr] = fromType match { case AppliedType(_, argTypeReprs) => argTypeReprs 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 index 644734062..2f0abc8bc 100644 --- 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 @@ -12,42 +12,34 @@ private[focus] trait SelectNamedTupleFieldParser { import this.macroContext.reflect.* object SelectNamedTupleField extends FocusParser { - - def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] = term match { - // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) - case Apply( - Apply( - TypeApply(Select(Ident("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, valueTpes)), - remainingCode :: Nil - ), - Literal(IntConstant(idx)) :: Nil - ) => - val names = unrollStrings(namesTpe.tpe) - val fromType = getType(remainingCode) - val fieldType = unroll(valueTpes.tpe.asType)(idx) - Some(Right(RemainingCode(remainingCode) -> FocusAction.SelectNamedTupleField(names(idx), fromType, fieldType))) - - case _ => None - } - } - - // TODO: handle errors with an either later on - private def unrollStrings(tp: TypeRepr): Vector[String] = - unroll(tp.asType).map { case ConstantType(StringConstant(l)) => l } - - private def unroll(tpe: Type[?]): 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 - case other => - report.errorAndAbort( - s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." - ) + val namedTuples = NamedTuples.create + + def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] = + namedTuples.flatMap { namedTuples => + term match { + // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) + case Apply( + Apply( + TypeApply(Select(Ident("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, valueTpes)), + remainingCode :: Nil + ), + Literal(IntConstant(idx)) :: Nil + ) => + namedTuples.describe(remainingCode.tpe).flatMap { description => + val fieldType = description.values(idx) + val fieldName = description.names(idx) + println(description) + println() + Some( + Right( + RemainingCode(remainingCode) -> FocusAction + .SelectNamedTupleField(fieldName, description, fieldType, namedTuples) + ) + ) + } + case _ => None + } } - - loop(tpe, Vector.empty) } + } diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala index 4b1079290..7cd08a366 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala @@ -10,20 +10,20 @@ private[focus] trait SelectNamedTupleFieldGenerator { import macroContext.reflect.* def generateSelectNamedTupleField(action: FocusAction.SelectNamedTupleField): Term = { + // TODO: handle errors + def generateGetter(from: Term): Term = action.namedTuples.accessFieldByName(from, action.from, action.fieldName).get - def generateGetter(from: Term): Term = ??? + def generateSetter(from: Term, to: Term): Term = + action.namedTuples.reconstruct(from, action.from, action.fieldName, to) - def generateSetter(from: Term, to: Term): Term = ??? + (action.from.sourceType.asType, action.toType.asType) match { + case ('[f], '[t]) => + '{ + Lens.apply[f, t]((from: f) => ${ generateGetter('from.asTerm).asExprOf[t] })((to: t) => + (from: f) => ${ generateSetter('from.asTerm, 'to.asTerm).asExprOf[f] } + ) + }.asTerm + } - // (fromType.asType, toType.asType) match { - // case ('[f], '[t]) => - // '{ - // 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/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index 4b67173fe..a4100ba9f 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -5,34 +5,33 @@ import monocle.Focus.* final class AppliedFocusTest extends munit.FunSuite { - // test("Applied focus returning an Optional") { - // // type User = (name: String, address: Option[Address]) - // // type Address = (streetNumber: Int, postcode: String) - // val elise = (name = "Elise", address = 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, ("Elise", Some((50, "high street")))) - // } - - // test named tuple within a case class test("Applied focus returning an Optional") { - case class User(name: String, address: Option[Address]) - type Address = (streetNumber: Int, postcode: String) - - val elise = User("Elise", Some((12, "high street"))) - - NamedTuple.apply(1) + // type User = (name: String, address: Option[Address]) + // type Address = (streetNumber: Int, postcode: String) + val elise = (name = "Elise", address = 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")))) + assertEquals(newElise, ("Elise", Some((50, "high street")))) } + // test named tuple within a case class + // test("Applied focus returning an Optional") { + // case class User(name: String, address: Option[Address]) + // type Address = (streetNumber: Int, postcode: String) + // + // val elise = User("Elise", Some((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, User("Elise", Some((50, "high street")))) + // } + // // test("Applied focus returning an Optional") { // case class User(name: String, address: Option[Address]) From 0cf4de5ccb0ee49cebe8bf8caf91b7453df1aa70 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Mon, 20 Jul 2026 22:42:14 +0200 Subject: [PATCH 07/25] fix issue with a non-widened type of 'remainingCode' (it was returning a narrowed-down type of the lambda param), add additional tests --- .../monocle/internal/focus/FocusBase.scala | 9 ++- ...a => SelectNamedTupleFieldGenerator.scala} | 0 .../SelectNamedTupleFieldParser.scala | 4 +- .../focus/NamedTupleAppliedFocusTest.scala | 76 +++++++++++-------- 4 files changed, 54 insertions(+), 35 deletions(-) rename core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/{SelectedNamedTupleFieldGenerator.scala => SelectNamedTupleFieldGenerator.scala} (100%) 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 88bce8e7a..8be6e578d 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 @@ -44,7 +44,7 @@ private[focus] trait FocusBase { 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.toString()}, ${toType.show})" + 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}, ...)" @@ -170,7 +170,12 @@ private[focus] trait FocusBase { sourceType: TypeRepr, namesTpe: TypeRepr, valuesTpe: TypeRepr - ) + ) { + def show: String = { + given Printer[TypeRepr] = Printer.TypeReprShortCode + 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/selectfield/SelectedNamedTupleFieldGenerator.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldGenerator.scala similarity index 100% rename from core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectedNamedTupleFieldGenerator.scala rename to core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectNamedTupleFieldGenerator.scala 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 index 2f0abc8bc..369837efb 100644 --- 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 @@ -25,10 +25,10 @@ private[focus] trait SelectNamedTupleFieldParser { ), Literal(IntConstant(idx)) :: Nil ) => - namedTuples.describe(remainingCode.tpe).flatMap { description => + namedTuples.describe(getType(remainingCode)).flatMap { description => val fieldType = description.values(idx) val fieldName = description.names(idx) - println(description) + println(description.show) println() Some( Right( diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index a4100ba9f..68376addf 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -3,48 +3,62 @@ package monocle.focus import monocle.Focus import monocle.Focus.* -final class AppliedFocusTest extends munit.FunSuite { +final class NamedTupleAppliedFocusTest extends munit.FunSuite { - test("Applied focus returning an Optional") { + test("Applied focus returning an Optional in nested named tuples") { // type User = (name: String, address: Option[Address]) // type Address = (streetNumber: Int, postcode: String) - val elise = (name = "Elise", address = Some((streetNumber = 12, postcode = "high street"))) + // 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 normal focus 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, ("Elise", Some((50, "high street")))) + assertEquals(newElise, (name = "Elise", address = Some((streetNumber = 50, postcode = "high street")))) } - // test named tuple within a case class - // test("Applied focus returning an Optional") { - // case class User(name: String, address: Option[Address]) - // type Address = (streetNumber: Int, postcode: String) - // - // val elise = User("Elise", Some((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, User("Elise", Some((50, "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") { + case class User(name: String, address: Option[Address]) + case class Address(streetNumber: Int, postcode: String) + val elise = User("Elise", Some(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, User("Elise", Some(Address(50, "high street")))) + } - // - // test("Applied focus returning an Optional") { - // case class User(name: String, address: Option[Address]) - // case class Address(streetNumber: Int, postcode: String) - // val elise = User("Elise", Some(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, User("Elise", Some(Address(50, "high street")))) - // } - // // test("Applied focus returning a Lens") { // case class User(name: String, address: Address) // case class Address(streetNumber: Int, postcode: String) From 0d804525a119ec2d1173b6667b4f6a7984236eae Mon Sep 17 00:00:00 2001 From: Aleksander Date: Mon, 20 Jul 2026 22:52:01 +0200 Subject: [PATCH 08/25] add test for NamedTuple.From --- .../focus/NamedTupleAppliedFocusTest.scala | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index 68376addf..48484e3d8 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -71,4 +71,25 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { // assertEquals(streetNumber, 5) // assertEquals(newBob, User("Bob", Address(77, "Bob St"))) // } + + test("Applied focus returning am 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") + )) + } } From 0c76f73b52247b7576fa1de1bab8fd04df8ffd46 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Mon, 20 Jul 2026 22:58:42 +0200 Subject: [PATCH 09/25] Lens tests, narrow down another issue --- .../focus/NamedTupleAppliedFocusTest.scala | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index 48484e3d8..605c4113f 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -59,20 +59,7 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { assertEquals(newElise, User("Elise", Some(Address(50, "high street")))) } - // test("Applied focus returning a Lens") { - // case class User(name: String, address: Address) - // case class Address(streetNumber: Int, postcode: String) - // - // val bob = User("Bob", Address(5, "Bob St")) - // - // val streetNumber = bob.focus(_.address.streetNumber).get - // val newBob = bob.focus(_.address.streetNumber).replace(77) - // - // assertEquals(streetNumber, 5) - // assertEquals(newBob, User("Bob", Address(77, "Bob St"))) - // } - - test("Applied focus returning am Optional with NamedTuple.From") { + test("Applied focus returning an Optional with NamedTuple.From") { case class User[A](name: String, address: A) case class Address(streetNumber: Int, postcode: String) @@ -92,4 +79,79 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { 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") { + case class User(name: String, address: Address) + case class Address(streetNumber: Int, postcode: String) + + val bob = User("Bob", Address(5, "Bob St")) + + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) + + assertEquals(streetNumber, 5) + assertEquals(newBob, User("Bob", 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") + )) + } } From b2de61d5979936a277214ede5e31c76b7ff72136 Mon Sep 17 00:00:00 2001 From: arainko Date: Tue, 21 Jul 2026 16:06:32 +0200 Subject: [PATCH 10/25] harden the case class check to simplify match types (needed for named tuples), cleanup the NamedTuples api --- .../monocle/internal/focus/FocusBase.scala | 49 +++++++++---------- .../internal/focus/features/ParserBase.scala | 3 +- .../internal/focus/features/ParserLoop.scala | 7 ++- .../focus/features/SelectParserBase.scala | 4 +- .../selectfield/SelectFieldParser.scala | 3 -- .../SelectNamedTupleFieldGenerator.scala | 5 +- .../SelectNamedTupleFieldParser.scala | 10 +--- 7 files changed, 31 insertions(+), 50 deletions(-) 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 8be6e578d..996d4b3b5 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 @@ -70,34 +70,45 @@ 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, private val companion: Symbol) { + final class NamedTuples private (private val unappliedNamedTuple: TypeRepr, val companion: Symbol) { def isNamedTuple(tpe: TypeRepr) = tpe.dealias.typeSymbol == unappliedNamedTuple.typeSymbol - // NamedTuple.toTuple[Names <: Tuple, Values <: Tuple](tup: NamedTuple.NamedTuple[Names, Values]): Values + // 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, description: NamedTuples.Description, fieldName: String): Option[Term] = { - val idxOfName = description.names.indexOf(fieldName) - Option.when(idxOfName != -1) { - val asTuple = toTuple(term, description) - unsafeAccessFieldByIndex(asTuple, description.values, idxOfName) - } + def accessFieldByName(term: Term, action: FocusAction.SelectNamedTupleField): Term = { + val idxOfName = action.from.names.indexOf(action.fieldName) + val asTuple = toTuple(term, action.from) + unsafeAccessFieldByIndex(asTuple, action.from, idxOfName) + } + + def reconstructWithUpdatedField(from: Term, action: FocusAction.SelectNamedTupleField, updatedValue: Term) = { + val updatedFieldIdx = action.from.names.indexOf(action.fieldName) + val asTuple = toTuple(from, action.from) + val values = 0 + .until(action.from.values.size) + .map(idx => + if (idx == updatedFieldIdx) updatedValue.asExpr + else unsafeAccessFieldByIndex(asTuple, action.from, idx).asExpr + ) + // NamedTuple >: Tuple so to 'construct' a named tuple we can just upcast an ordinary Tuple to a NamedTuple + Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using action.from.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 doesn't get _N accessors - private def unsafeAccessFieldByIndex(asTuple: Term, valueTpes: Vector[TypeRepr], index: Int) = { + 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 = valueTpes(index) + val tpeAtIndex = description.values(index) (asTuple.asExpr, tpeAtIndex.asType) match { case '{ $prod: scala.Product } -> '[tpe] => '{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm @@ -105,18 +116,6 @@ private[focus] trait FocusBase { } } - def reconstruct(from: Term, description: NamedTuples.Description, fieldToUpdate: String, updatedValue: Term) = { - val updatedFieldIdx = description.names.indexOf(fieldToUpdate) - val asTuple = toTuple(from, description) - val values = 0 - .until(description.values.size) - .map(idx => - if (idx == updatedFieldIdx) updatedValue.asExpr - else unsafeAccessFieldByIndex(asTuple, description.values, idx).asExpr - ) - Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType)) - } - def describe(sourceType: TypeRepr): Option[NamedTuples.Description] = sourceType.dealias.simplified match { case tpe @ AppliedType(_, namesTpe :: valuesTpe :: Nil) if isNamedTuple(tpe) => @@ -143,10 +142,6 @@ private[focus] trait FocusBase { loop(Type.of[tail], acc.appended(TypeRepr.of[head])) case '[EmptyTuple] => acc - case other => - report.errorAndAbort( - s"Unexpected type (${Type.show(using other)}) encountered when extracting tuple type elems." - ) } loop(tpe.asType, Vector.empty) @@ -155,7 +150,7 @@ private[focus] trait FocusBase { } object NamedTuples { - def create: Option[NamedTuples] = { + val Support: Option[NamedTuples] = { val companion = Symbol.requiredModule("scala.NamedTuple") companion 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..915f40a86 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 @@ -1,6 +1,5 @@ package monocle.internal.focus.features -import scala.quoted.Quotes import monocle.internal.focus.FocusBase private[focus] trait ParserBase { @@ -16,6 +15,6 @@ private[focus] trait ParserBase { } 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 c48cd8288..674f48eaf 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 @@ -57,13 +57,12 @@ private[focus] trait ParserLoop { case SelectOnlyField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) case SelectOnlyField(Left(error)) => Left(error) - 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 + case SelectField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) + case SelectField(Left(error)) => Left(error) + case unexpected => FocusError.UnexpectedCodeStructure(unexpected.show).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 85d11ffd5..bf46a55cd 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 @@ -2,8 +2,6 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase import scala.quoted.Type -import scala.quoted.Expr -import scala.annotation.tailrec private[focus] trait SelectParserBase extends ParserBase { this: FocusBase => @@ -13,7 +11,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/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index 494705ade..c5477ed68 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -2,9 +2,6 @@ package monocle.internal.focus.features.selectfield import monocle.internal.focus.FocusBase import monocle.internal.focus.features.SelectParserBase -import scala.quoted.Type -import scala.quoted.Quotes -import scala.annotation.tailrec private[focus] trait SelectFieldParser { this: FocusBase & SelectParserBase => 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 index 7cd08a366..f4264fc0f 100644 --- 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 @@ -10,11 +10,10 @@ private[focus] trait SelectNamedTupleFieldGenerator { import macroContext.reflect.* def generateSelectNamedTupleField(action: FocusAction.SelectNamedTupleField): Term = { - // TODO: handle errors - def generateGetter(from: Term): Term = action.namedTuples.accessFieldByName(from, action.from, action.fieldName).get + def generateGetter(from: Term): Term = action.namedTuples.accessFieldByName(from, action) def generateSetter(from: Term, to: Term): Term = - action.namedTuples.reconstruct(from, action.from, action.fieldName, to) + action.namedTuples.reconstructWithUpdatedField(from, action, to) (action.from.sourceType.asType, action.toType.asType) match { case ('[f], '[t]) => 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 index 369837efb..63a1a70bf 100644 --- 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 @@ -2,9 +2,6 @@ package monocle.internal.focus.features.selectfield import monocle.internal.focus.FocusBase import monocle.internal.focus.features.SelectParserBase -import scala.quoted.Type -import scala.quoted.Quotes -import scala.annotation.tailrec private[focus] trait SelectNamedTupleFieldParser { this: FocusBase & SelectParserBase => @@ -12,12 +9,11 @@ private[focus] trait SelectNamedTupleFieldParser { import this.macroContext.reflect.* object SelectNamedTupleField extends FocusParser { - val namedTuples = NamedTuples.create def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]] = - namedTuples.flatMap { namedTuples => + NamedTuples.Support.flatMap { namedTuples => term match { - // TODO: document what kinda tree this actually matches (i.e. look at the desugared named tuple access calls) + // 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("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, valueTpes)), @@ -28,8 +24,6 @@ private[focus] trait SelectNamedTupleFieldParser { namedTuples.describe(getType(remainingCode)).flatMap { description => val fieldType = description.values(idx) val fieldName = description.names(idx) - println(description.show) - println() Some( Right( RemainingCode(remainingCode) -> FocusAction From 5a334729a30ff27e2dab531adbe81de551e3582a Mon Sep 17 00:00:00 2001 From: arainko Date: Tue, 21 Jul 2026 16:06:45 +0200 Subject: [PATCH 11/25] cleanup tests --- .../focus/NamedTupleAppliedFocusTest.scala | 55 +++++-------------- 1 file changed, 13 insertions(+), 42 deletions(-) diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala index 605c4113f..83d7877a5 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala @@ -6,10 +6,8 @@ import monocle.Focus.* final class NamedTupleAppliedFocusTest extends munit.FunSuite { test("Applied focus returning an Optional in nested named tuples") { - // type User = (name: String, address: Option[Address]) - // type Address = (streetNumber: Int, postcode: String) // 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 normal focus on case classes when a type is described as Some instead of Option + // the macro cannot find a matching overload otherwise - note that this also happens in a normal .focus incovation 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 @@ -38,7 +36,6 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { 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) @@ -46,19 +43,6 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { assertEquals(newElise, (name = "Elise", address = Some(Address(50, "high street")))) } - - test("Applied focus returning an Optional") { - case class User(name: String, address: Option[Address]) - case class Address(streetNumber: Int, postcode: String) - val elise = User("Elise", Some(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, User("Elise", 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) @@ -106,33 +90,20 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { 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") { - case class User(name: String, address: Address) - case class Address(streetNumber: Int, postcode: String) + 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 bob = User("Bob", Address(5, "Bob St")) + val streetNumber = bob.focus(_.address.streetNumber).get + val newBob = bob.focus(_.address.streetNumber).replace(77) - val streetNumber = bob.focus(_.address.streetNumber).get - val newBob = bob.focus(_.address.streetNumber).replace(77) - - assertEquals(streetNumber, 5) - assertEquals(newBob, User("Bob", Address(77, "Bob St"))) - } + 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) From da8fec7dd720fee8a66911a3515086df8b1ff68a Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 23 Jul 2026 20:33:05 +0200 Subject: [PATCH 12/25] slightly refactor SlecNamedTupleFieldParser --- .../SelectNamedTupleFieldParser.scala | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) 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 index 63a1a70bf..50bb95cc8 100644 --- 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 @@ -15,22 +15,15 @@ private[focus] trait SelectNamedTupleFieldParser { 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("NamedTuple"), "apply") | Ident("apply"), List(namesTpe, valueTpes)), - remainingCode :: Nil - ), - Literal(IntConstant(idx)) :: Nil - ) => - namedTuples.describe(getType(remainingCode)).flatMap { description => - val fieldType = description.values(idx) - val fieldName = description.names(idx) - Some( - Right( - RemainingCode(remainingCode) -> FocusAction - .SelectNamedTupleField(fieldName, description, fieldType, namedTuples) - ) - ) - } + 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 } } From 5ee1fe996be409fae1582d0ede608a000e508b88 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 23 Jul 2026 21:54:05 +0200 Subject: [PATCH 13/25] cleanup build, add tests from their case class equivalent test specs --- build.sbt | 21 +-- ...usTest.scala => NamedTupleFocusTest.scala} | 136 +++++++++++++++++- 2 files changed, 140 insertions(+), 17 deletions(-) rename scalaNextTest/shared/src/test/scala/monocle/focus/{NamedTupleAppliedFocusTest.scala => NamedTupleFocusTest.scala} (56%) diff --git a/build.sbt b/build.sbt index 4a93a365e..9ea06d24e 100644 --- a/build.sbt +++ b/build.sbt @@ -151,7 +151,8 @@ lazy val root = tlCrossRootProject.aggregate( unsafe, test, example, - bench + bench, + scalaNextTest ) lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) @@ -287,24 +288,16 @@ lazy val test = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform) - .dependsOn(core, law, state, unsafe, macros) - .jvmSettings( - monocleJvmSettings ++ Seq( - scalacOptions --= Seq("-release:8", "-Ykind-projectors"), - scalacOptions ++= Seq("-Xkind-projector") - ) - ) + .dependsOn(core, macros) + .jvmSettings(monocleJvmSettings) .jsSettings(monocleJsSettings) .nativeSettings(monocleNativeSettings) .enablePlugins(NoPublishPlugin) .settings( - scalaVersion := scalaNextVersion, + scalaVersion := scalaNextVersion, crossScalaVersions := Seq(scalaNextVersion), - libraryDependencies ++= Seq( - cats.value, - catsLaws.value, - munitDiscipline.value - ) + libraryDependencies ++= Seq(munitDiscipline.value), + scalacOptions --= Seq("-release:8", "-Ykind-projector") ) lazy val bench = project diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala similarity index 56% rename from scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala rename to scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala index 83d7877a5..a99701941 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleAppliedFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala @@ -1,13 +1,14 @@ package monocle.focus -import monocle.Focus import monocle.Focus.* +import monocle.Focus +import monocle.Iso -final class NamedTupleAppliedFocusTest extends munit.FunSuite { +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 incovation on case classes when a type is described as Some instead of 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 @@ -124,5 +125,134 @@ final class NamedTupleAppliedFocusTest extends munit.FunSuite { 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("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) + } + + + } From 8bcaa1d17153b8c0dd72542d137d2c1629668c26 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 23 Jul 2026 22:17:59 +0200 Subject: [PATCH 14/25] special case 1-arity named tuples to generate an Iso instead --- .../monocle/internal/focus/FocusBase.scala | 32 +++++++++++-------- .../SelectNamedTupleFieldGenerator.scala | 25 +++++++++++---- .../monocle/focus/NamedTupleFocusTest.scala | 1 - 3 files changed, 37 insertions(+), 21 deletions(-) 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 996d4b3b5..da6c58687 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 @@ -27,7 +27,7 @@ private[focus] trait FocusBase { ) case SelectNamedTupleField( fieldName: String, - from: NamedTuples.Description, + fromDescription: NamedTuples.Description, toType: TypeRepr, namedTuples: NamedTuples ) @@ -82,24 +82,28 @@ private[focus] trait FocusBase { .appliedTo(term) def accessFieldByName(term: Term, action: FocusAction.SelectNamedTupleField): Term = { - val idxOfName = action.from.names.indexOf(action.fieldName) - val asTuple = toTuple(term, action.from) - unsafeAccessFieldByIndex(asTuple, action.from, idxOfName) + 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.from.names.indexOf(action.fieldName) - val asTuple = toTuple(from, action.from) - val values = 0 - .until(action.from.values.size) - .map(idx => - if (idx == updatedFieldIdx) updatedValue.asExpr - else unsafeAccessFieldByIndex(asTuple, action.from, idx).asExpr - ) - // NamedTuple >: Tuple so to 'construct' a named tuple we can just upcast an ordinary Tuple to a NamedTuple - Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using action.from.sourceType.asType)) + val updatedFieldIdx = action.fromDescription.names.indexOf(action.fieldName) + val asTuple = toTuple(from, action.fromDescription) + val values = + 0 + .until(action.fromDescription.values.size) + .map(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 doesn't get _N accessors private def unsafeAccessFieldByIndex(asTuple: Term, description: NamedTuples.Description, index: Int) = { 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 index f4264fc0f..abcc2836f 100644 --- 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 @@ -2,7 +2,9 @@ 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 => @@ -15,13 +17,24 @@ private[focus] trait SelectNamedTupleFieldGenerator { def generateSetter(from: Term, to: Term): Term = action.namedTuples.reconstructWithUpdatedField(from, action, to) - (action.from.sourceType.asType, action.toType.asType) match { + def generateReverseGet(to: Expr[Any]): Term = + action.namedTuples.construct(action.fromDescription, Vector(to)) + + (action.fromDescription.sourceType.asType, action.toType.asType) match { case ('[f], '[t]) => - '{ - Lens.apply[f, t]((from: f) => ${ generateGetter('from.asTerm).asExprOf[t] })((to: t) => - (from: f) => ${ generateSetter('from.asTerm, 'to.asTerm).asExprOf[f] } - ) - }.asTerm + 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/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala index a99701941..cdb16dd05 100644 --- a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala @@ -24,7 +24,6 @@ final class NamedTupleFocusTest extends munit.FunSuite { 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) From 79a36f2b5a47c3edce4269158bd332d3b06aa31d Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 23 Jul 2026 22:37:45 +0200 Subject: [PATCH 15/25] restore build to its original state before my dev changes, undo a bunch of random blankspace changes I did across multiple files lol --- build.sbt | 8 +++----- .../monocle/internal/focus/FocusBase.scala | 20 ++++++++----------- .../focus/features/GeneratorLoop.scala | 2 +- .../internal/focus/features/ParserBase.scala | 1 + .../internal/focus/features/ParserLoop.scala | 9 +++++---- .../focus/features/SelectParserBase.scala | 1 - .../selectfield/SelectFieldParser.scala | 2 +- 7 files changed, 19 insertions(+), 24 deletions(-) diff --git a/build.sbt b/build.sbt index 9ea06d24e..84bc7fce0 100644 --- a/build.sbt +++ b/build.sbt @@ -19,7 +19,7 @@ inThisBuild( Developer(username, fullName, s"@$username", url(s"https://github.com/$username")) }, run / fork := true, - scalaVersion := scala3Version, + scalaVersion := scala2Version, crossScalaVersions := Seq(scala2Version, scala3Version), tlCiScalafmtCheck := true, githubWorkflowBuild += WorkflowStep.Sbt( @@ -126,14 +126,12 @@ lazy val munitDiscipline = Def.setting("org.typelevel" %%% "discipline-munit" % lazy val macroVersion = "2.1.1" lazy val scalajsSettings = Seq( - Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-maxSize", "8", "-minSuccessfulTests", "50"), - bspEnabled := false + Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-maxSize", "8", "-minSuccessfulTests", "50") ) lazy val scalaNativeSettings = Seq( evictionErrorLevel := Level.Warn, - tlMimaPreviousVersions := Set.empty, - bspEnabled := false + tlMimaPreviousVersions := Set.empty ) lazy val monocleSettings = buildSettings 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 da6c58687..722836daa 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,7 +1,7 @@ package monocle.internal.focus -import scala.annotation.tailrec import scala.quoted.* +import scala.annotation.tailrec private[focus] trait FocusBase { val macroContext: Quotes @@ -53,6 +53,7 @@ private[focus] trait FocusBase { case KeywordWithDefault(toType, _) => s"KeywordWithDefault(${toType.show}, ...)" } } + enum FocusError { case NotACaseClass(className: String, fieldName: String, pos: Position) case NotAConcreteClass(className: String) @@ -91,12 +92,10 @@ private[focus] trait FocusBase { val updatedFieldIdx = action.fromDescription.names.indexOf(action.fieldName) val asTuple = toTuple(from, action.fromDescription) val values = - 0 - .until(action.fromDescription.values.size) - .map(idx => - if (idx == updatedFieldIdx) updatedValue.asExpr - else unsafeAccessFieldByIndex(asTuple, action.fromDescription, idx).asExpr - ) + Vector.tabulate(action.fromDescription.values.size) { idx => + if (idx == updatedFieldIdx) updatedValue.asExpr + else unsafeAccessFieldByIndex(asTuple, action.fromDescription, idx).asExpr + } construct(action.fromDescription, values) } @@ -105,7 +104,7 @@ private[focus] trait FocusBase { 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 doesn't get _N accessors + // 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}" @@ -135,7 +134,6 @@ private[focus] trait FocusBase { case _ => None } - // TODO: handle errors with an either later on private def unrollStrings(tp: TypeRepr): Vector[String] = unroll(tp).map { case ConstantType(StringConstant(l)) => l } @@ -170,10 +168,8 @@ private[focus] trait FocusBase { namesTpe: TypeRepr, valuesTpe: TypeRepr ) { - def show: String = { - given Printer[TypeRepr] = Printer.TypeReprShortCode + 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 e4876ae5e..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 @@ -11,7 +12,6 @@ import monocle.internal.focus.features.index.IndexGenerator import monocle.internal.focus.features.withdefault.WithDefaultGenerator import monocle.Iso import scala.quoted.Type -import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldGenerator private[focus] trait AllFeatureGenerators extends FocusBase 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 915f40a86..ff3b29ef7 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 @@ -1,5 +1,6 @@ package monocle.internal.focus.features +import scala.quoted.Quotes import monocle.internal.focus.FocusBase private[focus] trait ParserBase { 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 674f48eaf..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 @@ -9,7 +10,6 @@ import monocle.internal.focus.features.each.EachParser import monocle.internal.focus.features.at.AtParser import monocle.internal.focus.features.index.IndexParser import monocle.internal.focus.features.withdefault.WithDefaultParser -import monocle.internal.focus.features.selectfield.SelectNamedTupleFieldParser private[focus] trait AllFeatureParsers extends FocusBase @@ -57,12 +57,13 @@ private[focus] trait ParserLoop { case SelectOnlyField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) case SelectOnlyField(Left(error)) => Left(error) + 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 SelectField(Right(remainingCode, action)) => loop(remainingCode, action :: listSoFar) - case SelectField(Left(error)) => Left(error) - case unexpected => FocusError.UnexpectedCodeStructure(unexpected.show).asResult + 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 bf46a55cd..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 @@ -1,7 +1,6 @@ package monocle.internal.focus.features import monocle.internal.focus.FocusBase -import scala.quoted.Type private[focus] trait SelectParserBase extends ParserBase { this: FocusBase => diff --git a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala index c5477ed68..9745c33d9 100644 --- a/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala +++ b/core/shared/src/main/scala-3/monocle/internal/focus/features/selectfield/SelectFieldParser.scala @@ -17,6 +17,7 @@ private[focus] trait SelectFieldParser { val action = getFieldAction(fromType, fieldName, term.pos) val remainingCodeWithAction = action.map(a => (RemainingCode(remainingCode), a)) Some(remainingCodeWithAction) + case Select(remainingCode, fieldName) => Some(FocusError.NotACaseClass(remainingCode.tpe.widen.show, fieldName, term.pos).asResult) case _ => None @@ -27,5 +28,4 @@ private[focus] trait SelectFieldParser { getFieldType(fromType, fieldName, pos).flatMap { toType => Right(FocusAction.SelectField(fieldName, fromType, getSuppliedTypeArgs(fromType), toType)) } - } From 4eb5e3ce301cb6643d76f203b15013698fa46bc3 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 23 Jul 2026 23:56:24 +0200 Subject: [PATCH 16/25] move test suite to scala-3 source folder --- .../{scala => scala-3}/monocle/focus/NamedTupleFocusTest.scala | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scalaNextTest/shared/src/test/{scala => scala-3}/monocle/focus/NamedTupleFocusTest.scala (100%) diff --git a/scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala similarity index 100% rename from scalaNextTest/shared/src/test/scala/monocle/focus/NamedTupleFocusTest.scala rename to scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala From 5719f342ab65bdda926782f24b776ac73ae8c09c Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 24 Jul 2026 00:21:12 +0200 Subject: [PATCH 17/25] dont set scalaVersion directly on the scalaNextTest project --- build.sbt | 1 - 1 file changed, 1 deletion(-) diff --git a/build.sbt b/build.sbt index 4f3bfee39..2531bb27a 100644 --- a/build.sbt +++ b/build.sbt @@ -312,7 +312,6 @@ lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform) .nativeSettings(monocleNativeSettings) .enablePlugins(NoPublishPlugin) .settings( - scalaVersion := scalaNextVersion, crossScalaVersions := Seq(scalaNextVersion), libraryDependencies ++= Seq(munitDiscipline.value), scalacOptions --= Seq("-release:8", "-Ykind-projector") From b24195e45356ddf9490833f4b77c38d13e327eb5 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 24 Jul 2026 07:06:13 +0200 Subject: [PATCH 18/25] regenerate workflows --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 241181ed4..59d06a378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,11 +105,11 @@ jobs: - 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 + run: mkdir -p unsafe/.js/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 core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: tar cf targets.tar 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 + run: tar cf targets.tar unsafe/.js/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 core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) @@ -297,7 +297,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: From 38dd55f7ea02fe12b27dc25187e035469b43610d Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sat, 25 Jul 2026 07:38:38 +0200 Subject: [PATCH 19/25] regenerate workflows... again? --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d06a378..e5591c7b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,11 +105,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: mkdir -p unsafe/.js/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 core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target project/target + 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 - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: tar cf targets.tar unsafe/.js/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 core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target project/target + run: tar cf targets.tar 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 - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) From 2bcb97ce7fa66571cf49fa4f69516ab5d36129bb Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sat, 25 Jul 2026 21:27:27 +0200 Subject: [PATCH 20/25] fmt --- .../monocle/focus/NamedTupleFocusTest.scala | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala index cdb16dd05..62eb6219a 100644 --- a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala @@ -58,10 +58,13 @@ final class NamedTupleFocusTest extends munit.FunSuite { val newBob = bob.focus(_.address.some.streetNumber).replace(77) assertEquals(streetNumber, Some(5)) - assertEquals(newBob, ( - name = "Bob", - address = Option(streetNumber = 77, postcode = "Bob St") - )) + assertEquals( + newBob, + ( + name = "Bob", + address = Option(streetNumber = 77, postcode = "Bob St") + ) + ) } test("Applied focus returning a Lens in nested named tuples") { @@ -90,20 +93,20 @@ final class NamedTupleFocusTest extends munit.FunSuite { 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") - ) + 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) + 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"))) - } + 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) @@ -120,23 +123,26 @@ final class NamedTupleFocusTest extends munit.FunSuite { val newBob = bob.focus(_.address.streetNumber).replace(77) assertEquals(streetNumber, 5) - assertEquals(newBob, ( - name = "Bob", - address = (streetNumber = 77, postcode = "Bob St") - )) + 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 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 = "Arlen", lastName = "Appleby", yearLevel = 5), (firstName = "Bob", lastName = "Bobson", yearLevel = 6), - (firstName = "Carol", lastName = "Cornell", yearLevel = 7) + (firstName = "Carol", lastName = "Cornell", yearLevel = 7) ) ) @@ -161,7 +167,7 @@ final class NamedTupleFocusTest extends munit.FunSuite { def Animal(name: String): Animal = (name = name) type Owner = (pet: Animal) - def Owner(pet: Animal): Owner = (pet = pet) + def Owner(pet: Animal): Owner = (pet = pet) type Shop = (owner: Owner) def Shop(owner: Owner): Shop = (owner = owner) @@ -175,8 +181,8 @@ final class NamedTupleFocusTest extends munit.FunSuite { 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 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) @@ -252,6 +258,4 @@ final class NamedTupleFocusTest extends munit.FunSuite { assertEquals(Focus[CCInt](_.t).get(cc), 2) } - - } From fca5f6545a090a41e5aa1c010a697df4dd29e665 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Tue, 28 Jul 2026 21:47:16 +0200 Subject: [PATCH 21/25] dont aggregate scalaNextTest, have a separate job for running scalaNextTest/test only on JDK25 --- build.sbt | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index 2531bb27a..c04755133 100644 --- a/build.sbt +++ b/build.sbt @@ -22,11 +22,19 @@ 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 ++= + Seq( + WorkflowStep.Sbt( + List("docs/mdoc"), + name = Some("Run documentation"), + cond = Some(s"matrix.scala == '2.13' && matrix.project == 'rootJVM'") + ), + WorkflowStep.Sbt( + List("scalaNextTestJVM/test"), + name = Some("Run Scala Next Tests"), + cond = Some(s"matrix.java == 'temurin@25' && matrix.scala == '3' && matrix.project == 'rootJVM'") + ) + ), githubWorkflowJavaVersions := Seq( JavaSpec.temurin("11"), JavaSpec.temurin("25") @@ -38,7 +46,6 @@ inThisBuild( ) ) ) - lazy val kindProjector = "org.typelevel" % "kind-projector" % "0.13.4" cross CrossVersion.full lazy val buildSettings = Seq( @@ -169,8 +176,7 @@ lazy val root = tlCrossRootProject.aggregate( unsafe, test, example, - bench, - scalaNextTest + bench ) lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) From 8794ee762ecdfd9807c81bcc2c08cd929b152999 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Tue, 28 Jul 2026 21:49:31 +0200 Subject: [PATCH 22/25] regenerate ci.yml --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5591c7b9..a22ee1dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,10 @@ jobs: if: matrix.scala == '2.13' && matrix.project == 'rootJVM' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' docs/mdoc + - name: Run Scala Next Tests + if: matrix.java == 'temurin@25' && matrix.scala == '3' && matrix.project == 'rootJVM' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestJVM/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 From 7e1ec92e5c0ea0c371709c9934e9d152a85c1c3d Mon Sep 17 00:00:00 2001 From: Aleksander Date: Tue, 28 Jul 2026 22:06:39 +0200 Subject: [PATCH 23/25] add runs for all platform --- .github/workflows/ci.yml | 12 ++++++++++-- build.sbt | 13 +++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a22ee1dc9..cebee1379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,10 +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 - if: matrix.java == 'temurin@25' && matrix.scala == '3' && matrix.project == 'rootJVM' + - 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 diff --git a/build.sbt b/build.sbt index c04755133..5052bf228 100644 --- a/build.sbt +++ b/build.sbt @@ -23,18 +23,19 @@ inThisBuild( crossScalaVersions := Seq(scala2Version, scala3Version), tlCiScalafmtCheck := true, githubWorkflowBuild ++= - Seq( + 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("scalaNextTestJVM/test"), - name = Some("Run Scala Next Tests"), - cond = Some(s"matrix.java == 'temurin@25' && matrix.scala == '3' && matrix.project == 'rootJVM'") + 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") From fb44037ac66e20ca67019a67fff2cedacc1092a0 Mon Sep 17 00:00:00 2001 From: arainko Date: Thu, 30 Jul 2026 15:30:53 +0200 Subject: [PATCH 24/25] add law, XXL named tuple and a no-alias with Focus tests --- build.sbt | 2 +- .../monocle/focus/NamedTupleFocusTest.scala | 69 +++++++++++++++++++ .../monocle/focus/NamedTupleLawTest.scala | 25 +++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleLawTest.scala diff --git a/build.sbt b/build.sbt index 5052bf228..624b062b3 100644 --- a/build.sbt +++ b/build.sbt @@ -313,7 +313,7 @@ lazy val test = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform) - .dependsOn(core, macros) + .dependsOn(test % "test->test") .jvmSettings(monocleJvmSettings) .jsSettings(monocleJsSettings) .nativeSettings(monocleNativeSettings) diff --git a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala index 62eb6219a..94413152b 100644 --- a/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala +++ b/scalaNextTest/shared/src/test/scala-3/monocle/focus/NamedTupleFocusTest.scala @@ -194,6 +194,13 @@ final class NamedTupleFocusTest extends munit.FunSuite { ) } + 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")))), @@ -258,4 +265,66 @@ final class NamedTupleFocusTest extends munit.FunSuite { 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))) +} From bbdb9307db6f95231f9550f7d0612a0b5b7c27d7 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sun, 2 Aug 2026 07:28:50 +0200 Subject: [PATCH 25/25] address feedback (sprinkle some comments around new defs) --- .../src/main/scala-3/monocle/internal/focus/FocusBase.scala | 2 ++ .../scala-3/monocle/internal/focus/features/ParserBase.scala | 2 ++ 2 files changed, 4 insertions(+) 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 722836daa..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 @@ -29,6 +29,8 @@ private[focus] trait FocusBase { 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) 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 ff3b29ef7..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,6 +15,8 @@ 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.simplified