Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
531f084
setup build for editing
arainko Jul 16, 2026
95ea5ac
add scalaNextTests, start sketching out an implementation
Jul 17, 2026
aba706c
inlined nodes seem to be stripped out smh
Jul 17, 2026
c208903
rough POC of name parsing out of named tuples
Jul 17, 2026
52e581c
implement all the stuff inside NamedTuples
Jul 19, 2026
bff16f0
half-working POC
Jul 19, 2026
0cf4de5
fix issue with a non-widened type of 'remainingCode' (it was returnin…
Jul 20, 2026
0d80452
add test for NamedTuple.From
Jul 20, 2026
0c76f73
Lens tests, narrow down another issue
Jul 20, 2026
b2de61d
harden the case class check to simplify match types (needed for named…
arainko Jul 21, 2026
5a33472
cleanup tests
arainko Jul 21, 2026
da8fec7
slightly refactor SlecNamedTupleFieldParser
Jul 23, 2026
5ee1fe9
cleanup build, add tests from their case class equivalent test specs
Jul 23, 2026
8bcaa1d
special case 1-arity named tuples to generate an Iso instead
Jul 23, 2026
79a36f2
restore build to its original state before my dev changes, undo a bun…
Jul 23, 2026
798c39a
Merge branch 'master' into named-tuples-support
arainko Jul 23, 2026
4eb5e3c
move test suite to scala-3 source folder
Jul 23, 2026
5719f34
dont set scalaVersion directly on the scalaNextTest project
Jul 23, 2026
b24195e
regenerate workflows
Jul 24, 2026
38dd55f
regenerate workflows... again?
Jul 25, 2026
2bcb97c
fmt
Jul 25, 2026
fca5f65
dont aggregate scalaNextTest, have a separate job for running scalaNe…
Jul 28, 2026
8794ee7
regenerate ci.yml
Jul 28, 2026
7e1ec92
add runs for all platform
Jul 28, 2026
fb44037
add law, XXL named tuple and a no-alias with Focus tests
arainko Jul 30, 2026
bbdb930
address feedback (sprinkle some comments around new defs)
Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ jobs:
if: matrix.scala == '2.13' && matrix.project == 'rootJVM'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' docs/mdoc

- name: Run Scala Next Tests (jvm)
if: matrix.java == 'temurin@25' && matrix.scala == '3'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestJVM/test

- name: Run Scala Next Tests (js)
if: matrix.java == 'temurin@25' && matrix.scala == '3'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestJS/test

- name: Run Scala Next Tests (native)
if: matrix.java == 'temurin@25' && matrix.scala == '3'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' scalaNextTestNative/test

- name: Make target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
run: mkdir -p unsafe/.js/target generic/.jvm/target law/.js/target macro/.jvm/target unsafe/.native/target state/.jvm/target core/native/target unsafe/.jvm/target macro/.native/target core/js/target macro/.js/target law/.jvm/target generic/.js/target core/jvm/target refined/.native/target law/.native/target refined/.js/target refined/.jvm/target state/.native/target state/.js/target generic/.native/target project/target
Expand Down Expand Up @@ -297,7 +309,7 @@ jobs:
- name: Submit Dependencies
uses: scalacenter/sbt-dependency-submission@v2
with:
modules-ignore: monocle-test_2.13 monocle-test_3 monocle-test_native0.5_2.13 monocle-test_native0.5_3 rootjs_2.13 rootjs_3 monocle-docs_2.13 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 monocle-test_sjs1_2.13 monocle-test_sjs1_3 monocle-example_2.13 monocle-example_3 monocle-bench_2.13 monocle-bench_3
modules-ignore: monocle-test_2.13 monocle-test_3 monocle-test_native0.5_2.13 monocle-test_native0.5_3 rootjs_2.13 rootjs_3 monocle-docs_2.13 scalanexttest_3 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 scalanexttest_native0.5_3 scalanexttest_sjs1_3 monocle-test_sjs1_2.13 monocle-test_sjs1_3 monocle-example_2.13 monocle-example_3 monocle-bench_2.13 monocle-bench_3
configs-ignore: test scala-tool scala-doc-tool test-internal

validate-steward:
Expand Down
39 changes: 30 additions & 9 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,20 @@ inThisBuild(
scalaVersion := scala2Version,
crossScalaVersions := Seq(scala2Version, scala3Version),
tlCiScalafmtCheck := true,
githubWorkflowBuild += WorkflowStep.Sbt(
List("docs/mdoc"),
name = Some("Run documentation"),
cond = Some(s"matrix.scala == '2.13' && matrix.project == 'rootJVM'")
),
githubWorkflowBuild ++=
Vector(
WorkflowStep.Sbt(
List("docs/mdoc"),
name = Some("Run documentation"),
cond = Some(s"matrix.scala == '2.13' && matrix.project == 'rootJVM'")
)
) ++ scalaNextTest.projects.map { case (platform, project) =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here's the what (and the why) on what I did to the build:

  • scalaNextTest is built with Scala 3.8.4 (it could be built with 3.7.x or even 3.6.x but named tuples are experimental in 3.6.x so 3.7.x is the lowest possible version we could really test against) but I didn't find much reasons to not go with the shinest and brightest version,
  • Scala 3.8.x is only built on JDK17+ so I needed to ensure that we're building on JDK25 (because it was already in the build matrix and I didn't want to meddle with the build even further).

Note that this DOES NOT mean the library needs to built against 3.8.4, this is just a small subproject with tests to test my impl.

WorkflowStep.Sbt(
List(s"${project.id}/test"),
name = Some(s"Run Scala Next Tests (${platform.identifier})"),
cond = Some(s"matrix.java == 'temurin@25' && matrix.scala == '3'")
)
},
githubWorkflowJavaVersions := Seq(
JavaSpec.temurin("11"),
JavaSpec.temurin("25")
Expand All @@ -38,7 +47,6 @@ inThisBuild(
)
)
)

lazy val kindProjector = "org.typelevel" % "kind-projector" % "0.13.4" cross CrossVersion.full

lazy val buildSettings = Seq(
Expand Down Expand Up @@ -105,9 +113,10 @@ lazy val buildSettings = Seq(
}
)

lazy val catsVersion = "2.13.0"
lazy val scala2Version = "2.13.18"
lazy val scala3Version = "3.3.8"
lazy val catsVersion = "2.13.0"
lazy val scala2Version = "2.13.18"
lazy val scala3Version = "3.3.8"
lazy val scalaNextVersion = "3.8.4"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have two scala versions here, as a library we want to stay on LTS but it seems 3.8 is only used for new test. Is this because named tuples are not present on LTS?

@arainko arainko Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that's exactly it. I needed a test project to test those new macros

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it mean that the new macros aren't available on a scala LTS version?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are available - there's a branching path that hinges on the availability of Symbol.requiredModule("scala.NamedTuple") - this particular call will resolve to NoSymbol when used from an app that compiles on Scalas lower than 3.6 (where named tuples were an experimental feature) because NamedTuple quite literally doesn't exist on those versions, then if you add monocle as dependency in a Scala 3.6+ project and use focus on a named tuple you'll get proper support for the feature.

Note that a named tuple isn't a proper new language feature, it's just a bunch of sugar on top of opaque type NamedTuple[Names <: Tuple, Values <: Tuple] >: Tuple = Values so supporting it from 3.3.x is fully possible (and implemented in this PR!)


lazy val cats = Def.setting("org.typelevel" %%% "cats-core" % catsVersion)
lazy val catsFree = Def.setting("org.typelevel" %%% "cats-free" % catsVersion)
Expand Down Expand Up @@ -303,6 +312,18 @@ lazy val test = crossProject(JVMPlatform, JSPlatform, NativePlatform)
)
)

lazy val scalaNextTest = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.dependsOn(test % "test->test")
.jvmSettings(monocleJvmSettings)
.jsSettings(monocleJsSettings)
.nativeSettings(monocleNativeSettings)
.enablePlugins(NoPublishPlugin)
.settings(
crossScalaVersions := Seq(scalaNextVersion),
libraryDependencies ++= Seq(munitDiscipline.value),
scalacOptions --= Seq("-release:8", "-Ykind-projector")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this gets compiled under 3.8.4 I had to get rid of those two options (tbh only the -release one was a hard error due to 3.8.x shifting to JDK17, the kind projector one was a deprecation warning since it was promoted to an -X option)

)

lazy val bench = project
.dependsOn(core.jvm, generic.jvm, macros.jvm)
.settings(moduleName := "monocle-bench")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package monocle.internal.focus

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

private[focus] trait FocusBase {
val macroContext: Quotes
Expand All @@ -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 {
Expand All @@ -22,6 +25,14 @@ private[focus] trait FocusBase {
fromCompanion: Term,
toType: TypeRepr
)
case SelectNamedTupleField(
fieldName: String,
fromDescription: NamedTuples.Description,
toType: TypeRepr,
// storing the whole NamedTuples helper class might feel weird but it's the best way one can describe that this focus action is only possible
// when named tuples are actually supported (i.e. on Scala > 3.7.x)
namedTuples: NamedTuples

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might feel weird to carry the whole helper class around but IMO it's the cleanest way of saying 'this is only valid if named tuples are supported' (+ it has a bunch of uses in SelectNamedTupleFieldGenerator)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment about this in the code?

)
case KeywordSome(toType: TypeRepr)
case KeywordAs(fromType: TypeRepr, toType: TypeRepr)
case KeywordEach(fromType: TypeRepr, toType: TypeRepr, eachInstance: Term)
Expand All @@ -34,6 +45,8 @@ private[focus] trait FocusBase {
s"SelectField($fieldName, ${fromType.show}, ${fromTypeArgs.map(_.show)}, ${toType.show})"
case SelectOnlyField(fieldName, fromType, fromTypeArgs, _, toType) =>
s"SelectOnlyField($fieldName, ${fromType.show}, ${fromTypeArgs.map(_.show)}, ..., ${toType.show})"
case SelectNamedTupleField(fieldName, fromType, toType, _) =>
s"SelectNamedTupleField($fieldName, ${fromType.show}, ${toType.show})"
case KeywordSome(toType) => s"KeywordSome(${toType.show})"
case KeywordAs(fromType, toType) => s"KeywordAs(${fromType.show}, ${toType.show})"
case KeywordEach(fromType, toType, _) => s"KeywordEach(${fromType.show}, ${toType.show}, ...)"
Expand All @@ -58,4 +71,108 @@ private[focus] trait FocusBase {
}

type FocusResult[+A] = Either[FocusError, A]

// unappliedNamedTuple is the type lambda [Names, Values] =>> NamedTuple[Names, Values], used to harvest its type symbol later on
final class NamedTuples private (private val unappliedNamedTuple: TypeRepr, val companion: Symbol) {
def isNamedTuple(tpe: TypeRepr) =
tpe.dealias.typeSymbol == unappliedNamedTuple.typeSymbol

// a call to NamedTuple.toTuple[Names <: Tuple, Values <: Tuple](tup: NamedTuple.NamedTuple[Names, Values]): Values
def toTuple(term: Term, description: NamedTuples.Description) =
Select
.unique(Ident(companion.termRef), "toTuple")
.appliedToTypes(description.namesTpe :: description.valuesTpe :: Nil)
.appliedTo(term)

def accessFieldByName(term: Term, action: FocusAction.SelectNamedTupleField): Term = {

@arainko arainko Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this essentially comes down to something like this:

// let's say the field we want to access is 'field2'
val someNamedTuple = (field1 = 1, field2 = 2)
someNamedTuple.toTuple._2 // but ONLY when the result of .toTuple actually has a ._N accessor, there are cases where it might not have it (see unsafeAccessFieldByIndex doc comment)

And if you're curious why we aren't doing the same thing the compiler is doing underneath (someNamedTuple.apply(1) in this case) - it's the match type return type again (Elem[NamedTuple[N, V], n.type]), those things are a major annoyance when constructing trees by hand and I just couldn't get it to behave.

val idxOfName = action.fromDescription.names.indexOf(action.fieldName)
val asTuple = toTuple(term, action.fromDescription)
unsafeAccessFieldByIndex(asTuple, action.fromDescription, idxOfName)
}

def reconstructWithUpdatedField(from: Term, action: FocusAction.SelectNamedTupleField, updatedValue: Term) = {
val updatedFieldIdx = action.fromDescription.names.indexOf(action.fieldName)
val asTuple = toTuple(from, action.fromDescription)
val values =
Vector.tabulate(action.fromDescription.values.size) { idx =>
if (idx == updatedFieldIdx) updatedValue.asExpr
else unsafeAccessFieldByIndex(asTuple, action.fromDescription, idx).asExpr
}
construct(action.fromDescription, values)
}

// NamedTuple >: Tuple so to 'construct' a named tuple we can just upcast an ordinary Tuple to a NamedTuple
def construct(description: NamedTuples.Description, values: Seq[Expr[Any]]): Term =
Typed(Expr.ofTupleFromSeq(values).asTerm, TypeTree.of(using description.sourceType.asType))

// there's a chance that we're operating on a non-normalized (non TupleN) tuple (for example when N is > 22 or when using NamedTuple.From)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have tests for tuples with more than 22 named params?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are tests for named tuples derived from NamedTuple.From but not explicit > 22 ones, I'll toss a test or two in.

// in which case we need to fall back to using Product methods since TupleXXL <: scala.Product and '*:' (tuple cons) <: Product AND doesn't get _N accessors
private def unsafeAccessFieldByIndex(asTuple: Term, description: NamedTuples.Description, index: Int) = {
val tupleAccessor = s"_${index + 1}"

if (asTuple.tpe.typeSymbol.fieldMember(tupleAccessor).exists) {
Select.unique(asTuple, tupleAccessor)
} else {
val tpeAtIndex = description.values(index)
(asTuple.asExpr, tpeAtIndex.asType) match {
case '{ $prod: scala.Product } -> '[tpe] =>
'{ $prod.productElement(${ Expr(index) }).asInstanceOf[tpe] }.asTerm
}
}
}

def describe(sourceType: TypeRepr): Option[NamedTuples.Description] =
sourceType.dealias.simplified match {
case tpe @ AppliedType(_, namesTpe :: valuesTpe :: Nil) if isNamedTuple(tpe) =>
Some(
NamedTuples.Description(
unrollStrings(namesTpe),
unroll(valuesTpe),
sourceType,
namesTpe,
valuesTpe
)
)
case _ => None
}

private def unrollStrings(tp: TypeRepr): Vector[String] =
unroll(tp).map { case ConstantType(StringConstant(l)) => l }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a partial match? coul iever produce a match error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it gets fed a a TypeRepr that isn't a tuple type of literal Strings (like ("field1", "field2, "field3") then yeah - but a well formed named tuple is always encoded as a type of: NamedTuple.NamedTuple[Names, Values] where Names is ALWAYS a tuple of string literals.


private def unroll(tpe: TypeRepr): Vector[TypeRepr] = {
@tailrec def loop(curr: Type[?], acc: Vector[TypeRepr]): Vector[TypeRepr] =
curr match {
case '[head *: tail] =>
loop(Type.of[tail], acc.appended(TypeRepr.of[head]))
case '[EmptyTuple] =>
acc
}

loop(tpe.asType, Vector.empty)
}

}

object NamedTuples {
val Support: Option[NamedTuples] = {
val companion = Symbol.requiredModule("scala.NamedTuple")

companion
.declaredType("NamedTuple")
.headOption
.map(sym => NamedTuples(sym.typeRef, companion))
}

case class Description private[NamedTuples] (
names: Vector[String],
values: Vector[TypeRepr],
sourceType: TypeRepr,
namesTpe: TypeRepr,
valuesTpe: TypeRepr
) {
def show: String =
s"Description($names, ${values.map(_.show)}, ${sourceType.show}, ${namesTpe.show}, ${valuesTpe.show})"
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ private[focus] trait AllFeatureGenerators
extends FocusBase
with SelectFieldGenerator
with SelectOnlyFieldGenerator
with SelectNamedTupleFieldGenerator
with SomeGenerator
with AsGenerator
with EachGenerator
Expand All @@ -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] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ private[focus] trait ParserBase {
def unapply(term: Term): Option[FocusResult[(RemainingCode, FocusAction)]]
}

// the '.simplified' call here is needed because otherwise if an unreduced match type arrives at this call site we're greeted with a compiler barf, like:
// 'Cannot get type of value [...]' (note that this is especially important for terms that describe a named tuple field access which is typed as 'Elem[NamedTuple[N, V], n.type]' which IS a match type).
def getType(code: Term): TypeRepr =
code.tpe.widen.dealias
code.tpe.widen.dealias.simplified

@arainko arainko Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .simplified call here (and later on) is needed because otherwise if an unreduced match type (or NamedTuple.From) arrives at this call site we're greeted with a compiler barf, something akin to:

Cannot get type of value [...]

As a reminder, the way the compiler extracts a field from a named tuple like someNamedTuple.someField is to call NamedTuple.apply(someNamedTuple)(<index of someField)>) (which is again, a desugared form of an extension call like someNamedTuple.apply(<index of someField>)) and that method is defined as:

extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V])
      /** The value (without the name) at index `n` of this tuple. */
    inline def apply(n: Int): Elem[NamedTuple[N, V], n.type] =
      x.toTuple.apply(n).asInstanceOf[Elem[NamedTuple[N, V], n.type]]

emphasis on the return type: Elem[NamedTuple[N, V], n.type] which IS a match type so we need to reduce those (which .simplified does)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you put this as a comment on the file for future maintainers?


}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ private[focus] trait AllFeatureParsers
with KeywordParserBase
with SelectFieldParser
with SelectOnlyFieldParser
with SelectNamedTupleFieldParser
with SomeParser
with AsParser
with EachParser
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ private[focus] trait SelectParserBase extends ParserBase {
// Match on a term that is an instance of a case class
object CaseClass {
def unapply(term: Term): Option[Term] =
term.tpe.classSymbol.flatMap { sym =>
term.tpe.simplified.classSymbol.flatMap { sym =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same reason for that .simplified addition as in https://github.com/optics-dev/Monocle/pull/1610/changes#r3641484068

Option.when(sym.flags.is(Flags.Case))(term)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package monocle.internal.focus.features.selectfield

import monocle.internal.focus.FocusBase
import monocle.Lens
import monocle.Iso
import scala.quoted.Quotes
import scala.quoted.Expr

private[focus] trait SelectNamedTupleFieldGenerator {
this: FocusBase =>

import macroContext.reflect.*

def generateSelectNamedTupleField(action: FocusAction.SelectNamedTupleField): Term = {
def generateGetter(from: Term): Term = action.namedTuples.accessFieldByName(from, action)

def generateSetter(from: Term, to: Term): Term =
action.namedTuples.reconstructWithUpdatedField(from, action, to)

def generateReverseGet(to: Expr[Any]): Term =
action.namedTuples.construct(action.fromDescription, Vector(to))

(action.fromDescription.sourceType.asType, action.toType.asType) match {
case ('[f], '[t]) =>
if (action.fromDescription.values.size == 1) {

@arainko arainko Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alright so you might be wondering why this weird check - case classes have a whole separate Generator/Parser for when they have a single field and multiple fields so why not just follow suit?

That's because I've already got all of the information that I might need from a single SelectNamedTupleFieldParser call which provides us with a NamedTuples.Description which is more or less a full description of a given named tuple (its field names, value types, all the other goodies) which I then just reuse here (with the one caveat being this size check here since I wanted to follow the case class behavior as close as possible and generate an Iso if we're operating on a single-field named tuple).

'{
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
}
}

}
}
Loading
Loading