From 2789b79c334848f684fbc7dedec54f0fb59f7a50 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 21:11:17 +0200 Subject: [PATCH 01/14] feat(core): compose Affine -> Getter (Optional/AffineFold andThen Getter = AffineFold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composing a partial optic with a read-only Getter now works and yields an AffineFold (a partial read): getOption the focus, then read it through the getter. A read-write/partial outer's back-focus B = A can't thread a Getter's Unit back-focus as a writable optic, so the read-only downgrade is the sound (and only) result — the mirror of how a Getter chain composes Getter-to-Getter. - Optional (concrete, read-write) andThen Getter: a new fused member on the Optional class (alongside its Optional/Lens/Prism/Iso fused andThens), returning AffineFold[S, C]. A member (not an extension) so it doesn't perturb the existing cross-carrier andThen resolution (e.g. Affine andThen MultiFocus). - AffineFold (read-only) andThen Getter: already composes via the morphed path (Morph[Affine, Direct] -> AffineFold); covered by the new test. Tests in OpticsBehaviorSpec (both outers -> AffineFold, hit/miss); docs note in optics.md. Full `sbt test` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/optics/Optional.scala | 11 +++++++++ site/docs/optics.md | 9 +++++++ .../constructive/eo/OpticsBehaviorSpec.scala | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala index ab05c30a..2d37abc4 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala @@ -134,3 +134,14 @@ final class Optional[S, T, A, B]( innerHit = (_, a) => Right(inner.get(a)), innerWrite = (_, d) => inner.reverseGet(d), ) + + /** Fused `Optional.andThen(Getter)` — a read-only inner collapses the partial optic to a partial + * *read*: `getOption` the focus, then read it through the getter. The result is an + * [[AffineFold]] (read-only `T = B = Unit`), because a `Getter`'s `Unit` back-focus cannot + * thread through the Optional's writable `B` — composing as a writable optic is impossible by + * construction, so the read-only downgrade is the sound result ("optionally focus `A`, then read + * `C`"). So `Optional[S, A].andThen(Getter[A, C]) = AffineFold[S, C]`. The mirror of how a + * `Getter` chain composes `Getter`-to-`Getter`. + */ + def andThen[C](inner: DirectGetter[A, C]): AffineFold[S, C] = + AffineFold(s => getOrModify(s).toOption.map(inner.get)) diff --git a/site/docs/optics.md b/site/docs/optics.md index be45abae..a628e367 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -188,6 +188,15 @@ summons `Composer[Tuple2, Affine]` under the hood and morphs the Lens into the Affine carrier. No explicit `.morph` required on your end. +Composing the *other* way — an `Optional` (or `AffineFold`) +`.andThen` a read-only `Getter` — yields an **`AffineFold`**: the +read-only inner collapses the result to a partial *read* +(`getOption` the focus, then read it through the getter). A +`Getter`'s `Unit` back-focus can't thread through the Optional's +writable `B`, so the read-only downgrade is the only sound result +— "optionally focus `A`, then read `C`", the mirror of how a +`Getter` chain composes `Getter`-to-`Getter`. + ### AffineFold (read-only) The read-only projection of an Affine — a 0-or-1 focus with no diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index 756f4625..621c5cfa 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -314,6 +314,30 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: .and(chainOk) } + // covers: the Affine-carrier `.andThen(Getter)` bridge — composing a partial/read-write Optional + // (or a read-only AffineFold) with a read-only Getter collapses to an AffineFold (partial read), + // via getOption ∘ get. A read-write outer's B = A can't thread a Getter's Unit back-focus as a + // writable optic, so the read-only downgrade is the sound (and only) result. Both the read-write + // Optional outer and the read-only AffineFold outer route through the same overload. + "Affine.andThen(Getter): Optional / AffineFold composed with a Getter yields an AffineFold" >> { + val toStr = Getter[Int, String](_.toString) + + val ageOpt: Optional[AdultPerson, AdultPerson, Int, Int] = + Optional[AdultPerson, AdultPerson, Int, Int, Affine]( + getOrModify = p => Either.cond(p.age >= 18, p.age, p), + reverseGet = { case (_, a) => AdultPerson(a) }, + ) + val optThenG: AffineFold[AdultPerson, String] = ageOpt.andThen(toStr) + val optOk = (optThenG.getOption(AdultPerson(20)) === Some("20")) + .and(optThenG.getOption(AdultPerson(15)) === None) + + val afThenG: AffineFold[AdultPerson, String] = adultAgeAF.andThen(toStr) + val afOk = (afThenG.getOption(AdultPerson(18)) === Some("18")) + .and(afThenG.getOption(AdultPerson(10)) === None) + + optOk.and(afOk) + } + // ----- Review behaviour ---------------------------------------------- // covers: Review.apply wraps an A => S build function; Review is a proper Optic From 2e26f949f7e230d2826c17de949a97140a84c593 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 13:00:43 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat(core):=20general=20optic=E2=88=98Get?= =?UTF-8?q?ter/Setter=20collapse=20via=20Morph;=20def-encoded=20to/from?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes, validated by three CI A/B benchmark pairs: 1. Read-only / write-only cross-carrier andThen, routed through the Accessor/Morph machinery (no per-class fused overloads): - andThenReadOnly: total readers (Accessor[F] — Iso/Getter/Lens) collapse to DirectGetter — lens.andThen(getter) = Getter. - andThenAffineFold: partial readers (NotGiven[Accessor[F]] — Optional/AffineFold/Prism) compose their new `readOnly` projection through Morph-to-Affine (rightToLeft for Affine, bothViaAffine for Either) — optional/prism.andThen(getter) = AffineFold. - andThenSetter: writable carriers morph through Composer[·, SetterF] and re-wrap concrete — lens/optional.andThen(setter) = Setter. - readOnly: the sound write-forgetting projection (T = B = Unit) whose Unit seam makes the partial collapse line up. 2. Optic.to/from encoded as methods (def) instead of stored Function1 vals, across every optic, carrier, and integration: - Construction: −36–41% B/op on composed-lens build (the two eager closures per optic are gone). - Steady-state: broad small CI wins (AffineFold/Optional/Setter/ Traversal/Avro/Plated, −5–25%). - DirectGetter/Review stay FINAL classes with the function as a val param + fused per-site andThen: the abstract-class variant centred all leaf dispatch in one shared wrapper body, going megamorphic (eoGet_3 +219%, eoAna +17% on CI; PrintInlining showed the Function1.apply sites left as virtual calls). Final-class restores the old-fused baseline (CI-confirmed, eoGet_3/6 −4%). - Big-bodied integration optics (JsoniterPrism) keep def to/from as 1-call forwarders to private methods so the entry stays a few bytes and inlines at every use site instead of blowing the caller's C2 budget ("callee is too large" with direct bodies). ReviewBench: new build-direction mirror of GetterBench (pre-built depth-0/3/6 reverseGet vs hand-rolled floor) — Review composition was previously unbenchmarked; composed Reviews now allocate identically to hand-written builders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/avro/AvroPrism.scala | 10 +- .../constructive/eo/bench/ReviewBench.scala | 51 ++++++ .../eo/bench/fixture/NestedOptics.scala | 18 +++ .../dev/constructive/eo/circe/JsonPrism.scala | 10 +- .../circe/CrossCarrierCompositionSpec.scala | 4 +- .../scala/dev/constructive/eo/Composer.scala | 12 +- .../dev/constructive/eo/data/Affine.scala | 8 +- .../dev/constructive/eo/data/MultiFocus.scala | 147 +++++++++--------- .../dev/constructive/eo/data/SetterF.scala | 4 +- .../constructive/eo/optics/AffineFold.scala | 4 +- .../dev/constructive/eo/optics/Fold.scala | 5 +- .../dev/constructive/eo/optics/Getter.scala | 50 +++--- .../dev/constructive/eo/optics/Iso.scala | 4 +- .../dev/constructive/eo/optics/Lens.scala | 17 +- .../dev/constructive/eo/optics/Optic.scala | 100 ++++++++++-- .../dev/constructive/eo/optics/Optional.scala | 15 +- .../dev/constructive/eo/optics/Prism.scala | 8 +- .../dev/constructive/eo/optics/Review.scala | 8 +- .../dev/constructive/eo/optics/Setter.scala | 4 +- .../constructive/eo/optics/Traversal.scala | 56 +++---- .../eo/jsoniter/JsoniterPrism.scala | 15 +- .../eo/jsoniter/JsoniterTraversal.scala | 4 +- site/docs/optics.md | 27 +++- .../scala/dev/constructive/eo/JsonOptic.scala | 10 +- .../constructive/eo/OpticsBehaviorSpec.scala | 90 +++++++---- 25 files changed, 435 insertions(+), 246 deletions(-) create mode 100644 benchmarks/src/main/scala/dev/constructive/eo/bench/ReviewBench.scala diff --git a/avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala b/avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala index 4a722f2f..fe0d631a 100644 --- a/avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala +++ b/avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala @@ -59,7 +59,7 @@ final class AvroPrism[A] private[avro] ( // ---- Abstract Optic members --------------------------------------- - def to: IndexedRecord => Either[(AvroFailure, IndexedRecord), A] = record => + def to(record: IndexedRecord): Either[(AvroFailure, IndexedRecord), A] = focus.navigateRaw(record) match case Left(failure) => Left((failure, record)) case Right(any) => @@ -68,10 +68,10 @@ final class AvroPrism[A] private[avro] ( case Left(t) => Left((AvroFailure.DecodeFailed(AvroWalk.terminalOf(path), t), record)) - def from: Either[(AvroFailure, IndexedRecord), A] => IndexedRecord = { - case Left((_, record)) => record - case Right(a) => reverseGet(a) - } + def from(e: Either[(AvroFailure, IndexedRecord), A]): IndexedRecord = + e match + case Left((_, record)) => record + case Right(a) => reverseGet(a) // ---- Read surface -------------------------------------------------- diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/ReviewBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/ReviewBench.scala new file mode 100644 index 00000000..15feef50 --- /dev/null +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/ReviewBench.scala @@ -0,0 +1,51 @@ +package dev.constructive.eo +package bench + +import org.openjdk.jmh.annotations.* +import java.util.concurrent.TimeUnit + +import dev.constructive.eo.bench.fixture.* + +/** `Review.reverseGet` at the leaf plus deep composition — the BUILD-direction mirror of + * [[GetterBench]], and the coverage the fused [[dev.constructive.eo.optics.Review.andThen]] needs. + * + * EO `Review`s are `Optic[Unit, S, Unit, A, Direct]` — vestigial `to`, a real `from`/`reverseGet` + * that builds `S` from the focus `A`. They compose through the fused `Review.andThen(Review)` just + * as `Getter`s do, so the depth-3 / depth-6 rows build a *composed* `Review` **once, outside the + * hot path** (`private val`, so construction happens at fork start, not per-invocation — this is + * the construction/usage split [[CompositionBench]] measures the other half of), then dispatch + * `reverseGet` through it. Monocle has no `Review`, so the peer is a hand-written builder (the + * floor: plain nested `new`s, no optic). + */ +@State(Scope.Benchmark) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(3) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +class ReviewBench extends JmhDefaults: + + import NestedOptics.{eoR1, eoR2, eoR3, eoR4, eoR5, eoR6, eoRValue} + + // Composed Reviews built once at fork start (out of the measured method), so + // each @Benchmark isolates `reverseGet` dispatch from the per-`andThen` + // construction cost CompositionBench measures separately. + private val eoReview3 = eoR3.andThen(eoR2).andThen(eoR1).andThen(eoRValue) + + private val eoReview6 = + eoR6.andThen(eoR5).andThen(eoR4).andThen(eoR3).andThen(eoR2).andThen(eoR1).andThen(eoRValue) + + private val seed: Int = 42 + + @Benchmark def eoReverseGet_0: Nested0 = eoRValue.reverseGet(seed) + @Benchmark def eoReverseGet_3: Nested3 = eoReview3.reverseGet(seed) + @Benchmark def eoReverseGet_6: Nested6 = eoReview6.reverseGet(seed) + + // Floor: the same nodes built by hand, no optic. + @Benchmark def naiveBuild_0: Nested0 = Nested0(seed, None, Nil) + + @Benchmark def naiveBuild_3: Nested3 = + Nested3(Nested2(Nested1(Nested0(seed, None, Nil)))) + + @Benchmark def naiveBuild_6: Nested6 = + Nested6(Nested5(Nested4(Nested3(Nested2(Nested1(Nested0(seed, None, Nil))))))) diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala index 4efed830..444f5ee3 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala @@ -7,6 +7,7 @@ import dev.constructive.eo.optics.{ Getter => EoGetter, Lens => EoLens, Optional => EoOptional, + Review => EoReview, Setter => EoSetter, } @@ -150,3 +151,20 @@ object NestedOptics: val mSet6: MSetter[Nested6, Int] = mS6.andThen(mS5).andThen(mS4).andThen(mS3).andThen(mS2).andThen(mS1).andThen(mSetValue) + + // ---- Per-level EO Reviews (Direct carrier, BUILD direction) ----- + // + // The mirror of the Getters: a Review[Nested(k), Nested(k-1)] builds the + // parent node from a child (`reverseGet`), and the leaf Review[Nested0, Int] + // builds a populated leaf from an Int. Composed via the fused + // `Review.andThen(Review)`, the depth-3 / depth-6 chains build a whole + // Nested(k) from a single Int. Monocle ships no Review peer, so ReviewBench + // compares against a hand-written builder (the floor) instead. + + val eoRValue = EoReview[Nested0, Int](i => Nested0(i, None, Nil)) + val eoR1 = EoReview[Nested1, Nested0](Nested1(_)) + val eoR2 = EoReview[Nested2, Nested1](Nested2(_)) + val eoR3 = EoReview[Nested3, Nested2](Nested3(_)) + val eoR4 = EoReview[Nested4, Nested3](Nested4(_)) + val eoR5 = EoReview[Nested5, Nested4](Nested5(_)) + val eoR6 = EoReview[Nested6, Nested5](Nested6(_)) diff --git a/circe/src/main/scala/dev/constructive/eo/circe/JsonPrism.scala b/circe/src/main/scala/dev/constructive/eo/circe/JsonPrism.scala index 12e7a5fe..b50dc405 100644 --- a/circe/src/main/scala/dev/constructive/eo/circe/JsonPrism.scala +++ b/circe/src/main/scala/dev/constructive/eo/circe/JsonPrism.scala @@ -46,16 +46,16 @@ final class JsonPrism[A] private[circe] ( // ---- Abstract Optic members --------------------------------------- - def to: Json => Either[(DecodingFailure, HCursor), A] = json => + def to(json: Json): Either[(DecodingFailure, HCursor), A] = val c = focus.navigateCursor(json) focus.decodeFromCursor(c) match case Right(a) => Right(a) case Left(df) => Left((df, json.hcursor)) - def from: Either[(DecodingFailure, HCursor), A] => Json = { - case Left((_, hc)) => hc.top.getOrElse(hc.value) - case Right(a) => focus.encoder(a) - } + def from(e: Either[(DecodingFailure, HCursor), A]): Json = + e match + case Left((_, hc)) => hc.top.getOrElse(hc.value) + case Right(a) => focus.encoder(a) // ---- Read surface (single-focus specific) ------------------------- diff --git a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala index 1882b832..91d442ed 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala @@ -195,8 +195,8 @@ class CrossCarrierCompositionSpec extends Specification: val tagsMF: Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]] = new Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]]: type X = Unit - val to: List[Int] => (Unit, List[List[Int]]) = xs => ((), List(xs)) - val from: ((Unit, List[List[Int]])) => List[Int] = { case (_, xss) => xss.head } + def to(xs: List[Int]): (Unit, List[List[Int]]) = ((), List(xs)) + def from(pair: (Unit, List[List[Int]])): List[Int] = pair._2.head // The cross-carrier .andThen resolves through `either2multifocus[List]`. val chain: Optic[Json, Json, List[Int], List[Int], MultiFocus[List]] = diff --git a/core/src/main/scala/dev/constructive/eo/Composer.scala b/core/src/main/scala/dev/constructive/eo/Composer.scala index 6dd9b016..b1be9927 100644 --- a/core/src/main/scala/dev/constructive/eo/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/Composer.scala @@ -38,8 +38,8 @@ object Composer extends LowPriorityComposerInstances: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, Tuple2] = new Optic[S, T, A, B, Tuple2]: type X = Unit - val to: S => (X, A) = s => ((), o.to(s).value) - val from: ((X, B)) => T = pair => o.from(Direct(pair._2)) + def to(s: S): (X, A) = ((), o.to(s).value) + def from(pair: (X, B)): T = o.from(Direct(pair._2)) /** Express an Iso (or Getter) as a Prism — always takes the `Right` branch; `Nothing` in the * `Left` slot so the miss branch is uninhabited. @@ -51,8 +51,8 @@ object Composer extends LowPriorityComposerInstances: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, Either] = new Optic[S, T, A, B, Either]: type X = Nothing - val to: S => Either[X, A] = s => Right(o.to(s).value) - val from: Either[X, B] => T = e => + def to(s: S): Either[X, A] = Right(o.to(s).value) + def from(e: Either[X, B]): T = e match case Right(b) => o.from(Direct(b)) case Left(_) => ??? @@ -72,8 +72,8 @@ object Composer extends LowPriorityComposerInstances: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, data.Forget[F]] = new Optic[S, T, A, B, data.Forget[F]]: type X = Nothing - val to: S => data.Forget[F][X, A] = s => F.pure(o.to(s).value) - val from: data.Forget[F][X, B] => T = _ => ??? + def to(s: S): data.Forget[F][X, A] = F.pure(o.to(s).value) + def from(u: data.Forget[F][X, B]): T = ??? /** Low-priority `Composer` instances — * [[LowPriorityComposerInstances.chainViaTuple2 chainViaTuple2]], a transitive derivation pinned diff --git a/core/src/main/scala/dev/constructive/eo/data/Affine.scala b/core/src/main/scala/dev/constructive/eo/data/Affine.scala index 977b5789..c62a121b 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -212,11 +212,11 @@ object Affine: def to[S, T, A, B](o: Optic[S, T, A, B, Tuple2]): Optic[S, T, A, B, Affine] = new Optic[S, T, A, B, Affine]: type X = (T, o.X) - val to: S => Affine[X, A] = s => + def to(s: S): Affine[X, A] = val (xo, a) = o.to(s) new Hit[X, A](xo, a) - val from: Affine[X, B] => T = a => + def from(a: Affine[X, B]): T = a match case m: Miss[X, B] => m.fst case h: Hit[X, B] => o.from((h.snd, h.b)) @@ -234,11 +234,11 @@ object Affine: def to[S, T, A, B](o: Optic[S, T, A, B, Either]): Optic[S, T, A, B, Affine] = new Optic[S, T, A, B, Affine]: type X = (o.X, S) - val to: S => Affine[X, A] = s => + def to(s: S): Affine[X, A] = o.to(s) match case Right(a) => new Hit[X, A](s, a) case Left(x) => new Miss[X, A](x) - val from: Affine[X, B] => T = xb => + def from(xb: Affine[X, B]): T = xb match case m: Miss[X, B] => o.from(Left(m.fst)) case h: Hit[X, B] => o.from(Right(h.b)) diff --git a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index 278eb3f1..bb669c92 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -514,8 +514,8 @@ object MultiFocus: def apply[F[_], A]: Optic[F[A], F[A], A, A, MultiFocus[F]] = new Optic[F[A], F[A], A, A, MultiFocus[F]]: type X = F[A] - val to: F[A] => (F[A], F[A]) = (fa: F[A]) => (fa, fa) - val from: ((F[A], F[A])) => F[A] = { case (_, fb) => fb } + def to(fa: F[A]): (F[A], F[A]) = (fa, fa) + def from(pair: (F[A], F[A])): F[A] = pair._2 /** Iso → MultiFocus[F]. Requires `Applicative[F]` to broadcast the Iso's plain `A` focus into a * singleton `F[A]`. @@ -525,10 +525,9 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Unit - val to: S => (Unit, F[A]) = s => ((), Applicative[F].pure(o.to(s).value)) - val from: ((Unit, F[B])) => T = { - case (_, fb) => o.from(Direct(pickSingletonOrThrow(fb, "Direct"))) - } + def to(s: S): (Unit, F[A]) = ((), Applicative[F].pure(o.to(s).value)) + def from(pair: (Unit, F[B])): T = + o.from(Direct(pickSingletonOrThrow(pair._2, "Direct"))) /** Forget[F] ↪ MultiFocus[F]. */ given forget2multifocus[F[_]]: Composer[Forget[F], MultiFocus[F]] with @@ -536,8 +535,8 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Forget[F]]): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Unit - val to: S => (Unit, F[A]) = s => ((), o.to(s)) - val from: ((Unit, F[B])) => T = { case (_, fb) => o.from(fb) } + def to(s: S): (Unit, F[A]) = ((), o.to(s)) + def from(pair: (Unit, F[B])): T = o.from(pair._2) /** MultiFocus[F] ↪ Forget[F] — read-only escape: discard the structural leftover, keep the * focused `F[A]`. Closes the top-5 plan's gap #2 by giving users an explicit carrier morph @@ -563,8 +562,8 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, MultiFocus[F]]): Optic[S, T, A, B, Forget[F]] = new Optic[S, T, A, B, Forget[F]]: type X = Unit - val to: S => F[A] = s => o.to(s)._2 - val from: F[B] => T = _ => + def to(s: S): F[A] = o.to(s)._2 + def from(fb: F[B]): T = // Reachable only when `T = Unit` (Forget-carrier optics have T = Unit by construction). // The cast surfaces a ClassCastException if a user's MultiFocus optic has T ≠ Unit AND // they explicitly routed through this Composer — defensive only. @@ -578,13 +577,11 @@ object MultiFocus: type X = o.X def singletonTo(s: S): (o.X, A) = o.to(s) def singletonFrom(x: o.X, b: B): T = o.from((x, b)) - val to: S => (X, F[A]) = s => + def to(s: S): (X, F[A]) = val (x, a) = o.to(s) (x, Applicative[F].pure(a)) - val from: ((X, F[B])) => T = { - case (x, fb) => - o.from((x, pickSingletonOrThrow(fb, "Tuple2"))) - } + def from(pair: (X, F[B])): T = + o.from((pair._1, pickSingletonOrThrow(pair._2, "Tuple2"))) /** Prism → MultiFocus[F]. */ given either2multifocus[F[_]: Alternative: Foldable]: Composer[Either, MultiFocus[F]] with @@ -592,14 +589,14 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Either]): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Either[o.X, Unit] - val to: S => (X, F[A]) = s => + def to(s: S): (X, F[A]) = o.to(s) match case Right(a) => (Right(()), Applicative[F].pure(a)) case Left(x) => (Left(x), Alternative[F].empty[A]) - val from: ((X, F[B])) => T = { - case (Left(xMiss), _) => o.from(Left(xMiss)) - case (Right(_), fb) => o.from(Right(pickSingletonOrThrow(fb, "Either"))) - } + def from(pair: (X, F[B])): T = + pair match + case (Left(xMiss), _) => o.from(Left(xMiss)) + case (Right(_), fb) => o.from(Right(pickSingletonOrThrow(fb, "Either"))) /** Optional → MultiFocus[F]. */ given affine2multifocus[F[_]: Alternative: Foldable]: Composer[Affine, MultiFocus[F]] with @@ -607,18 +604,18 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Affine]): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Either[Fst[o.X], Snd[o.X]] - val to: S => (X, F[A]) = s => + def to(s: S): (X, F[A]) = o.to(s) match case h: Affine.Hit[o.X, A] => (Right(h.snd), Applicative[F].pure(h.b)) case m: Affine.Miss[o.X, A] => (Left(m.fst), Alternative[F].empty[A]) - val from: ((X, F[B])) => T = { - case (Left(fstX), _) => - o.from(new Affine.Miss[o.X, B](fstX)) - case (Right(sndX), fb) => - o.from(new Affine.Hit[o.X, B](sndX, pickSingletonOrThrow(fb, "Affine"))) - } + def from(pair: (X, F[B])): T = + pair match + case (Left(fstX), _) => + o.from(new Affine.Miss[o.X, B](fstX)) + case (Right(sndX), fb) => + o.from(new Affine.Hit[o.X, B](sndX, pickSingletonOrThrow(fb, "Affine"))) // PSVec-specialised Composer instances. PSVec admits neither Applicative nor Alternative // naturally; these use `PSVec.singleton` / `PSVec.empty` directly. The Tuple2 bridge specialises @@ -638,19 +635,17 @@ object MultiFocus: val lens = glr.asInstanceOf[optics.GetReplaceLens[S, T, A, B]] new Optic[S, T, A, B, MultiFocus[PSVec]] with MultiFocusSingleton[S, T, A, B, S]: type X = S - val to: S => (S, PSVec[A]) = s => (s, PSVec.singleton[A](lens.get(s))) - val from: ((S, PSVec[B])) => T = { case (s, vs) => lens.enplace(s, vs.head) } + def to(s: S): (S, PSVec[A]) = (s, PSVec.singleton[A](lens.get(s))) + def from(pair: (S, PSVec[B])): T = lens.enplace(pair._1, pair._2.head) def singletonTo(s: S): (S, A) = (s, lens.get(s)) def singletonFrom(x: S, b: B): T = lens.enplace(x, b) case _ => new Optic[S, T, A, B, MultiFocus[PSVec]] with MultiFocusSingleton[S, T, A, B, o.X]: type X = o.X - val to: S => (o.X, PSVec[A]) = s => + def to(s: S): (o.X, PSVec[A]) = val (xo, a) = o.to(s) (xo, PSVec.singleton[A](a)) - val from: ((o.X, PSVec[B])) => T = { - case (xo, vs) => o.from((xo, vs.head)) - } + def from(pair: (o.X, PSVec[B])): T = o.from((pair._1, pair._2.head)) def singletonTo(s: S): (o.X, A) = o.to(s) def singletonFrom(x: o.X, b: B): T = o.from((x, b)) @@ -664,14 +659,14 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Either]): Optic[S, T, A, B, MultiFocus[PSVec]] = new Optic[S, T, A, B, MultiFocus[PSVec]] with MultiFocusPSMaybeHit[S, T, A, B]: type X = Option[o.X] - val to: S => (Option[o.X], PSVec[A]) = s => + def to(s: S): (Option[o.X], PSVec[A]) = o.to(s) match case Left(x) => (Some(x), PSVec.empty[A]) case Right(a) => (None, PSVec.singleton[A](a)) - val from: ((Option[o.X], PSVec[B])) => T = { - case (Some(x), _) => o.from(Left(x)) - case (None, vs) => o.from(Right(vs.head)) - } + def from(pair: (Option[o.X], PSVec[B])): T = + pair match + case (Some(x), _) => o.from(Left(x)) + case (None, vs) => o.from(Right(vs.head)) def collectTo( s: S, @@ -700,14 +695,14 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Affine]): Optic[S, T, A, B, MultiFocus[PSVec]] = new Optic[S, T, A, B, MultiFocus[PSVec]] with MultiFocusPSMaybeHit[S, T, A, B]: type X = Either[Fst[o.X], Snd[o.X]] - val to: S => (X, PSVec[A]) = s => + def to(s: S): (X, PSVec[A]) = o.to(s) match case m: Affine.Miss[o.X, A] => (Left(m.fst), PSVec.empty[A]) case h: Affine.Hit[o.X, A] => (Right(h.snd), PSVec.singleton[A](h.b)) - val from: ((X, PSVec[B])) => T = { - case (Left(fx), _) => o.from(new Affine.Miss[o.X, B](fx)) - case (Right(sx), vs) => o.from(new Affine.Hit[o.X, B](sx, vs.head)) - } + def from(pair: (X, PSVec[B])): T = + pair match + case (Left(fx), _) => o.from(new Affine.Miss[o.X, B](fx)) + case (Right(sx), vs) => o.from(new Affine.Hit[o.X, B](sx, vs.head)) def collectTo( s: S, @@ -734,8 +729,8 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, MultiFocus[F]]): Optic[S, T, A, B, SetterF] = new Optic[S, T, A, B, SetterF]: type X = (S, A) - val to: S => SetterF[X, A] = s => SetterF((s, identity[A])) - val from: SetterF[X, B] => T = sfxb => + def to(s: S): SetterF[X, A] = SetterF((s, identity[A])) + def from(sfxb: SetterF[X, B]): T = val (s, f) = sfxb.setter val (x, fa) = o.to(s) o.from((x, Functor[F].map(fa)(f))) @@ -747,22 +742,22 @@ object MultiFocus: ): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = lens.X - val to: S => (X, F[A]) = lens.to - val from: ((X, F[B])) => T = lens.from + def to(s: S): (X, F[A]) = lens.to(s) + def from(pair: (X, F[B])): T = lens.from(pair) def fromPrismF[F[_]: MonoidK, S, T, A, B]( prism: Optic[S, T, F[A], F[B], Either] ): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Either[prism.X, Unit] - val to: S => (X, F[A]) = s => + def to(s: S): (X, F[A]) = prism.to(s) match case Right(fa) => (Right(()), fa) case Left(x) => (Left(x), MonoidK[F].empty[A]) - val from: ((X, F[B])) => T = { - case (Left(xMiss), _) => prism.from(Left(xMiss)) - case (Right(_), fb) => prism.from(Right(fb)) - } + def from(pair: (X, F[B])): T = + pair match + case (Left(xMiss), _) => prism.from(Left(xMiss)) + case (Right(_), fb) => prism.from(Right(fb)) // Function1-shaped MultiFocus factories (the Grate-absorbed surface). @@ -777,8 +772,8 @@ object MultiFocus: ): Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]] = new Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]]: type X = Unit - val to: F[A] => (Unit, F.Representation => A) = fa => ((), F.index(fa)) - val from: ((Unit, F.Representation => A)) => F[A] = { case (_, k) => F.tabulate(k) } + def to(fa: F[A]): (Unit, F.Representation => A) = ((), F.index(fa)) + def from(pair: (Unit, F.Representation => A)): F[A] = F.tabulate(pair._2) /** Representable-indexed variant with explicit representative index. The `repr0` argument is * unused at runtime (rebuild operates pointwise via `F.tabulate`); preserved for API parity and @@ -790,8 +785,8 @@ object MultiFocus: val _ = repr0 new Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]]: type X = Unit - val to: F[A] => (Unit, F.Representation => A) = fa => ((), F.index(fa)) - val from: ((Unit, F.Representation => A)) => F[A] = { case (_, k) => F.tabulate(k) } + def to(fa: F[A]): (Unit, F.Representation => A) = ((), F.index(fa)) + def from(pair: (Unit, F.Representation => A)): F[A] = F.tabulate(pair._2) /** Polymorphic homogeneous-tuple Function1-shaped factory. `to(t) = ((), i => t._i)`, * `from((_, k))` materialises via `Tuple.fromArray(Array.tabulate(size)(i => k(i)))`. @@ -811,18 +806,17 @@ object MultiFocus: val size = sz.value new Optic[T, T, A, A, MultiFocus[Function1[Int, *]]]: type X = Unit - val to: T => (Unit, Int => A) = t => + def to(t: T): (Unit, Int => A) = val read: Int => A = (i: Int) => t.productElement(i).asInstanceOf[A] ((), read) - val from: ((Unit, Int => A)) => T = { - case (_, k) => - val arr = new Array[Object](size) - var i = 0 - while i < size do - arr(i) = k(i).asInstanceOf[Object] - i += 1 - Tuple.fromArray(arr).asInstanceOf[T] - } + def from(pair: (Unit, Int => A)): T = + val k = pair._2 + val arr = new Array[Object](size) + var i = 0 + while i < size do + arr(i) = k(i).asInstanceOf[Object] + i += 1 + Tuple.fromArray(arr).asInstanceOf[T] // ------------------------------------------------------------------ // Iso → Function1-shaped MultiFocus bridge — absorbs v1 `forgetful2grate`. @@ -839,27 +833,26 @@ object MultiFocus: ): Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] = new Optic[S, T, A, B, MultiFocus[Function1[X0, *]]]: type X = Unit - val to: S => (Unit, X0 => A) = s => + def to(s: S): (Unit, X0 => A) = val a = o.to(s).value ((), (_: X0) => a) - val from: ((Unit, X0 => B)) => T = { - case (_, k) => o.from(Direct(k(null.asInstanceOf[X0]))) - } + def from(pair: (Unit, X0 => B)): T = + o.from(Direct(pair._2(null.asInstanceOf[X0]))) def fromOptionalF[F[_]: MonoidK, S, T, A, B]( opt: Optic[S, T, F[A], F[B], Affine] ): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Affine[opt.X, Unit] - val to: S => (X, F[A]) = s => + def to(s: S): (X, F[A]) = opt.to(s) match case m: Affine.Miss[opt.X, F[A]] @unchecked => (m.widenB[Unit], MonoidK[F].empty[A]) case h: Affine.Hit[opt.X, F[A]] @unchecked => (new Affine.Hit[opt.X, Unit](h.snd, ()), h.b) - val from: ((X, F[B])) => T = { - case (m: Affine.Miss[opt.X, Unit] @unchecked, _) => - opt.from(m.widenB[F[B]]) - case (h: Affine.Hit[opt.X, Unit] @unchecked, fb) => - opt.from(new Affine.Hit[opt.X, F[B]](h.snd, fb)) - } + def from(pair: (X, F[B])): T = + pair match + case (m: Affine.Miss[opt.X, Unit] @unchecked, _) => + opt.from(m.widenB[F[B]]) + case (h: Affine.Hit[opt.X, Unit] @unchecked, fb) => + opt.from(new Affine.Hit[opt.X, F[B]](h.snd, fb)) diff --git a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala index 7615ca69..898e5993 100644 --- a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala +++ b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala @@ -59,9 +59,9 @@ object SetterF: ): optics.Optic[S, T, A, B, SetterF] = new optics.Optic[S, T, A, B, SetterF]: type X = (S, A) - val to: S => SetterF[X, A] = s => SetterF((s, identity[A])) + def to(s: S): SetterF[X, A] = SetterF((s, identity[A])) - val from: SetterF[X, B] => T = sfxb => + def from(sfxb: SetterF[X, B]): T = val (s, f) = sfxb.setter applyWrite(s, f) diff --git a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 275c5359..0c53aa21 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala @@ -31,11 +31,11 @@ object AffineFold: def apply[S, A](matches: S => Option[A]): AffineFold[S, A] = new Optic[S, Unit, A, Unit, Affine]: type X = (Unit, Unit) - val to: S => Affine[X, A] = s => + def to(s: S): Affine[X, A] = matches(s) match case Some(a) => new Affine.Hit[X, A]((), a) case None => new Affine.Miss[X, A](()) - val from: Affine[X, Unit] => Unit = _ => () + def from(a: Affine[X, Unit]): Unit = () /** Filtering — hits only on inputs satisfying `p`. @group Constructors */ def select[A](p: A => Boolean): AffineFold[A, A] = diff --git a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 307fb97f..784bb38f 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -45,11 +45,12 @@ object Fold: * accepts. */ final class ForgetFold[S, F[_], A]( - val to: S => F[A] + val read: S => F[A] )(using FF: Foldable[F]) extends Optic[S, Unit, A, Unit, Forget[F]]: type X = Nothing - val from: F[Unit] => Unit = _ => () + def to(s: S): F[A] = read(s) + def from(u: F[Unit]): Unit = () /** Eager `foldMap` — folds `s` straight through the captured `Foldable[F]`, returning `M` with no * intermediate `S => M` closure and no `ForgetfulFold` summon. Wins over the [[Optic.foldMap]] diff --git a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala index e369a772..aa005b8b 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala @@ -3,6 +3,33 @@ package optics import data.Direct +/** Concrete Optic subclass for a read-only getter. A `final class` storing `get` directly — NOT an + * abstract class with an abstract `get`: the CI A/B showed composed-getter dispatch through the + * abstract-class form costs ~1.8x (eoGet_3 5.1ns final vs 11.5ns abstract) even with a fused + * `andThen`, while every fused path that stayed a concrete class (`GetReplaceLens` lens-reuse) was + * flat. The hot path skips the `Accessor[Direct]` dispatch the generic extension would perform — + * the same shape as [[BijectionIso]] / [[GetReplaceLens]]. Returned by [[Getter.apply]] so + * hand-written getters pick up the fused path automatically. + */ +final class DirectGetter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, Direct]: + type X = Nothing + + def to(s: S): Direct[X, A] = Direct(get(s)) + def from(d: Direct[X, Unit]): Unit = () + + /** Fused `Getter.andThen(Getter)` — composes the read functions; the vestigial `Unit` write path + * needs no threading. + * + * `inline` so each composition call site splices its own copy of the `s => inner.get(get(s))` + * lambda, yielding a *distinct* synthetic method per level. A plain `def` compiles the lambda + * once (`andThen$$anonfun$1`), so a depth-N runtime chain reuses that one bytecode and C2 treats + * the `.get` cascade as recursion — capping inlining at `MaxRecursiveInlineLevel` and leaving + * the deep tail as virtual `Function1.apply`. Distinct per-level methods sidestep that cap with + * no JVM flag, the same way Monocle's per-compose anonymous classes do. + */ + inline def andThen[B](inner: DirectGetter[A, B]): DirectGetter[S, B] = + new DirectGetter(s => inner.get(get(s))) + /** Constructor for `Getter` — read-only single-focus optic, backed by `Direct` with `T = B = Unit`. * `.get(s)` is the only meaningful operation; the write path is vestigial. * @@ -26,26 +53,3 @@ object Getter: */ def apply[S, A](get: S => A): DirectGetter[S, A] = new DirectGetter(get) - -/** Concrete Optic subclass for a read-only getter. Stores `get` directly (so the hot path skips the - * `Accessor[Direct]` dispatch the generic extension would perform) and carries a fused `andThen` - * for getter∘getter composition — the same shape as [[BijectionIso]] / [[GetReplaceLens]]. - * Returned by [[Getter.apply]] so hand-written getters pick up the fused path automatically. - */ -final class DirectGetter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, Direct]: - type X = Nothing - val to: S => Direct[X, A] = s => Direct(get(s)) - val from: Direct[X, Unit] => Unit = _ => () - - /** Fused `Getter.andThen(Getter)` — composes the read functions; the vestigial `Unit` write path - * needs no threading. - * - * `inline` so each composition call site splices its own copy of the `s => inner.get(get(s))` - * lambda, yielding a *distinct* synthetic method per level. A plain `def` compiles the lambda - * once (`andThen$$anonfun$1`), so a depth-N runtime chain reuses that one bytecode and C2 treats - * the `.get` cascade as recursion — capping inlining at `MaxRecursiveInlineLevel` and leaving - * the deep tail as virtual `Function1.apply`. Distinct per-level methods sidestep that cap with - * no JVM flag, the same way Monocle's per-compose anonymous classes do. - */ - inline def andThen[B](inner: DirectGetter[A, B]): DirectGetter[S, B] = - new DirectGetter(s => inner.get(get(s))) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala index 2ebe2715..38cabda8 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala @@ -36,8 +36,8 @@ final class BijectionIso[S, T, A, B]( val reverseGet: B => T, ) extends Optic[S, T, A, B, Direct]: type X = Nothing - val to: S => Direct[X, A] = s => Direct(get(s)) - val from: Direct[X, B] => T = d => reverseGet(d.value) + def to(s: S): Direct[X, A] = Direct(get(s)) + def from(d: Direct[X, B]): T = reverseGet(d.value) inline def modify(f: A => B): S => T = s => reverseGet(f(get(s))) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala index 180ec538..20e55fbd 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala @@ -74,8 +74,8 @@ class GetReplaceLens[S, T, A, B]( val enplace: (S, B) => T, ) extends Optic[S, T, A, B, Tuple2]: type X = S - val to: S => (S, A) = s => (s, get(s)) - val from: ((S, B)) => T = pair => enplace(pair._1, pair._2) + def to(s: S): (S, A) = (s, get(s)) + def from(pair: (S, B)): T = enplace(pair._1, pair._2) inline def replace: B => S => T = b => s => enplace(s, b) @@ -155,11 +155,12 @@ class GetReplaceLens[S, T, A, B]( */ class SplitCombineLens[S, T, A, B, XA]( val get: S => A, - val to: S => (XA, A), + val split: S => (XA, A), val combine: (XA, B) => T, ) extends Optic[S, T, A, B, Tuple2]: type X = XA - val from: ((XA, B)) => T = pair => combine(pair._1, pair._2) + def to(s: S): (XA, A) = split(s) + def from(pair: (XA, B)): T = combine(pair._1, pair._2) inline def modify(f: A => B): S => T = s => @@ -178,9 +179,9 @@ class SplitCombineLens[S, T, A, B, XA]( */ final class SimpleLens[S, A, XA]( get: S => A, - to: S => (XA, A), + split: S => (XA, A), combine: (XA, A) => S, -) extends SplitCombineLens[S, S, A, A, XA](get, to, combine): +) extends SplitCombineLens[S, S, A, A, XA](get, split, combine): inline def place(a: A): S => S = s => @@ -199,7 +200,7 @@ final class SimpleLens[S, A, XA]( */ object SimpleLens: - /** `to` already has the shape `S => (X, A)` the generic mutation extensions require. */ + /** `split` already has the shape `S => (X, A)` the generic mutation extensions require. */ given transformEvidence[S, A, XA](using o: SimpleLens[S, A, XA] - ): (S => (o.X, A)) = o.to + ): (S => (o.X, A)) = o.split diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index ace518e1..4f72f897 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -42,12 +42,12 @@ trait Optic[S, T, A, B, F[_, _]]: /** Push the source `S` into the carrier, extracting the focus `A` and packing the leftover `X`. * Paired with [[from]] to reconstruct `T`. */ - def to: S => F[X, A] + def to(s: S): F[X, A] /** Close the carrier: given a modified focus `B` (and the leftover `X` already inside the `F`), * reassemble the result `T`. */ - def from: F[X, B] => T + def from(b: F[X, B]): T /** Compose with another optic under the shared carrier `F`. Requires `AssociativeFunctor[F]`. * Cross-carrier composition (Lens → Optional, Lens → Traversal, …) goes through the @@ -68,8 +68,8 @@ trait Optic[S, T, A, B, F[_, _]]: val innerRef = o.asInstanceOf[Optic[A, B, C, D, F] { type X = o.X }] new Optic: type X = af.Z - val to: S => F[X, C] = s => af.composeTo(s, outerRef, innerRef) - val from: F[X, D] => T = xd => af.composeFrom(xd, innerRef, outerRef) + def to(s: S): F[X, C] = af.composeTo(s, outerRef, innerRef) + def from(xd: F[X, D]): T = af.composeFrom(xd, innerRef, outerRef) /** Build-then-observe across the *build-output ⇄ read-input* seam, **preserving structure**, on a * **shared carrier** `F`. Flip `self` (it must be reversible — `Accessor[F]` *and* @@ -114,8 +114,8 @@ object Optic: )(f: R => S)(g: T => U): Optic[R, U, A, B, F] = new Optic[R, U, A, B, F]: type X = o.X - val to: R => F[X, A] = r => o.to(f(r)) - val from: F[X, B] => U = fxb => g(o.from(fxb)) + def to(r: R): F[X, A] = o.to(f(r)) + def from(fxb: F[X, B]): U = g(o.from(fxb)) /** Profunctor over `(B, A)` — `dimap` on the inner focus pair. Requires a `ForgetfulFunctor[F]` * so the carrier can map its focus in place. @@ -131,8 +131,8 @@ object Optic: )(f: D => B)(g: A => C): Optic[S, T, C, D, F] = new Optic[S, T, C, D, F]: type X = o.X - val to: S => F[X, C] = s => F.map(o.to(s), g) - val from: F[X, D] => T = d => o.from(F.map(d, f)) + def to(s: S): F[X, C] = F.map(o.to(s), g) + def from(xd: F[X, D]): T = o.from(F.map(xd, f)) /** The identity optic — `S` is its own focus and modification has no effect. Carrier is * [[data.Direct]] (no leftover). @@ -142,8 +142,8 @@ object Optic: def id[A]: Optic[A, A, A, A, Direct] = new Optic[A, A, A, A, Direct]: type X = Nothing - val to: A => Direct[X, A] = a => Direct(a) - val from: Direct[X, A] => A = _.value + def to(a: A): Direct[X, A] = Direct(a) + def from(da: Direct[X, A]): A = da.value /** Cross-carrier `.andThen` — picks the direction via a summoned [[Morph]] when the two optics' * carriers differ. With one exception (`forget2multifocus` / `multifocus2forget`), cats-eo ships @@ -170,6 +170,82 @@ object Optic: summonInline[AssociativeFunctor[m.Out, morphedSelf.X, morphedO.X]] ) + /** Compose with a read-only [[Getter]] → the **read-only collapse** of this optic, for a + * **total reader** (`Accessor[F]` — `Iso`/`Getter` on `Direct`, `Lens` on `Tuple2`). A + * `Getter`'s back-focus is `Unit`, so it can never thread through a writable `B` as a writable + * optic; the only sound result is read-only, and that is exactly what's wanted ("focus `A`, + * then read `C`"): read `self` through the accessor, then `o.get` — a [[DirectGetter]]. + * Partial readers take the `andThenAffineFold` overload below; together they give + * `lens.andThen(getter) = Getter`, `optional/prism.andThen(getter) = AffineFold` — the + * read-side dual of the `andThen(Setter)` write-side collapse. Routed through the carrier's + * capability, not per-class fused overloads. + * + * @group Operations + */ + @annotation.targetName("andThenReadOnly") + inline def andThen[C](o: DirectGetter[A, C])(using af: Accessor[F]): DirectGetter[S, C] = + Getter[S, C](s => o.get(af.get(self.to(s)))) + + /** Compose with a read-only [[Getter]] → the **read-only collapse** of this optic, for a + * **partial reader** (no `Accessor[F]` — `Optional`/[[AffineFold]] on `Affine`, `Prism` on + * `Either`). Takes the [[readOnly]] projection (write side forgotten, so the `Unit` seam lines + * up against the getter's `T = Unit`) and composes through `Morph[F, Direct]` pinned to the + * `Affine` carrier (`rightToLeft` for `Affine` itself, `bothViaAffine` for `Either`) — an + * [[AffineFold]]. The `NotGiven[Accessor[F]]` guard keeps total readers on the + * `andThenReadOnly` overload above, unambiguously. + * + * @group Operations + */ + @annotation.targetName("andThenAffineFold") + inline def andThen[C](o: DirectGetter[A, C])(using + ng: scala.util.NotGiven[Accessor[F]], + m: Morph[F, Direct] { type Out[X, Y] = Affine[X, Y] }, + ): AffineFold[S, C] = + val ms = m.morphSelf(self.readOnly) + val mo = m.morphO(o) + ms.andThen(mo)(using summonInline[AssociativeFunctor[Affine, ms.X, mo.X]]) + + /** Compose with a write-only [[Setter]] → a concrete [[SetterOptic]] (the **write-side dual** + * of `andThen(Getter)`). A `Setter`'s read side is trivial, so the composite is write-only: it + * modifies `this` optic's focus through the inner setter. Routed through `Morph[F, SetterF]` + * (every writable carrier ships a `Composer[·, SetterF]`), then re-wrapped as a `SetterOptic` + * so the concrete fused `.modify` surface is preserved. So `lens.andThen(setter)` / + * `optional.andThen(setter)` / `prism.andThen(setter)` are all `Setter`s. + * + * @group Operations + */ + @annotation.targetName("andThenSetter") + inline def andThen[C, D](o: SetterOptic[A, B, C, D])(using + m: Morph[F, SetterF] { type Out[X, Y] = SetterF[X, Y] } + ): SetterOptic[S, T, C, D] = + val ms = m.morphSelf(self) + val mo = m.morphO(o) + val composed = ms.andThen(mo)(using summonInline[AssociativeFunctor[SetterF, ms.X, mo.X]]) + Setter[S, T, C, D](f => composed.modify(f)) + + /** Compose with a build-only [[Review]] → a [[Review]] (the build-direction collapse). A + * `Review`'s source/read is trivial (`Unit`), so the composite is build-only: build the inner + * focus, then rebuild `T` through `self`. Requires `this` to be reversible + * (`ReverseAccessor[F]`). + * + * @group Operations + */ + @annotation.targetName("andThenReview") + def andThen[C](o: Optic[Unit, B, Unit, C, F])(using RA: ReverseAccessor[F]): Review[T, C] = + Review[T, C](c => self.from(RA.reverseGet(o.from(RA.reverseGet(c))))) + + /** The **read-only projection** of this optic — same `to`, write side forgotten (`T = B = + * Unit`). Sound for every carrier (`from` ignores its input); this is the seam adapter the + * partial-reader `andThen(Getter)` collapse composes through. + * + * @group Operations + */ + def readOnly: Optic[S, Unit, A, Unit, F] = + new Optic[S, Unit, A, Unit, F]: + type X = self.X + def to(s: S): F[X, A] = self.to(s) + def from(b: F[X, Unit]): Unit = () + /** Cross-carrier [[cross]] (mirrors `andThen` vs `andThenMorphed`) — when `that` sits on a * *different* carrier `G`, a `Morph[F, G]` bridges the two before composing, so the result's * read capability follows the *composed* carrier: `.getOption` through a Prism, `.foldMap` @@ -232,8 +308,8 @@ object Optic: def reverse: Optic[B, A, T, S, F] = new Optic[B, A, T, S, F]: type X = o.X - val to: B => F[X, T] = (b: B) => RA.reverseGet(o.from(RA.reverseGet(b))) - val from: F[X, S] => A = (fs: F[X, S]) => A.get(o.to(A.get(fs))) + def to(b: B): F[X, T] = RA.reverseGet(o.from(RA.reverseGet(b))) + def from(fs: F[X, S]): A = A.get(o.to(A.get(fs))) /** Modify (`A => B`) or replace (by constant `B`) the focus in-place. Available for every carrier * with a `ForgetfulFunctor[F]` instance — i.e. every current optic family. diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala index 2d37abc4..c0e21c68 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala @@ -63,12 +63,12 @@ final class Optional[S, T, A, B]( ) extends Optic[S, T, A, B, Affine]: type X = (T, S) - val to: S => Affine[X, A] = s => + def to(s: S): Affine[X, A] = getOrModify(s) match case Right(a) => new Affine.Hit[X, A](s, a) case Left(t) => new Affine.Miss[X, A](t) - val from: Affine[X, B] => T = a => + def from(a: Affine[X, B]): T = a match case h: Affine.Hit[X, B] => reverseGet(h.snd, h.b) case m: Affine.Miss[X, B] => m.fst @@ -134,14 +134,3 @@ final class Optional[S, T, A, B]( innerHit = (_, a) => Right(inner.get(a)), innerWrite = (_, d) => inner.reverseGet(d), ) - - /** Fused `Optional.andThen(Getter)` — a read-only inner collapses the partial optic to a partial - * *read*: `getOption` the focus, then read it through the getter. The result is an - * [[AffineFold]] (read-only `T = B = Unit`), because a `Getter`'s `Unit` back-focus cannot - * thread through the Optional's writable `B` — composing as a writable optic is impossible by - * construction, so the read-only downgrade is the sound result ("optionally focus `A`, then read - * `C`"). So `Optional[S, A].andThen(Getter[A, C]) = AffineFold[S, C]`. The mirror of how a - * `Getter` chain composes `Getter`-to-`Getter`. - */ - def andThen[C](inner: DirectGetter[A, C]): AffineFold[S, C] = - AffineFold(s => getOrModify(s).toOption.map(inner.get)) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala index 4180538d..a0c02368 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala @@ -67,9 +67,9 @@ final class MendTearPrism[S, T, A, B]( val mend: B => T, ) extends Optic[S, T, A, B, Either]: type X = T - val to: S => Either[T, A] = tear + def to(s: S): Either[T, A] = tear(s) - val from: Either[T, B] => T = e => + def from(e: Either[T, B]): T = e match case Right(b) => mend(b) case Left(t) => t @@ -188,12 +188,12 @@ final class PickMendPrism[S, A, B]( ) extends Optic[S, S, A, B, Either]: type X = S - val to: S => Either[S, A] = s => + def to(s: S): Either[S, A] = pick(s) match case Some(a) => Right(a) case None => Left(s) - val from: Either[S, B] => S = e => + def from(e: Either[S, B]): S = e match case Right(b) => mend(b) case Left(s) => s diff --git a/core/src/main/scala/dev/constructive/eo/optics/Review.scala b/core/src/main/scala/dev/constructive/eo/optics/Review.scala index c8639a6e..11eaca45 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Review.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Review.scala @@ -14,6 +14,9 @@ import data.Direct * necessity.) A Review composes with another Review through the fused `andThen` just as `Getter`s * do, and slots into the `Direct`-carrier optic surface. * + * A `final class` storing `reverseGet` directly — NOT an abstract class with an abstract member — + * for the same composed-dispatch reason documented on [[DirectGetter]]. + * * To get the build direction out of an `Iso` or `Prism`, wrap its reverse directly — * `Review(iso.reverseGet)` / `Review(prism.mend)` — rather than via a bespoke factory: an * `Iso`/`Prism` already *is* a build direction, so cross-optic `from*` constructors would be @@ -21,8 +24,9 @@ import data.Direct */ final class Review[S, A](val reverseGet: A => S) extends Optic[Unit, S, Unit, A, Direct]: type X = Nothing - val to: Unit => Direct[X, Unit] = _ => Direct(()) - val from: Direct[X, A] => S = d => reverseGet(d.value) + + def to(u: Unit): Direct[X, Unit] = Direct(u) + def from(d: Direct[X, A]): S = reverseGet(d.value) /** Fused `Review.andThen(Review)` — the build-direction mirror of [[DirectGetter.andThen]]. * `this` builds `S` from `A`, `inner` builds `A` from `B`, so the composite builds `S` from `B` diff --git a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala b/core/src/main/scala/dev/constructive/eo/optics/Setter.scala index 34fbba11..38cac0c4 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Setter.scala @@ -40,9 +40,9 @@ final class SetterOptic[S, T, A, B](val modifyFn: (A => B) => S => T) extends Optic[S, T, A, B, SetterF]: type X = (S, A) - val to: S => SetterF[X, A] = s => SetterF(s, identity[A]) + def to(s: S): SetterF[X, A] = SetterF(s, identity[A]) - val from: SetterF[X, B] => T = s => modifyFn(s.setter._2)(s.setter._1) + def from(s: SetterF[X, B]): T = modifyFn(s.setter._2)(s.setter._1) /** Fused `.modify` — applies the stored writer directly, bypassing the `SetterF` carrier. */ inline def modify(f: A => B): S => T = modifyFn(f) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala index b5a9b2f7..bb59a062 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala @@ -35,7 +35,7 @@ object Traversal: new Optic[T[A], T[B], A, B, MultiFocus[PSVec]]: type X = T[A] - val to: T[A] => (T[A], PSVec[A]) = ta => + def to(ta: T[A]): (T[A], PSVec[A]) = ta match case refArr: ArraySeq.ofRef[?] => // ArraySeq.ofRef.unsafeArray is already an Array[AnyRef]; aliasing is safe because @@ -46,22 +46,21 @@ object Traversal: Traverse[T].foldLeft(ta, ())((_, a) => { buf.append(a.asInstanceOf[AnyRef]); () }) (ta, buf.freezeAsPSVec[A]) - val from: ((T[A], PSVec[B])) => T[B] = { - case (xo, vec) => - xo match - case _: ArraySeq[?] => - // `unsafeShareableArray` returns the Slice's backing array when it densely covers - // it (always true post-`composeFrom`); ArraySeq.unsafeWrapArray forbids mutation, - // so aliasing is safe end-to-end. Fallback PSVec shapes copy via toAnyRefArray. - ArraySeq.unsafeWrapArray(vec.unsafeShareableArray).asInstanceOf[T[B]] - case _ => - var idx = 0 - Traverse[T].map(xo) { _ => - val b = vec(idx) - idx += 1 - b - } - } + def from(pair: (T[A], PSVec[B])): T[B] = + val (xo, vec) = pair + xo match + case _: ArraySeq[?] => + // `unsafeShareableArray` returns the Slice's backing array when it densely covers + // it (always true post-`composeFrom`); ArraySeq.unsafeWrapArray forbids mutation, + // so aliasing is safe end-to-end. Fallback PSVec shapes copy via toAnyRefArray. + ArraySeq.unsafeWrapArray(vec.unsafeShareableArray).asInstanceOf[T[B]] + case _ => + var idx = 0 + Traverse[T].map(xo) { _ => + val b = vec(idx) + idx += 1 + b + } /** Monomorphic self-traversal from an explicit immediate-children view — focuses the values of * `S` that are themselves `S` (the immediate sub-terms), with `S` itself as the leftover @@ -85,8 +84,8 @@ object Traversal: ): Optic[S, S, S, S, MultiFocus[PSVec]] = new Optic[S, S, S, S, MultiFocus[PSVec]]: type X = S - val to: S => (S, PSVec[S]) = s => (s, children(s)) - val from: ((S, PSVec[S])) => S = { case (s, vec) => rebuild(s, vec) } + def to(s: S): (S, PSVec[S]) = (s, children(s)) + def from(pair: (S, PSVec[S])): S = rebuild(pair._1, pair._2) /** Traversal over two per-element getters with a `reverse` reassembly. Carrier is * `MultiFocus[Function1[Int, *]]` — the homogeneous-arity Tuple_N-shaped traversal encoded as an @@ -103,16 +102,14 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - val to: S => (Unit, Int => A) = s => + def to(s: S): (Unit, Int => A) = val read: Int => A = (i: Int) => i match case 0 => a(s) case 1 => b(s) ((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(1)) - } + def from(pair: (Unit, Int => B)): T = reverse(pair._2(0), pair._2(1)) /** Fixed-arity-3 — see [[two]]. @group Constructors */ def three[S, T, A, B]( @@ -124,7 +121,7 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - val to: S => (Unit, Int => A) = s => + def to(s: S): (Unit, Int => A) = val read: Int => A = (i: Int) => i match case 0 => a(s) @@ -132,9 +129,7 @@ object Traversal: case 2 => c(s) ((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(1), k(2)) - } + def from(pair: (Unit, Int => B)): T = reverse(pair._2(0), pair._2(1), pair._2(2)) /** Fixed-arity-4 — see [[two]]. @group Constructors */ def four[S, T, A, B]( @@ -147,7 +142,7 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - val to: S => (Unit, Int => A) = s => + def to(s: S): (Unit, Int => A) = val read: Int => A = (i: Int) => i match case 0 => a(s) @@ -156,6 +151,5 @@ object Traversal: case 3 => d(s) ((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(1), k(2), k(3)) - } + def from(pair: (Unit, Int => B)): T = + reverse(pair._2(0), pair._2(1), pair._2(2), pair._2(3)) diff --git a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterPrism.scala b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterPrism.scala index 29ec8f9c..a29555af 100644 --- a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterPrism.scala +++ b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterPrism.scala @@ -70,7 +70,18 @@ object JsoniterPrism: new Optic[Array[Byte], Array[Byte], A, A, Affine]: type X = (Array[Byte], (Array[Byte], Int, Int)) - val to: Array[Byte] => Affine[X, A] = bytes => + // `to` / `from` are 1-call forwarders; the large bodies live in private + // methods so the abstract-`def` entry stays a few bytes and inlines into + // every caller without blowing its C2 inlining budget — PrintInlining + // showed the direct 103/175-byte bodies failing with "callee is too + // large" at hot use sites, where the old `val to` encoding's 12-byte + // Function1 bridges inlined everywhere (the big body compiled + // separately). The forwarders recreate that shape under the `def` + // encoding: `to (6 bytes) inline` at every site. + def to(bytes: Array[Byte]): Affine[X, A] = scan(bytes) + def from(aff: Affine[X, A]): Array[Byte] = splice(aff) + + private def scan(bytes: Array[Byte]): Affine[X, A] = val span = JsonPathScanner.find(bytes, steps) if !span.isHit then new Affine.Miss[X, A](bytes) else @@ -79,7 +90,7 @@ object JsoniterPrism: new Affine.Hit[X, A]((bytes, span.start, span.end), a) catch case _: Throwable => new Affine.Miss[X, A](bytes) - val from: Affine[X, A] => Array[Byte] = aff => + private def splice(aff: Affine[X, A]): Array[Byte] = aff match case h: Affine.Hit[X, A] => // Phase-2 splice. h.snd carries (src, start, end). The user's `.modify` / diff --git a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala index b3bd7a65..47ebd67e 100644 --- a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala +++ b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala @@ -67,7 +67,7 @@ object JsoniterTraversal: new Optic[Array[Byte], Array[Byte], A, A, MultiFocus[PSVec]]: type X = (Array[Byte], List[JsonPathScanner.Span]) - val to: Array[Byte] => (X, PSVec[A]) = bytes => + def to(bytes: Array[Byte]): (X, PSVec[A]) = val spans = JsonPathScanner.findAll(bytes, steps) val n = spans.length if n == 0 then ((bytes, spans), PSVec.empty[A]) @@ -91,4 +91,4 @@ object JsoniterTraversal: PSVec.unsafeWrap[A](tight) ((bytes, spans), psv) - val from: ((X, PSVec[A])) => Array[Byte] = pair => pair._1._1 + def from(pair: (X, PSVec[A])): Array[Byte] = pair._1._1 diff --git a/site/docs/optics.md b/site/docs/optics.md index a628e367..a18a6a9f 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -188,14 +188,25 @@ summons `Composer[Tuple2, Affine]` under the hood and morphs the Lens into the Affine carrier. No explicit `.morph` required on your end. -Composing the *other* way — an `Optional` (or `AffineFold`) -`.andThen` a read-only `Getter` — yields an **`AffineFold`**: the -read-only inner collapses the result to a partial *read* -(`getOption` the focus, then read it through the getter). A -`Getter`'s `Unit` back-focus can't thread through the Optional's -writable `B`, so the read-only downgrade is the only sound result -— "optionally focus `A`, then read `C`", the mirror of how a -`Getter` chain composes `Getter`-to-`Getter`. +**Read-only / write-only collapse.** Composing *any* optic with a +read-only `Getter` projects it to its **read-only counterpart**: +the `Getter`'s `Unit` back-focus can't thread through a writable +`B`, so the write side is forgotten (`T = B = Unit`). A `ReadOnly[F]` +carrier projection picks the result — a total reader (`Lens` / `Iso`) +yields a `Getter`, a partial one (`Optional` / `Prism`) an +`AffineFold`: + +```scala +lens.andThen(getter) // Getter +optional.andThen(getter) // AffineFold (partial read) +prism.andThen(getter) // AffineFold +``` + +Dually, composing with a write-only `Setter` collapses the *read* +side and yields a `Setter` (`lens.andThen(setter)`, +`optional.andThen(setter)`, …) — it modifies the focus through the +inner setter. One rule per side, across the whole algebra, rather +than a per-family special case. ### AffineFold (read-only) diff --git a/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala b/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala index a7f23eab..d3139cf7 100644 --- a/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala +++ b/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala @@ -20,10 +20,8 @@ object JsonOptic: def fromCodec[A](codec: Codec[A]): Optic[Json, HCursor, A, A, JsonF] = new Optic[Json, HCursor, A, A, JsonF]: type X = HCursor - def to: Json => JsonF[X, A] = - js => JsonF(js.hcursor, codec.decodeJson(js)) - def from: JsonF[X, A] => HCursor = - _.cursor + def to(js: Json): JsonF[X, A] = JsonF(js.hcursor, codec.decodeJson(js)) + def from(b: JsonF[X, A]): HCursor = b.cursor // Work-in-progress stub — body is `???` pending migration into a demo // spec (see docs/plans/2026-04-17-001-feat-production-readiness-laws-docs-plan.md). @@ -32,5 +30,5 @@ object JsonOptic: @scala.annotation.unused o: Optic[S, S, A, A, F] ): JsonOptic[S, A, F] = new JsonOptic[S, A, F]: - def to: S => F[HCursor, A] = ??? - def from: F[HCursor, Json] => Json = ??? + def to(s: S): F[HCursor, A] = ??? + def from(b: F[HCursor, Json]): Json = ??? diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index 621c5cfa..8346d8d5 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -22,6 +22,8 @@ import optics.{ Optional, Prism, Review, + Setter, + SetterOptic, Traversal, } import optics.Optic.* @@ -66,8 +68,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: ): Optic[Int, Int, Int, Int, Forget[F]] = new Optic[Int, Int, Int, Int, Forget[F]]: type X = Unit - val to = toF - val from = fromF + def to(s: Int): F[Int] = toF(s) + def from(b: F[Int]): Int = fromF(b) // ----- Basic-factory smoke tests ------------------------------------- // @@ -314,28 +316,65 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: .and(chainOk) } - // covers: the Affine-carrier `.andThen(Getter)` bridge — composing a partial/read-write Optional - // (or a read-only AffineFold) with a read-only Getter collapses to an AffineFold (partial read), - // via getOption ∘ get. A read-write outer's B = A can't thread a Getter's Unit back-focus as a - // writable optic, so the read-only downgrade is the sound (and only) result. Both the read-write - // Optional outer and the read-only AffineFold outer route through the same overload. - "Affine.andThen(Getter): Optional / AffineFold composed with a Getter yields an AffineFold" >> { + // covers: the GENERAL read-only collapse — composing ANY optic with a read-only Getter projects + // it to its read-only counterpart (write side forgotten, T = B = Unit), routed through the + // Accessor / Morph machinery (not per-class fused overloads): a total reader (Lens / Iso, + // `Accessor[F]`) yields a Getter; a partial one (Optional / AffineFold / Prism) composes its + // `readOnly` projection through Morph-to-Affine and yields an AffineFold. Plus the write-side + // dual: any writable optic .andThen(Setter) morphs through `Composer[·, SetterF]` and stays a + // Setter. Not Affine-only and not per-class — one rule per side. + "any optic .andThen(Getter) projects to read-only (Getter / AffineFold); .andThen(Setter) stays a Setter" >> { val toStr = Getter[Int, String](_.toString) + // total readers -> Getter + val fstLens = Lens[(Int, String), Int](_._1, (t, n) => (n, t._2)) + val lensThen: Optic[(Int, String), Unit, String, Unit, Direct] = fstLens.andThen(toStr) + val lensOk = lensThen.get((7, "x")) === "7" + + val idIso = Iso[Int, Int, Int, Int](identity, identity) + val isoThen: Optic[Int, Unit, String, Unit, Direct] = idIso.andThen(toStr) + val isoOk = isoThen.get(9) === "9" + + // partial readers -> AffineFold val ageOpt: Optional[AdultPerson, AdultPerson, Int, Int] = Optional[AdultPerson, AdultPerson, Int, Int, Affine]( getOrModify = p => Either.cond(p.age >= 18, p.age, p), reverseGet = { case (_, a) => AdultPerson(a) }, ) - val optThenG: AffineFold[AdultPerson, String] = ageOpt.andThen(toStr) - val optOk = (optThenG.getOption(AdultPerson(20)) === Some("20")) - .and(optThenG.getOption(AdultPerson(15)) === None) - - val afThenG: AffineFold[AdultPerson, String] = adultAgeAF.andThen(toStr) - val afOk = (afThenG.getOption(AdultPerson(18)) === Some("18")) - .and(afThenG.getOption(AdultPerson(10)) === None) - - optOk.and(afOk) + val optThen: AffineFold[AdultPerson, String] = ageOpt.andThen(toStr) + val optOk = (optThen.getOption(AdultPerson(20)) === Some("20")) + .and(optThen.getOption(AdultPerson(15)) === None) + + val afThen: AffineFold[AdultPerson, String] = adultAgeAF.andThen(toStr) + val afOk = (afThen.getOption(AdultPerson(18)) === Some("18")) + .and(afThen.getOption(AdultPerson(10)) === None) + + val intP = Prism.optional[String, Int](s => s.toIntOption, _.toString) + val prismThen: AffineFold[String, String] = intP.andThen(toStr) + val prismOk = + (prismThen.getOption("42") === Some("42")).and(prismThen.getOption("nope") === None) + + // write-side dual: any writable optic .andThen(Setter) -> Setter + val plusSetter = Setter[Int, Int, Int, Int](f => n => f(n)) + val lensThenSet: SetterOptic[(Int, String), (Int, String), Int, Int] = + fstLens.andThen(plusSetter) + val setLensOk = lensThenSet.modify(_ + 1)((7, "x")) === ((8, "x")) + + val ageOptW = Optional[AdultPerson, AdultPerson, Int, Int, Affine]( + getOrModify = p => Either.cond(p.age >= 18, p.age, p), + reverseGet = { case (_, a) => AdultPerson(a) }, + ) + val optThenSet = ageOptW.andThen(plusSetter) + val setOptOk = (optThenSet.modify(_ + 1)(AdultPerson(20)) === AdultPerson(21)) + .and(optThenSet.modify(_ + 1)(AdultPerson(15)) === AdultPerson(15)) // miss: unchanged + + lensOk + .and(isoOk) + .and(optOk) + .and(afOk) + .and(prismOk) + .and(setLensOk) + .and(setOptOk) } // ----- Review behaviour ---------------------------------------------- @@ -438,11 +477,10 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val inner2: Optic[Int, Int, Int, Int, MultiFocus[Option]] = new Optic[Int, Int, Int, Int, MultiFocus[Option]]: type X = String - val to: Int => (String, Option[Int]) = n => (s"tag-$n", Option.when(n < 1000)(n + 1)) - val from: ((String, Option[Int])) => Int = { - case (tag, fb) => - fb.fold(tag.length)(_ * 100 + tag.length) - } + def to(n: Int): (String, Option[Int]) = (s"tag-$n", Option.when(n < 1000)(n + 1)) + def from(pair: (String, Option[Int])): Int = + val (tag, fb) = pair + fb.fold(tag.length)(_ * 100 + tag.length) val composed = lifted.andThen(inner2) val composedOk = (composed.modify(identity)(5) === 5113).and(composed.modify(identity)(-2) === -1) @@ -675,14 +713,14 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val triplet: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - val to: Int => Forget[List][Unit, Int] = n => List(n - 1, n, n + 1) - val from: Forget[List][Unit, Unit] => Unit = _ => () + def to(n: Int): Forget[List][Unit, Int] = List(n - 1, n, n + 1) + def from(u: Forget[List][Unit, Unit]): Unit = () val pair: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - val to: Int => Forget[List][Unit, Int] = n => List(n, n + 1) - val from: Forget[List][Unit, Unit] => Unit = _ => () + def to(n: Int): Forget[List][Unit, Int] = List(n, n + 1) + def from(u: Forget[List][Unit, Unit]): Unit = () val composed = triplet.andThen(pair) // to(5) = List(4,5,6).flatMap(n => List(n, n+1)) = List(4,5,5,6,6,7) From 515c432f71f15378c08c8343e1af0188cc0d0407 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 13:39:31 +0200 Subject: [PATCH 03/14] refactor(core): cruft sweep + de-anonymize the shared read-only wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AffineFold.apply/select now return a concrete `final class PickFold` (stores `pick`, fused getOption + andThen(PickFold/DirectGetter)) — removes the shared-anonymous-wrapper shape whose single `pick.apply` bytecode site goes megamorphic across instances (the PrintInlining finding from the abstract-class DirectGetter regression). - Optic.readOnly is `inline` (per-call-site splice, same rationale); E197 suppressed as the intended behaviour. - Optic.id returns BijectionIso so identity compositions take the fused members instead of the generic carrier path. - Drop the unused `[X]` type params on MendTearPrism/PickMendPrism modify/replace (they shadowed the class's `type X`). - Composer.direct2either: replace `???` on the uninhabited Left branch with Nothing-elimination (`case Left(x) => x`) — total and compiler-verified. - Refresh the Optic companion's operations catalogue (readOnly, cross, the andThen collapses). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scala/dev/constructive/eo/Composer.scala | 4 +- .../constructive/eo/optics/AffineFold.scala | 50 +++++++++++++++---- .../dev/constructive/eo/optics/Optic.scala | 25 ++++++---- .../dev/constructive/eo/optics/Prism.scala | 8 +-- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/Composer.scala b/core/src/main/scala/dev/constructive/eo/Composer.scala index b1be9927..67d7def7 100644 --- a/core/src/main/scala/dev/constructive/eo/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/Composer.scala @@ -55,7 +55,9 @@ object Composer extends LowPriorityComposerInstances: def from(e: Either[X, B]): T = e match case Right(b) => o.from(Direct(b)) - case Left(_) => ??? + // `X = Nothing` makes `Left` uninhabited; returning the `Nothing` value (which + // conforms to `T`) keeps the match total and compiler-verified — no `???` needed. + case Left(x) => x /** Express a read-only Direct optic (a Getter) as a Fold — lift the single focus into `F` via * `pure`. This is the `Composer[Direct, Forget[F]]` bridge diff --git a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 0c53aa21..25a0e664 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala @@ -15,6 +15,42 @@ import data.Affine */ type AffineFold[S, A] = Optic[S, Unit, A, Unit, Affine] +/** Concrete Optic subclass for [[AffineFold]] — a `final class` storing `pick` directly, NOT a + * shared anonymous wrapper: a single anon class would host one `pick.apply` bytecode site for + * every AffineFold instance, the megamorphic-dispatch trap PrintInlining exposed on the + * abstract-class [[DirectGetter]] (the JIT profiles that one site over all instances' lambdas and + * gives up inlining). Same storage shape as [[PickMendPrism]] (`pick`) and [[ForgetFold]] + * (`read`). Returned by [[AffineFold.apply]] / [[AffineFold.select]] so hand-written affine folds + * pick up the fused members automatically. + */ +final class PickFold[S, A](val pick: S => Option[A]) extends Optic[S, Unit, A, Unit, Affine]: + type X = (Unit, Unit) + + def to(s: S): Affine[X, A] = + pick(s) match + case Some(a) => new Affine.Hit[X, A]((), a) + case None => new Affine.Miss[X, A](()) + + def from(a: Affine[X, Unit]): Unit = () + + /** Fused `getOption` — reads `pick` directly, skipping the `Affine` carrier round-trip the + * generic extension performs. Wins over the extension by member precedence at static type. + */ + inline def getOption(s: S): Option[A] = pick(s) + + /** Fused `AffineFold.andThen(AffineFold)` — `flatMap`s the picks; no `Affine` wrappers, no + * `Morph`. `inline` so each compose site splices its own lambda (per-site monomorphic dispatch — + * see [[DirectGetter.andThen]]). + */ + inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = + new PickFold(s => pick(s).flatMap(inner.pick)) + + /** Fused `AffineFold.andThen(Getter)` — `map`s the read over the pick; the concrete fast path of + * the generic `andThenAffineFold` collapse. + */ + inline def andThen[C](inner: DirectGetter[A, C]): PickFold[S, C] = + new PickFold(s => pick(s).map(inner.get)) + /** Constructors for [[AffineFold]]. */ object AffineFold: @@ -28,18 +64,12 @@ object AffineFold: * val adultAge = AffineFold[Person, Int](p => Option.when(p.age >= 18)(p.age)) * }}} */ - def apply[S, A](matches: S => Option[A]): AffineFold[S, A] = - new Optic[S, Unit, A, Unit, Affine]: - type X = (Unit, Unit) - def to(s: S): Affine[X, A] = - matches(s) match - case Some(a) => new Affine.Hit[X, A]((), a) - case None => new Affine.Miss[X, A](()) - def from(a: Affine[X, Unit]): Unit = () + def apply[S, A](matches: S => Option[A]): PickFold[S, A] = + new PickFold(matches) /** Filtering — hits only on inputs satisfying `p`. @group Constructors */ - def select[A](p: A => Boolean): AffineFold[A, A] = - apply(a => Option(a).filter(p)) + def select[A](p: A => Boolean): PickFold[A, A] = + new PickFold(a => Option(a).filter(p)) // No `fromOptional` / `fromPrism` weakening factories: a writable optic's read-only view is // just `AffineFold(optic.getOption)` (`.getOption` is defined on both the Affine and Either diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index 4f72f897..322fb58b 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -96,9 +96,10 @@ trait Optic[S, T, A, B, F[_, _]]: /** Companion for [[Optic]]. Hosts the profunctor instances and the capability-gated extension * catalogue — `.get`, `.modify`, `.replace`, `.foldMap`, `.modifyA`, `.all`, `.reverseGet`, - * `.getOption`, `.put`, `.transform`, `.place`, `.transfer`, `.andThen`, `.morph`, `.headOption`, - * `.length`, `.exists`. Adding a new carrier means supplying the typeclass instances of the - * operations it should support. + * `.getOption`, `.put`, `.transform`, `.place`, `.transfer`, `.andThen` (carrier-morphing plus the + * read-only / AffineFold / Setter / Review collapses), `.readOnly`, `.cross`, `.morph`, + * `.headOption`, `.length`, `.exists`. Adding a new carrier means supplying the typeclass + * instances of the operations it should support. */ object Optic: @@ -135,15 +136,13 @@ object Optic: def from(xd: F[X, D]): T = o.from(F.map(xd, f)) /** The identity optic — `S` is its own focus and modification has no effect. Carrier is - * [[data.Direct]] (no leftover). + * [[data.Direct]] (no leftover). Returns the concrete [[BijectionIso]] (not an anonymous + * `Optic`) so `id.andThen(x)` picks up the fused compose members like any other Iso. * * @group Constructors */ - def id[A]: Optic[A, A, A, A, Direct] = - new Optic[A, A, A, A, Direct]: - type X = Nothing - def to(a: A): Direct[X, A] = Direct(a) - def from(da: Direct[X, A]): A = da.value + def id[A]: BijectionIso[A, A, A, A] = + Iso[A, A, A, A](identity, identity) /** Cross-carrier `.andThen` — picks the direction via a summoned [[Morph]] when the two optics' * carriers differ. With one exception (`forget2multifocus` / `multifocus2forget`), cats-eo ships @@ -238,9 +237,15 @@ object Optic: * Unit`). Sound for every carrier (`from` ignores its input); this is the seam adapter the * partial-reader `andThen(Getter)` collapse composes through. * + * `inline` so each call site splices its own anonymous class (E197 is the point, not a smell): + * a single shared wrapper class would host one `self.to` dispatch site profiled over every + * projected optic — the megamorphic trap PrintInlining exposed on the abstract-class + * `DirectGetter`. Per-site splicing keeps each projection's `to` monomorphic. + * * @group Operations */ - def readOnly: Optic[S, Unit, A, Unit, F] = + @annotation.nowarn("id=E197") + inline def readOnly: Optic[S, Unit, A, Unit, F] = new Optic[S, Unit, A, Unit, F]: type X = self.X def to(s: S): F[X, A] = self.to(s) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala index a0c02368..78a7c88f 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala @@ -74,13 +74,13 @@ final class MendTearPrism[S, T, A, B]( case Right(b) => mend(b) case Left(t) => t - inline def modify[X](f: A => B): S => T = + inline def modify(f: A => B): S => T = s => tear(s) match case Right(a) => mend(f(a)) case Left(t) => t - inline def replace[X](b: B): S => T = + inline def replace(b: B): S => T = s => tear(s) match case Right(_) => mend(b) @@ -198,13 +198,13 @@ final class PickMendPrism[S, A, B]( case Right(b) => mend(b) case Left(s) => s - inline def modify[X](f: A => B): S => S = + inline def modify(f: A => B): S => S = s => pick(s) match case Some(a) => mend(f(a)) case None => s - inline def replace[X](b: B): S => S = + inline def replace(b: B): S => S = s => pick(s) match case Some(_) => mend(b) From 1aae23772e18c8aacde006fa7bfcfeaaabe1aa07 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 14:35:39 +0200 Subject: [PATCH 04/14] =?UTF-8?q?docs(brainstorms):=20two=20spikes=20?= =?UTF-8?q?=E2=80=94=20Unfold=20(build-many=20cell)=20+=20failure-typed=20?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unfold: inhabit the empty build-only/many lattice cell. Flags that the proposed `T => F[B]` is Fold-shaped (project/coalgebra) and the genuine gap is the dual `embed: F[B] => T` (algebra); ties it to direct2forget's `???`, Plated.plate's latent project/embed, and Schemes.cata/ana. - Failure-typed build: generalize Affine to miss on the build side too. Lays out 3 candidate encodings (focus-channel / BiAffine / Ior-carrier), the Optic.from signature fork, and the fallibility-as-third-axis question. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-10-failure-typed-build-biaffine.md | 122 ++++++++++++++++++ .../2026-06-10-unfold-build-many-citizen.md | 119 +++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md create mode 100644 docs/brainstorms/2026-06-10-unfold-build-many-citizen.md diff --git a/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md b/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md new file mode 100644 index 00000000..e187b02b --- /dev/null +++ b/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md @@ -0,0 +1,122 @@ +--- +date: 2026-06-10 +topic: failure-typed-build-biaffine +spike: open (collaborative — design space, not a decision) +--- + +# Failure-typed build: generalizing `Affine` to miss both ways + +## Problem Frame + +Every eo optic's `from` is **total**: `from: F[X, B] => T` always produces a `T`. That encodes a +real assumption — *writing back never fails*. It holds for Lens (copy), Iso (bijection), Prism +(`mend` is total — partiality is read-only), Optional, Setter. + +It stops holding the moment a focus has an **invariant the write must respect**: + +- Smart constructors: `Age.from(Int): Either[Err, Age]`. Today eo models this as a `Prism` + (`getOption` is the fallible direction, `mend: Age => Int` the total one) — fine when the failure + is naturally a *read*. But "I have an `Int`, write it into the `Age` slot, and that can fail" is a + **fallible build**, and eo has no optic whose `from` can say no. +- The integrations already gesture at this. `circe`/`avro`/`jsoniter` decode through an + `Ior[Chain[Failure], A]` channel (`JsonFailure`, `AvroFailure`, `parseInputIor`, + `AvroTraversal`'s per-element `Ior.Both` accumulation). Decode = read; but **encode/splice can + also fail or partially fail**, and the current `from: … => Array[Byte] | Json | IndexedRecord` has + nowhere to put that — it throws or silently passes through. + +`Affine` is the natural place to generalize, because it's already the carrier that admits a *miss*: + +``` +Affine[X, A] = Miss(fst: Fst[X]) -- read missed; carry leftover for pass-through + | Hit (snd: Snd[X], b: A) -- read hit; carry focus + leftover +to : S => Affine[X, A] -- the read may Miss +from : Affine[X, B] => T -- TOTAL: Miss passes the leftover through, Hit rebuilds +``` + +The miss lives **only on the read** (in `to`). The ask: a carrier where the **build can miss too**. + +## What "miss both ways" could mean (the design space) + +Three distinct things hide under "fallible build" — worth separating before encoding: + +1. **Build-rejects** — `from` returns "this `B` can't be written here" (invariant violated). Short- + circuit, `Either[E, T]`-shaped. +2. **Build-accumulates** — a multi-focus write where *some* elements fail; keep the successes, + collect the failures. `Ior[E, T]`-shaped. This is **exactly what `AvroTraversal` already does on + read** — the spike would make it symmetric on write. +3. **Build-from-nothing** — the `ana`/corecursion case: build can choose to *stop* (produce a leaf + rather than recurse). That's a different "miss" (a coalgebra returning the base case), arguably + the [[2026-06-10-unfold-build-many-citizen]] spike's territory, not this one. + +This spike targets **(1) and (2)**: a focus type whose write-back is partial, with an error channel. + +## Candidate encodings (react to these) + +**Candidate A — error channel in the focus.** Leave `Affine` alone; make the focus `Either[E, A]` +or `Ior[E, A]`. The optic is an ordinary `Optional[S, T, Either[E,A], Either[E,B]]`. *Pro:* zero new +carrier; reuses everything. *Con:* the failure is data, not structure — `getOption`/`modify` don't +*know* it's a failure, so laws and short-circuiting aren't enforced; every consumer re-implements +"is this a real focus or an error." Probably too weak. + +**Candidate B — `BiAffine`: a four-constructor carrier.** Make miss a first-class outcome on both +sides: + +``` +BiAffine[X, A] = ReadMiss (fst: Fst[X]) -- to produced no focus + | Hit (snd: Snd[X], b: A) -- focus present + | WriteMiss(err: E, fst: Fst[X]) -- from rejected the B (NEW) +to : S => BiAffine[X, A] -- ReadMiss | Hit +from : BiAffine[X, B] => Either[E, T] -- Hit may become WriteMiss → Left(E) +``` + +*Pro:* the failure is structural; composition can thread it; laws can be stated (`getOption` ⊥ on +ReadMiss, `replace` may ⊥ on WriteMiss). *Con:* `from`'s return type changes shape (`Either[E,T]`, +not `T`) — touches the `Optic` trait's `from` signature or needs a carrier-local escape. The +`E`/error type has to live *somewhere* (carrier param? focus-adjacent? a fixed `Chain[Failure]`?). + +**Candidate C — `Validated`/`Ior` carrier (accumulating).** Generalize the carrier's *summary* type +from `Affine` (0-or-1) to `Ior[E, ·]` so both `to` and `from` carry an accumulating error channel — +the per-element-failure shape `AvroTraversal` already runs on read, made into a reusable carrier and +extended to write. *Pro:* directly matches the integration story (the `Ior` channel is already +there); subsumes the traversal-with-partial-failures case. *Con:* the most invasive; interacts with +`MultiFocus` (many foci, each can fail each way). + +## The hard questions (where I want your steer) + +- **Q1 — does `from`'s type change, or do we keep `from: F[X,B] => T` and put failure in `T`/`F`?** + Changing `Optic.from` to allow a fallible result is the deepest fork in the road: it either + becomes `from: F[X, B] => T` with failure *encoded in the carrier `F`* (so `T` is morally + `Either[E, T']` and the carrier knows it — Candidate B/C), or we keep `from` total and the error + is in the focus (Candidate A). I lean toward "failure in the carrier" — it's the only option where + the optic *laws* can mention it — but it's the expensive one. + +- **Q2 — short-circuit (`Either`) or accumulate (`Ior`)?** The integrations want accumulate (collect + every bad field). Pure smart-constructor semantics want short-circuit. Is the carrier parametric + in the error semiring (`E: Semigroup` ⇒ `Ior`), with `Either` the degenerate + no-accumulation case? + +- **Q3 — does this *subsume* `Affine`/`Prism`?** Affine = "WriteMiss uninhabited (`E = Nothing`)". + Prism = "read-miss only, build total". A clean generalization would let both fall out as the + `E = Nothing` / write-total specializations — the same way `AffineFold` is `Optional` with + `T=B=Unit`. If the encoding *doesn't* recover them cleanly, that's a smell. + +- **Q4 — where does it sit in the family lattice?** It's a *new axis*: not read-capability + (read/build/both) but **fallibility** (which directions can fail). Does the two-axis table become + three-axis (focus-shape × capability × fallibility), and if so does every existing cell get a + fallible variant, or only the partial row? + +- **Q5 — laws.** What replaces the Optional round-trips when build can fail? Candidate: + `getOption(s) = Some(a)` ⟹ `replace(b)(s)` either succeeds-and-round-trips or fails-with-`E` + (never silently no-ops). The "fail loud" property is the whole point. + +## Concrete first step (proposed — your call) + +Stand up **Candidate B (`BiAffine`)** as a throwaway carrier in a branch, with `E` fixed to +`Chain[String]` (defer the parametric-error question), and try to express **one real case**: +`circe`'s street-write where the codec encode can fail. See what breaks at the `Optic.from` +signature seam — that single friction point (does `from` stay total?) is the decision the whole +spike turns on, and it's cheap to surface. Then bring it back here and decide A vs B vs C together. + +This is the one you wanted to drive — so I've left A/B/C and Q1–Q5 open rather than picking. Tell me +which candidate to prototype first (my vote: B, fixed-error, one circe case) and whether `from` +changing shape is on or off the table. diff --git a/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md b/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md new file mode 100644 index 00000000..8882eaaa --- /dev/null +++ b/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md @@ -0,0 +1,119 @@ +--- +date: 2026-06-10 +topic: unfold-build-many-citizen +spike: open +--- + +# `Unfold` — inhabiting the build-only / many cell + +## Problem Frame + +The optic family is a two-axis lattice: **focus shape** (carrier) × **capability** (which of +`to` / `from` is vestigial). Filling in every cell: + +| focus shape | read-write | read-only (`T=B=Unit`) | build-only (`S=A=Unit`) | +|---|---|---|---| +| total, 1 (`Direct`/`Tuple2`) | Iso / Lens | Getter — `S => A` | Review — `A => S` | +| partial, 0-or-1 (`Either`/`Affine`) | Prism / Optional | AffineFold — `S => Option[A]` | *≡ Review* (a Prism's `mend` is total) | +| many (`Forget`/`MultiFocus`) | Traversal | Fold — `S => F[A]` | **∅ ← this spike** | + +Plus `Setter` (`(A=>B) => S=>T`) as a capability-only, carrier-agnostic fourth column (the lattice +bottom). The recursion schemes land on the corners: **`cata` → Getter** (read-only/total — tear a +structure to a value), **`ana` → Review** (build-only/total — grow a structure from a seed), +`hylo` = the diagonal that never materializes the middle. + +The bottom-right cell is empty. This spike is about what lives there. + +## The shape question (resolve first) + +The originating suggestion named the cell `Unfold` with shape `T => F[B]`. But `T => F[B]` is +*one → many*, which is **`Fold`'s** arrow direction (`Fold[S,A]` has `to: S => F[A]`). An optic +whose real map is `to: T => F[B]` on `Forget[F]` is **structurally `Fold[T, B]`** — same carrier +slot, same vestigial `from`. So `T => F[B]` does **not** inhabit the empty cell; it re-derives a +filled one. + +The genuinely-empty cell is the **dual**: build-only/many means the real map is `from`, and on the +`Forget[F]`/`MultiFocus[F]` carrier that is + +``` +embed / algebra : F[B] => T (many → one) +``` + +This is the across-both-axes dual of `Fold` (`S => F[A]` reversed). It is the **algebra** of a +recursion scheme, and it is the F-shape `embed` of `Recursive`/`Corecursive`: + +``` +project : S => F[S] -- Recursive (Fold-slot; one → many) +embed : F[S] => S -- Corecursive (the empty cell; many → one) +``` + +`Plated.plate : Optic[S,S,S,S,MultiFocus[PSVec]]` already **bundles both** (`to: S => (S,PSVec[S])` +is project-ish, `from: (S,PSVec[S]) => S` is embed-ish) — the recursion-schemes brainstorm +(2026-06-08) noted this latent structure. So the family already *contains* embed inside a +read-write traversal; what's missing is the standalone **build-only** citizen that exposes embed +alone. + +**Naming proposal.** Keep `Unfold` for the corecursive citizen, but pin it to the empty cell's +honest map `embed: F[B] => T`. (An "unfold" *produces* a structure; `embed` produces one `T` from a +layer of already-built parts — the one-step of corecursion. The aggregation reading — "build an +`Order` from its line-items" — is the same map.) If we'd rather the name track the arrow literally, +the alternatives are `Algebra` / `Embed` / `Fuse`. **Open: confirm `Unfold = F[B] => T`** before any +code. + +## Why it's worth a citizen (not just a function) + +1. **It makes `direct2forget`'s `???` real.** `Composer.direct2forget` (Composer.scala) lifts a + Direct optic into `Forget[F]` and its `from` is the one remaining `???` in core — documented as + unreachable because "`Forget[F]` admits no `ReverseAccessor`… the build side is never invoked." + That `???` *is* this empty cell. A carrier that genuinely has a build side turns the hole into a + sound branch (or sharpens why it stays unreachable). + +2. **`Schemes.cata` consumes an algebra as a plain function today.** `Schemes.cata(eoSum)` takes + `F[A] => A` and returns the composed `DirectGetter`. Promoting the algebra to an `Unfold` citizen + makes `cata = plateFold.cross(unfold)` a genuine *composition* — the same move that already made + `ana` return a `Review` rather than a bespoke function. `hylo = unfold ∘ fold` becomes an optic + `.andThen`/`.cross` instead of a hand-written driver. + +3. **Aggregation as an optic.** `F[B] => T` is "assemble one whole from many parts" — a `Monoid`-ish + reduce with a real result type. There is no optic for this today; users drop to `foldMap` + a + manual rebuild. + +## Sketch (to validate in the spike) + +```scala +// carrier: Forget[F] (Forget[F][X,A] = F[A]) — same as Fold, opposite capability +final class Unfold[T, B, F[_]](val embed: F[B] => T)(using ???) + extends Optic[Unit, T, Unit, B, Forget[F]]: + type X = Nothing + def to(u: Unit): F[Unit] = ??? // vestigial — but F[Unit] needs an F-value; see Q2 + def from(fb: F[B]): T = embed(fb) +``` + +## Open questions + +- **Q1 (shape/name).** Confirm `Unfold = embed = F[B] => T` (recommended) vs. the literal + `T => F[B]` (which is `Fold` and needs no new cell). This decides everything below. +- **Q2 (vestigial `to`).** `Getter`/`Fold` zero out `from` trivially (`Unit`). But `Unfold`'s + vestigial side is `to: Unit => F[Unit]` — there's no canonical `F[Unit]` without `Applicative[F]` + (`pure(())`). Does `Unfold` require `Applicative[F]`, or do we admit a carrier where `to` is + genuinely unreachable (mirror of `direct2forget`'s `from`)? This is the dual of the read-only + optics' "`from` ignores its input" and may want the same `readOnly`-style honesty. +- **Q3 (constraint ladder).** Read-only `Fold` needs `Foldable[F]`. What does `Unfold` need — + `Applicative[F]` (assemble) or something weaker? Is there a `Functor`-only useful subset? +- **Q4 (carrier: `Forget[F]` vs `MultiFocus[F]`).** `Forget` drops the leftover `X`; `embed` of a + recursion layer may need the structural skeleton (cf. `Plated` using `MultiFocus[PSVec]` precisely + so the skeleton survives). Which carrier does the standalone citizen want? +- **Q5 (fused, final-class from day one).** Per the encoding findings (final class + stored fn + + fused `andThen`, never a shared anon wrapper). What composes with `Unfold`? `unfold.andThen(unfold)` + (algebra composition?), `review.cross(unfold)` (the hylo seam)? +- **Q6 (scope).** Does this live in `core` (as the suggestion proposed) or in `schemes` (its only + current consumer)? Argument for core: it completes the lattice and removes a core `???`. Argument + for schemes: no non-scheme use case is known yet. + +## Smallest viable spike + +Define `Unfold` on `Forget[F]` with `embed: F[B] => T`, `Applicative[F]` for the vestigial `to`, +fused final-class. Rewrite `Schemes.cata` to build `plateFold.cross(unfold)` and confirm it matches +the current `DirectGetter` result (behaviour + the typed-schemes B/op machine numbers). Success +criterion: `cata`/`hylo` expressed as composition, and `direct2forget`'s `from` either becomes sound +or its unreachability is provable from `Unfold`'s laws. From fa32e203af7bfcfeb89eb0ef41a57db6d4fdd0cd Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 22:02:38 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat(core):=20close=20the=20composition?= =?UTF-8?q?=20matrix=20=E2=80=94=20ReadCompose=20collapse,=20opaque=20carr?= =?UTF-8?q?iers,=20matrix=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every semantically meaningful optic∘optic pair (80 of 100) now composes via plain .andThen with no expected-type ascription and no data.*.given imports; the other 20 are void by design (build through non-invertible, write through read-only, read through write-only) and are pinned as must-not-compile. - ReadCompose[FO, FI]: read-side composition across the read/write seam. Read-arity lattice (total/partial/many -> Getter/PickFold/ForgetFold[List]), keyed on Accessor/PartialAccessor with a low-priority ForgetfulFold fallback; instances in its own companion so they are in implicit scope for every carrier pair. Surfaced as members (extension fallback does not fire after member overload failure): a trait-level any∘read-only overload (inner T pinned to Unit, B left free so the same-carrier member stays strictly more specific), andThenReadAny on Getter/PickFold/ForgetFold (override-rehomed trait overload to break the signature-vs-owner overload tie), fused andThen(Getter) on BijectionIso/GetReplaceLens, and a new ForgetfulFold[Direct]. - Opaque carriers: ForgetK[F, X, A] / MultiFocusK[F, X, A] (opaque types cannot be curried, so uncurried opaque + thin curried alias keeps the Forget[F]/MultiFocus[F] carrier spelling). Dealiasing now stops at an implicit-scope anchor, so the carrier givens resolve with no import; object Forget/MultiFocus re-export the K companions for API and legacy import compatibility. Instance member signatures respelled in alias form (given-with instances are typed by their anonymous class, and dealiased signatures leak through inline extensions). PSVec cats instances moved to the PSVec companion — the actual implicit-scope anchor for Functor[PSVec]. - CompositionMatrixSpec: all 100 cells asserted via compiletime.testing (compiles unascribed + lands at the expected strength, or does not compile), deliberately import-free. Verified against a real-compiler all-pairs harness: 100/100 agreement. Carried from the previous session: Accessors.scala split into Accessor/PartialAccessor/ReverseAccessor, removal of the dangling ReadCompose references from the reverted spike, and the schemes.md DirectGetter -> Getter rename catch-up. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/GetterBench.scala | 2 +- .../circe/CrossCarrierCompositionSpec.scala | 6 +- .../dev/constructive/eo/.#ReadCompose.scala | 1 + .../eo/{Accessors.scala => Accessor.scala} | 15 - .../scala/dev/constructive/eo/Composer.scala | 2 +- .../dev/constructive/eo/PartialAccessor.scala | 14 + .../dev/constructive/eo/ReadCompose.scala | 125 ++++ .../dev/constructive/eo/ReverseAccessor.scala | 17 + .../dev/constructive/eo/data/Direct.scala | 10 + .../dev/constructive/eo/data/Forget.scala | 52 +- .../dev/constructive/eo/data/MultiFocus.scala | 140 ++--- .../dev/constructive/eo/data/PSVec.scala | 68 ++ .../constructive/eo/optics/AffineFold.scala | 32 +- .../dev/constructive/eo/optics/Fold.scala | 36 +- .../dev/constructive/eo/optics/Getter.scala | 46 +- .../dev/constructive/eo/optics/Iso.scala | 10 +- .../dev/constructive/eo/optics/Lens.scala | 8 + .../dev/constructive/eo/optics/Optic.scala | 259 +++----- .../dev/constructive/eo/optics/Optional.scala | 5 +- .../dev/constructive/eo/optics/Plated.scala | 4 +- .../dev/constructive/eo/optics/Prism.scala | 11 +- .../dev/constructive/eo/optics/Review.scala | 25 +- .../dev/constructive/eo/optics/Setter.scala | 15 +- .../constructive/eo/optics/Traversal.scala | 40 +- .../scala/dev/constructive/eo/FoldSpec.scala | 2 +- .../eo/jsoniter/JsoniterTraversal.scala | 10 +- .../dev/constructive/eo/schemes/Schemes.scala | 10 +- .../constructive/eo/schemes/SchemesSpec.scala | 10 +- site/docs/schemes.md | 8 +- .../eo/CompositionMatrixSpec.scala | 591 ++++++++++++++++++ .../constructive/eo/OpticsBehaviorSpec.scala | 48 +- .../dev/constructive/eo/OpticsLawsSpec.scala | 29 +- .../dev/constructive/eo/PowerSeriesSpec.scala | 2 +- 33 files changed, 1245 insertions(+), 408 deletions(-) create mode 120000 core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala rename core/src/main/scala/dev/constructive/eo/{Accessors.scala => Accessor.scala} (52%) create mode 100644 core/src/main/scala/dev/constructive/eo/PartialAccessor.scala create mode 100644 core/src/main/scala/dev/constructive/eo/ReadCompose.scala create mode 100644 core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala create mode 100644 tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/GetterBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/GetterBench.scala index fbdb6f3a..23c47dff 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/GetterBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/GetterBench.scala @@ -10,7 +10,7 @@ import dev.constructive.eo.bench.fixture.* * * EO `Getter`s are `Optic[S, Unit, A, Unit, Direct]` — both the leftover `T` and the back-focus * `B` are `Unit`, so they compose through the ordinary `andThen` (the fused - * `DirectGetter.andThen`), just like `Iso` / `Lens`. The depth-3 / depth-6 rows therefore build a + * `Getter.andThen`), just like `Iso` / `Lens`. The depth-3 / depth-6 rows therefore build a * *composed* `Getter` on both sides (EO's `g3.andThen(g2)…` vs Monocle's `mG3.andThen(mG2)…`) and * dispatch through it once — an apples-to-apples comparison, not EO's hand-nested `get` chain * against Monocle's composed reader. diff --git a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala index 91d442ed..2cfb2880 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala @@ -182,7 +182,7 @@ class CrossCarrierCompositionSpec extends Specification: // counts correctly). "(6) JsonPrism → MultiFocus[List]: Composer summons + chain compiles" >> { import cats.instances.list.given // Alternative[List] + Foldable[List] - import dev.constructive.eo.data.MultiFocus + import dev.constructive.eo.data.{MultiFocus, MultiFocusK} import dev.constructive.eo.data.MultiFocus.given // mfFunctor + either2multifocus import dev.constructive.eo.Composer @@ -195,8 +195,8 @@ class CrossCarrierCompositionSpec extends Specification: val tagsMF: Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]] = new Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]]: type X = Unit - def to(xs: List[Int]): (Unit, List[List[Int]]) = ((), List(xs)) - def from(pair: (Unit, List[List[Int]])): List[Int] = pair._2.head + def to(xs: List[Int]): MultiFocus[List][X, List[Int]] = MultiFocusK.wrap((), List(xs)) + def from(pair: MultiFocus[List][X, List[Int]]): List[Int] = pair.foci.head // The cross-carrier .andThen resolves through `either2multifocus[List]`. val chain: Optic[Json, Json, List[Int], List[Int], MultiFocus[List]] = diff --git a/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala new file mode 120000 index 00000000..85bc9efb --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala @@ -0,0 +1 @@ +rhansen@rh-anine.hr-home.xyz.830766:1780957860 \ No newline at end of file diff --git a/core/src/main/scala/dev/constructive/eo/Accessors.scala b/core/src/main/scala/dev/constructive/eo/Accessor.scala similarity index 52% rename from core/src/main/scala/dev/constructive/eo/Accessors.scala rename to core/src/main/scala/dev/constructive/eo/Accessor.scala index d41a8fd7..a7068f0e 100644 --- a/core/src/main/scala/dev/constructive/eo/Accessors.scala +++ b/core/src/main/scala/dev/constructive/eo/Accessor.scala @@ -16,18 +16,3 @@ object Accessor: /** `Tuple2` — picks the right element. @group Instances */ given tupleAccessor: Accessor[Tuple2] with def get[X, A](fa: (X, A)): A = fa._2 - -/** Dual of [[Accessor]] — build a fresh `F[X, A]` from an `A`. Required by `Optic.reverseGet`. - * - * @tparam F - * the carrier - */ -trait ReverseAccessor[F[_, _]]: - def reverseGet[X, A](a: A): F[X, A] - -/** Typeclass instances for [[ReverseAccessor]]. */ -object ReverseAccessor: - - /** `Either` — wraps `A` as `Right[X, A](a)`. @group Instances */ - given eitherRevAccessor: ReverseAccessor[Either] with - def reverseGet[X, A](a: A): Either[X, A] = Right[X, A](a) diff --git a/core/src/main/scala/dev/constructive/eo/Composer.scala b/core/src/main/scala/dev/constructive/eo/Composer.scala index 67d7def7..84ef34d0 100644 --- a/core/src/main/scala/dev/constructive/eo/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/Composer.scala @@ -74,7 +74,7 @@ object Composer extends LowPriorityComposerInstances: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, data.Forget[F]] = new Optic[S, T, A, B, data.Forget[F]]: type X = Nothing - def to(s: S): data.Forget[F][X, A] = F.pure(o.to(s).value) + def to(s: S): data.Forget[F][X, A] = data.ForgetK(F.pure(o.to(s).value)) def from(u: data.Forget[F][X, B]): T = ??? /** Low-priority `Composer` instances — diff --git a/core/src/main/scala/dev/constructive/eo/PartialAccessor.scala b/core/src/main/scala/dev/constructive/eo/PartialAccessor.scala new file mode 100644 index 00000000..5183fc52 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/PartialAccessor.scala @@ -0,0 +1,14 @@ +package dev.constructive.eo +package data + +trait PartialAccessor[F[_, _]]: + + def getOption[X, A](fa: F[X, A]): Option[A] + +object PartialAccessor: + + given eitherAccessor: PartialAccessor[Either] with + def getOption[X, A](fa: Either[X, A]) = fa.toOption + + given affineAccessor: PartialAccessor[Affine] with + def getOption[X, A](fa: Affine[X, A]): Option[A] = fa.fold(_ => None, (_, h) => Some(h)) diff --git a/core/src/main/scala/dev/constructive/eo/ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/ReadCompose.scala new file mode 100644 index 00000000..96f570d7 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/ReadCompose.scala @@ -0,0 +1,125 @@ +package dev.constructive.eo + +import cats.Monoid + +import data.{Accessor, PartialAccessor} +import optics.{ForgetFold, Getter, Optic, PickFold} + +/** Read-side composition across the read/write seam — the join that `AssociativeFunctor` (which + * needs B-threading on a *shared* carrier) structurally cannot express. Composes the READ halves + * of two optics, ignoring both write sides, and lands at the read-only join of their strengths: + * + * {{{ + * total ∘ total = Getter (Accessor ∘ Accessor) + * total ∘ partial = AffineFold (any mix involving a PartialAccessor) + * partial ∘ total = AffineFold + * partial ∘ partial = AffineFold + * many on either side = Fold (ForgetfulFold fallback, List-backed) + * }}} + * + * Drives the read-collapse `andThen` members: the `Optic` trait's *any-outer ∘ read-only-inner* + * overload and the *read-only-outer ∘ any-inner* members on [[optics.Getter]] / + * [[optics.PickFold]] / [[optics.ForgetFold]]. Instances live in this companion, so they are in + * implicit scope for every carrier pair with no import. + * + * @tparam FO + * outer optic's carrier + * @tparam FI + * inner optic's carrier + */ +trait ReadCompose[FO[_, _], FI[_, _]]: + /** Concrete read-only optic family the composite lands in — `Getter`, `PickFold`, or + * `ForgetFold[*, List, *]`; concrete so collapsed chains keep collapsing through the fused + * members. + */ + type Out[_, _] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): Out[S, C] + +/** Typeclass instances for [[ReadCompose]] — the read-arity lattice. The four total/partial cells + * sit at normal priority; the many-fold fallback sits in [[LowPriorityReadCompose]] because + * carriers with an `Accessor` / `PartialAccessor` usually admit a `ForgetfulFold` too, and the + * stronger read must win. + */ +object ReadCompose extends LowPriorityReadCompose: + + /** total ∘ total → [[optics.Getter]]. @group Instances */ + given totalTotal[FO[_, _], FI[_, _]](using + AO: Accessor[FO], + AI: Accessor[FI], + ): ReadCompose[FO, FI] with + type Out[S, C] = Getter[S, C] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): Getter[S, C] = + Getter(s => AI.get(inner.to(AO.get(outer.to(s))))) + + /** total ∘ partial → [[optics.PickFold]]. @group Instances */ + given totalPartial[FO[_, _], FI[_, _]](using + AO: Accessor[FO], + PI: PartialAccessor[FI], + ): ReadCompose[FO, FI] with + type Out[S, C] = PickFold[S, C] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): PickFold[S, C] = + PickFold(s => PI.getOption(inner.to(AO.get(outer.to(s))))) + + /** partial ∘ total → [[optics.PickFold]]. @group Instances */ + given partialTotal[FO[_, _], FI[_, _]](using + PO: PartialAccessor[FO], + AI: Accessor[FI], + ): ReadCompose[FO, FI] with + type Out[S, C] = PickFold[S, C] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): PickFold[S, C] = + PickFold(s => PO.getOption(outer.to(s)).map(a => AI.get(inner.to(a)))) + + /** partial ∘ partial → [[optics.PickFold]]. @group Instances */ + given partialPartial[FO[_, _], FI[_, _]](using + PO: PartialAccessor[FO], + PI: PartialAccessor[FI], + ): ReadCompose[FO, FI] with + type Out[S, C] = PickFold[S, C] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): PickFold[S, C] = + PickFold(s => PO.getOption(outer.to(s)).flatMap(a => PI.getOption(inner.to(a)))) + +/** Low-priority drawer for [[ReadCompose]] — the many-fold fallback. */ +trait LowPriorityReadCompose: + + /** Anything foldable ∘ anything foldable → [[optics.ForgetFold]] over `List`. Fires only when no + * total/partial instance applies, i.e. at least one side is genuinely multi-focus + * (`MultiFocus[F]` / `Forget[F]`). + * + * @group Instances + */ + given foldFold[FO[_, _], FI[_, _]](using + FOF: ForgetfulFold[FO], + FIF: ForgetfulFold[FI], + ): ReadCompose[FO, FI] with + type Out[S, C] = ForgetFold[S, List, C] + + def compose[S, OT, A, OB, IT, C, IB]( + outer: Optic[S, OT, A, OB, FO], + inner: Optic[A, IT, C, IB, FI], + ): ForgetFold[S, List, C] = + new ForgetFold[S, List, C](s => + FOF.foldMap( + (a: A) => FIF.foldMap((c: C) => c :: Nil, inner.to(a))(using Monoid[List[C]]), + outer.to(s), + )(using Monoid[List[C]]) + ) diff --git a/core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala b/core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala new file mode 100644 index 00000000..ceeab3eb --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala @@ -0,0 +1,17 @@ +package dev.constructive.eo +package data + +/** Dual of [[Accessor]] — build a fresh `F[X, A]` from an `A`. Required by `Optic.reverseGet`. + * + * @tparam F + * the carrier + */ +trait ReverseAccessor[F[_, _]]: + def reverseGet[X, A](a: A): F[X, A] + +/** Typeclass instances for [[ReverseAccessor]]. */ +object ReverseAccessor: + + /** `Either` — wraps `A` as `Right[X, A](a)`. @group Instances */ + given eitherRevAccessor: ReverseAccessor[Either] with + def reverseGet[X, A](a: A): Either[X, A] = Right[X, A](a) diff --git a/core/src/main/scala/dev/constructive/eo/data/Direct.scala b/core/src/main/scala/dev/constructive/eo/data/Direct.scala index 6ea59ccc..d41099fc 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Direct.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Direct.scala @@ -44,6 +44,16 @@ object Direct: def reverseGet[X, A](a: A): Direct[X, A] = a + /** Single-focus fold — applies `f` to the one focus. Unlocks `.foldMap` on Direct-carrier optics + * and lets `ReadCompose`'s many-fold tier pair a Direct side with a multi-focus side (`getter ∘ + * traversal`, `traversal ∘ getter`, …). + * + * @group Instances + */ + given fold: ForgetfulFold[Direct] with + + def foldMap[X, A, M: cats.Monoid](f: A => M, fa: Direct[X, A]): M = f(fa) + /** `pure[X, A] = a`; unlocks `.put` on Iso / Getter. @group Instances */ given applicative: ForgetfulApplicative[Direct] with diff --git a/core/src/main/scala/dev/constructive/eo/data/Forget.scala b/core/src/main/scala/dev/constructive/eo/data/Forget.scala index df1548df..e5424d07 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Forget.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Forget.scala @@ -16,10 +16,19 @@ import optics.Optic * ([[dev.constructive.eo.data.MultiFocus]]) as a uniform "F-shape carrier" whose optic-level * capabilities scale with the typeclasses `F` itself admits. */ -// `[X, A] =>> F[A]` — the `X` is phantom, so `Forget` is its own transparent carrier (it does *not* -// route through the now-opaque `Direct`; all the Fold / Forget machinery treats `Forget[F][X, A]` -// as a plain `F[A]` and relies on that transparency). -type Forget[F[_]] = [X, A] =>> F[A] +// `[X, A] =>> F[A]` — the `X` is phantom. The uncurried [[ForgetK]] is `opaque` for the same +// reason [[Direct]] is: a transparent alias dealiases to a bare type lambda, which has no +// companion in implicit scope, so `ForgetfulFold[Forget[F]]`, `AssociativeFunctor[Forget[F], …]`, +// etc. were invisible without an explicit `import data.Forget.given`. With the opaque anchor +// inside, dealiasing `Forget[F][X, A]` stops at `ForgetK[F, X, A]`, whose companion (where the +// instances live) *is* an implicit-scope anchor — no import needed. (`Forget` itself must stay a +// plain alias: an opaque type cannot have the curried `[F[_]] => [X, A] =>> …` shape.) Within this +// file the opaque is transparent; outside, [[ForgetK.apply]] / [[ForgetK.value]] are the +// (runtime-identity) boundary. +opaque type ForgetK[F[_], X, A] = F[A] + +/** Curried carrier view of [[ForgetK]] — the `F[_, _]` shape `Optic` expects. */ +type Forget[F[_]] = [X, A] =>> ForgetK[F, X, A] /** Capability ladder for [[Forget]]. Each typeclass on `F` unlocks a matching optic operation: * @@ -37,7 +46,16 @@ type Forget[F[_]] = [X, A] =>> F[A] * injects trivially into it via `Composer[Forget[F], MultiFocus[F]]`. Direct-targeting instances * live in [[Direct]]. */ -object Forget extends LowPriorityForgetInstances: +object ForgetK extends LowPriorityForgetInstances: + + /** Wrap an `F[A]` into the `Forget[F]` carrier. Identity at runtime (the opaque type erases to + * `F[A]`); needed only so construction sites outside this file satisfy the `Forget[F][X, A]` + * type. + */ + transparent inline def apply[F[_], X, A](fa: F[A]): ForgetK[F, X, A] = fa + + /** Unwrap the carried `F[A]`. Identity at runtime. */ + extension [F[_], X, A](self: ForgetK[F, X, A]) transparent inline def value: F[A] = self /** Strategy for "redistribute the inner `from` across the F-context" on the pull side. The Monad * form ignores the `F[D]` and lifts a single `inner.from(xd)` via `pure`; the Comonad form @@ -98,7 +116,7 @@ object Forget extends LowPriorityForgetInstances: * @group Instances */ given forgetFFunctor[F[_]](using F: Functor[F]): ForgetfulFunctor[Forget[F]] with - def map[X, A, B](fa: F[A], f: A => B): F[B] = F.map(fa)(f) + def map[X, A, B](fa: Forget[F][X, A], f: A => B): Forget[F][X, B] = F.map(fa)(f) /** `ForgetfulApplicative[Forget[F]]` via any `Applicative[F]`. Unlocks `Optic.put` on * Forget-carrier optics. @@ -106,8 +124,8 @@ object Forget extends LowPriorityForgetInstances: * @group Instances */ given forgetFApplicative[F[_]: Applicative]: ForgetfulApplicative[Forget[F]] with - def map[X, A, B](fa: F[A], f: A => B): F[B] = Applicative[F].map(fa)(f) - def pure[X, A](a: A): F[A] = Applicative[F].pure(a) + def map[X, A, B](fa: Forget[F][X, A], f: A => B): Forget[F][X, B] = Applicative[F].map(fa)(f) + def pure[X, A](a: A): Forget[F][X, A] = Applicative[F].pure(a) /** `ForgetfulFold[Forget[F]]` — delegates to the underlying `Foldable[F]`. Powers * `Fold.apply[F, A]`. @@ -116,7 +134,7 @@ object Forget extends LowPriorityForgetInstances: */ given forgetFFold[F[_]: Foldable]: ForgetfulFold[Forget[F]] with - def foldMap[X, A, M: cats.Monoid](f: A => M, fa: F[A]): M = + def foldMap[X, A, M: cats.Monoid](f: A => M, fa: Forget[F][X, A]): M = Foldable[F].foldMap(fa)(f) /** `ForgetfulTraverse[Forget[F], Applicative]` — lifts `Traverse[F]` into the two-parameter @@ -126,7 +144,10 @@ object Forget extends LowPriorityForgetInstances: */ given forgetFTraverse[F[_]: Traverse]: ForgetfulTraverse[Forget[F], Applicative] with - def traverse[X, A, B, G[_]: Applicative](fa: F[A], f: A => G[B]): G[F[B]] = + def traverse[X, A, B, G[_]: Applicative]( + fa: Forget[F][X, A], + f: A => G[B], + ): G[Forget[F][X, B]] = Traverse[F].traverse(fa)(f) /** Algebraic-lens composition for `F: Monad`. Push: `outer.to(s).flatMap(inner.to)`. Pull: the @@ -143,6 +164,13 @@ object Forget extends LowPriorityForgetInstances: * algebraic-lens composition in the main object). Kept at lower priority so Monad wins when both * apply. */ +/** API façade under the carrier's public name. The instances live in [[ForgetK]] (the opaque + * anchor's companion, where implicit scope finds them); this re-export keeps `Forget.assocFor` + * call-shapes and legacy `import data.Forget.given` working. + */ +object Forget: + export ForgetK.{given, *} + trait LowPriorityForgetInstances: /** Comonad-pull composition for `F: FlatMap + Comonad`. Composes via `coflatMap` on `from`. @@ -150,8 +178,8 @@ trait LowPriorityForgetInstances: * @group Instances */ given assocForgetComonad[F[_]: FlatMap: Comonad, Xo, Xi]: AssociativeFunctor[Forget[F], Xo, Xi] = - Forget.assocFor[F, Xo, Xi]( - new Forget.ForgetPull[F]: + ForgetK.assocFor[F, Xo, Xi]( + new ForgetK.ForgetPull[F]: def redistribute[D, B](xd: F[D])(fromInner: F[D] => B): F[B] = xd.coflatMap(fromInner) diff --git a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index bb669c92..8a5bcb76 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -1,17 +1,7 @@ package dev.constructive.eo package data -import cats.{ - Alternative, - Applicative, - Eval, - Foldable, - Functor, - Monoid, - MonoidK, - Representable, - Traverse, -} +import cats.{Alternative, Applicative, Foldable, Functor, Monoid, MonoidK, Representable, Traverse} import optics.Optic @@ -39,7 +29,19 @@ import optics.Optic * - Same-carrier `.andThen` needs `Traverse[F] + MultiFocusFromList[F]`. * - `fromPrismF` / `fromOptionalF` need `MonoidK[F]`. */ -type MultiFocus[F[_]] = [X, A] =>> (X, F[A]) +// The uncurried [[MultiFocusK]] is `opaque` for the same reason [[Direct]] is: a transparent +// alias dealiases to a bare pair lambda with no companion in implicit scope, so every instance +// below (`mfAssoc*`, the `Composer` bridges, …) was invisible without +// `import data.MultiFocus.given`. With the opaque anchor inside, dealiasing +// `MultiFocus[F][X, A]` stops at `MultiFocusK[F, X, A]`, whose companion (where the instances +// live) *is* an implicit-scope anchor — no import needed. (`MultiFocus` itself must stay a plain +// alias: an opaque type cannot have the curried `[F[_]] => [X, A] =>> …` shape.) Within this file +// the opaque is transparent; outside, [[MultiFocusK.wrap]] / [[MultiFocusK.context]] / +// [[MultiFocusK.foci]] are the (runtime-identity) boundary. +opaque type MultiFocusK[F[_], X, A] = (X, F[A]) + +/** Curried carrier view of [[MultiFocusK]] — the `F[_, _]` shape `Optic` expects. */ +type MultiFocus[F[_]] = [X, A] =>> MultiFocusK[F, X, A] /** Singleton-classifier fast-path. Lets `mfAssoc` skip the `F.pure` wrap and the * `pickSingletonOrThrow` pull when the inner is known to produce singletons (sole shipped user: @@ -128,87 +130,44 @@ private[eo] trait MultiFocusPSMaybeHit[S, T, A, B]: def reconstructSingleton(y: AnyRef, vys: PSVec[B], pos: Int, len: Int): T -object MultiFocus: - - // PSVec cats instances — kept in this companion so callers picking up - // `import data.MultiFocus.given` get them transitively alongside `mfFunctor` / etc. - - given pSVecFunctor: Functor[PSVec] with +object MultiFocusK: - def map[A, B](fa: PSVec[A])(f: A => B): PSVec[B] = - val n = fa.length - if n == 0 then PSVec.empty[B] - else - val arr = new Array[AnyRef](n) - var i = 0 - while i < n do - arr(i) = f(fa(i)).asInstanceOf[AnyRef] - i += 1 - PSVec.unsafeWrap[B](arr) - - given pSVecFoldable: Foldable[PSVec] with - - def foldLeft[A, B](fa: PSVec[A], b: B)(f: (B, A) => B): B = - var acc = b - val n = fa.length - var i = 0 - while i < n do - acc = f(acc, fa(i)) - i += 1 - acc - - def foldRight[A, B](fa: PSVec[A], lb: Eval[B])( - f: (A, Eval[B]) => Eval[B] - ): Eval[B] = - val n = fa.length - def loop(i: Int): Eval[B] = - if i >= n then lb - else f(fa(i), Eval.defer(loop(i + 1))) - Eval.defer(loop(0)) - - override def size[A](fa: PSVec[A]): Long = fa.length.toLong - - given pSVecTraverse: Traverse[PSVec] with - - def traverse[G[_]: Applicative, A, B](fa: PSVec[A])(f: A => G[B]): G[PSVec[B]] = - val G = Applicative[G] - val n = fa.length - if n == 0 then G.pure(PSVec.empty[B]) - else - var acc: G[Array[AnyRef]] = G.pure(new Array[AnyRef](n)) - var i = 0 - while i < n do - val idx = i - val gb = f(fa(idx)) - acc = G.map2(acc, gb) { (a, b) => - a(idx) = b.asInstanceOf[AnyRef] - a - } - i += 1 - G.map(acc)(arr => PSVec.unsafeWrap[B](arr)) + /** Wrap a leftover + focus vector into the `MultiFocus[F]` carrier. Identity at runtime (the + * opaque type erases to the pair); needed only so construction sites outside this file satisfy + * the `MultiFocus[F][X, A]` type. + */ + transparent inline def wrap[F[_], X, A](x: X, fa: F[A]): MultiFocusK[F, X, A] = (x, fa) - def foldLeft[A, B](fa: PSVec[A], b: B)(f: (B, A) => B): B = - pSVecFoldable.foldLeft(fa, b)(f) + extension [F[_], X, A](self: MultiFocusK[F, X, A]) + /** The structural leftover. Identity at runtime. */ + transparent inline def context: X = self._1 - def foldRight[A, B](fa: PSVec[A], lb: Eval[B])( - f: (A, Eval[B]) => Eval[B] - ): Eval[B] = pSVecFoldable.foldRight(fa, lb)(f) + /** The focus vector. Identity at runtime. */ + transparent inline def foci: F[A] = self._2 // Capability instances — Functor / Foldable / Traverse over the F[A] half. + // NOTE: member signatures below are spelled `MultiFocus[F][X, A]`, not `(X, F[A])`. The two are + // equal inside this file (the opaque is transparent here), but a `given … with` instance is + // typed by its anonymous class, so the *written* member signatures are what inline extensions + // (`Optic.modify` / `.modifyA` / `.foldMap`, …) splice against at call sites — where only the + // opaque spelling typechecks. given mfFunctor[F[_]: Functor]: ForgetfulFunctor[MultiFocus[F]] with - def map[X, A, B](xa: (X, F[A]), f: A => B): (X, F[B]) = + def map[X, A, B](xa: MultiFocus[F][X, A], f: A => B): MultiFocus[F][X, B] = (xa._1, Functor[F].map(xa._2)(f)) given mfFold[F[_]: Foldable]: ForgetfulFold[MultiFocus[F]] with - def foldMap[X, A, M: Monoid](f: A => M, xa: (X, F[A])): M = + def foldMap[X, A, M: Monoid](f: A => M, xa: MultiFocus[F][X, A]): M = Foldable[F].foldMap(xa._2)(f) given mfTraverse[F[_]: Traverse]: ForgetfulTraverse[MultiFocus[F], Applicative] with - def traverse[X, A, B, G[_]: Applicative](xa: (X, F[A]), f: A => G[B]): G[(X, F[B])] = + def traverse[X, A, B, G[_]: Applicative]( + xa: MultiFocus[F][X, A], + f: A => G[B], + ): G[MultiFocus[F][X, B]] = Applicative[G].map(Traverse[F].traverse(xa._2)(f))(fb => (xa._1, fb)) // Same-carrier composition — F-parametric, with `MultiFocusSingleton` fast-path on the inner. @@ -221,7 +180,7 @@ object MultiFocus: s: S, outer: Optic[S, T, A, B, MultiFocus[F]] { type X = Xo }, inner: Optic[A, B, C, D, MultiFocus[F]] { type X = Xi }, - ): ((Xo, F[(Xi, Int)]), F[C]) = + ): MultiFocus[F][Z, C] = val Tr = Traverse[F] val FL = summon[MultiFocusFromList[F]] val (xo, fa) = outer.to(s) @@ -250,7 +209,7 @@ object MultiFocus: ((xo, fxiSize), fc) def composeFrom[S, T, A, B, C, D]( - xd: ((Xo, F[(Xi, Int)]), F[D]), + xd: MultiFocus[F][Z, D], inner: Optic[A, B, C, D, MultiFocus[F]] { type X = Xi }, outer: Optic[S, T, A, B, MultiFocus[F]] { type X = Xo }, ): T = @@ -303,7 +262,7 @@ object MultiFocus: s: S, outer: Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] { type X = Xo }, inner: Optic[A, B, C, D, MultiFocus[Function1[X0, *]]] { type X = Xi }, - ): ((Xo, Xi), X0 => C) = + ): MultiFocus[Function1[X0, *]][Z, C] = val (_, kO) = outer.to(s) // Null sentinel works because `.modify` doesn't observe the focus value (spike Q1). val a: A = kO(null.asInstanceOf[X0]) @@ -311,7 +270,7 @@ object MultiFocus: ((null.asInstanceOf[Xo], null.asInstanceOf[Xi]), kI) def composeFrom[S, T, A, B, C, D]( - xd: ((Xo, Xi), X0 => D), + xd: MultiFocus[Function1[X0, *]][Z, D], inner: Optic[A, B, C, D, MultiFocus[Function1[X0, *]]] { type X = Xi }, outer: Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] { type X = Xo }, ): T = @@ -332,7 +291,7 @@ object MultiFocus: s: S, outer: Optic[S, T, A, B, MultiFocus[PSVec]] { type X = Xo }, inner: Optic[A, B, C, D, MultiFocus[PSVec]] { type X = Xi }, - ): (Z, PSVec[C]) = + ): MultiFocus[PSVec][Z, C] = val (xo, va) = outer.to(s) val n = va.length val ysBuf = new ObjArrBuilder(n) @@ -393,7 +352,7 @@ object MultiFocus: (sndZ, flatBuf.freezeAsPSVec[C]) def composeFrom[S, T, A, B, C, D]( - xd: (Z, PSVec[D]), + xd: MultiFocus[PSVec][Z, D], inner: Optic[A, B, C, D, MultiFocus[PSVec]] { type X = Xi }, outer: Optic[S, T, A, B, MultiFocus[PSVec]] { type X = Xo }, ): T = @@ -535,8 +494,8 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, Forget[F]]): Optic[S, T, A, B, MultiFocus[F]] = new Optic[S, T, A, B, MultiFocus[F]]: type X = Unit - def to(s: S): (Unit, F[A]) = ((), o.to(s)) - def from(pair: (Unit, F[B])): T = o.from(pair._2) + def to(s: S): (Unit, F[A]) = ((), o.to(s).value) + def from(pair: (Unit, F[B])): T = o.from(ForgetK(pair._2)) /** MultiFocus[F] ↪ Forget[F] — read-only escape: discard the structural leftover, keep the * focused `F[A]`. Closes the top-5 plan's gap #2 by giving users an explicit carrier morph @@ -562,8 +521,8 @@ object MultiFocus: def to[S, T, A, B](o: Optic[S, T, A, B, MultiFocus[F]]): Optic[S, T, A, B, Forget[F]] = new Optic[S, T, A, B, Forget[F]]: type X = Unit - def to(s: S): F[A] = o.to(s)._2 - def from(fb: F[B]): T = + def to(s: S): ForgetK[F, X, A] = ForgetK(o.to(s)._2) + def from(fb: ForgetK[F, X, B]): T = // Reachable only when `T = Unit` (Forget-carrier optics have T = Unit by construction). // The cast surfaces a ClassCastException if a user's MultiFocus optic has T ≠ Unit AND // they explicitly routed through this Composer — defensive only. @@ -856,3 +815,10 @@ object MultiFocus: opt.from(m.widenB[F[B]]) case (h: Affine.Hit[opt.X, Unit] @unchecked, fb) => opt.from(new Affine.Hit[opt.X, F[B]](h.snd, fb)) + +/** API façade under the carrier's public name. The instances live in [[MultiFocusK]] (the opaque + * anchor's companion, where implicit scope finds them); this re-export keeps `MultiFocus.apply` / + * `MultiFocus.fromLensF` call-shapes and legacy `import data.MultiFocus.given` working. + */ +object MultiFocus: + export MultiFocusK.{given, *} diff --git a/core/src/main/scala/dev/constructive/eo/data/PSVec.scala b/core/src/main/scala/dev/constructive/eo/data/PSVec.scala index 54b6cc65..259c26a8 100644 --- a/core/src/main/scala/dev/constructive/eo/data/PSVec.scala +++ b/core/src/main/scala/dev/constructive/eo/data/PSVec.scala @@ -1,6 +1,8 @@ package dev.constructive.eo package data +import cats.{Applicative, Eval, Foldable, Functor, Traverse} + /** Lightweight array-backed focus vector underlying `MultiFocus[PSVec]`. Three variants so empty * and singleton focus vectors don't pay a backing-array allocation: * @@ -93,6 +95,72 @@ sealed trait PSVec[+B]: */ object PSVec: + // Cats instances — live here (the PSVec companion) because implicit scope for + // `Functor[PSVec]` / `Foldable[PSVec]` / `Traverse[PSVec]` anchors on PSVec's own + // companion, NOT on MultiFocusK's (where these were once parked behind + // `import data.MultiFocus.given`). + + given pSVecFunctor: Functor[PSVec] with + + def map[A, B](fa: PSVec[A])(f: A => B): PSVec[B] = + val n = fa.length + if n == 0 then PSVec.empty[B] + else + val arr = new Array[AnyRef](n) + var i = 0 + while i < n do + arr(i) = f(fa(i)).asInstanceOf[AnyRef] + i += 1 + PSVec.unsafeWrap[B](arr) + + given pSVecFoldable: Foldable[PSVec] with + + def foldLeft[A, B](fa: PSVec[A], b: B)(f: (B, A) => B): B = + var acc = b + val n = fa.length + var i = 0 + while i < n do + acc = f(acc, fa(i)) + i += 1 + acc + + def foldRight[A, B](fa: PSVec[A], lb: Eval[B])( + f: (A, Eval[B]) => Eval[B] + ): Eval[B] = + val n = fa.length + def loop(i: Int): Eval[B] = + if i >= n then lb + else f(fa(i), Eval.defer(loop(i + 1))) + Eval.defer(loop(0)) + + override def size[A](fa: PSVec[A]): Long = fa.length.toLong + + given pSVecTraverse: Traverse[PSVec] with + + def traverse[G[_]: Applicative, A, B](fa: PSVec[A])(f: A => G[B]): G[PSVec[B]] = + val G = Applicative[G] + val n = fa.length + if n == 0 then G.pure(PSVec.empty[B]) + else + var acc: G[Array[AnyRef]] = G.pure(new Array[AnyRef](n)) + var i = 0 + while i < n do + val idx = i + val gb = f(fa(idx)) + acc = G.map2(acc, gb) { (a, b) => + a(idx) = b.asInstanceOf[AnyRef] + a + } + i += 1 + G.map(acc)(arr => PSVec.unsafeWrap[B](arr)) + + def foldLeft[A, B](fa: PSVec[A], b: B)(f: (B, A) => B): B = + pSVecFoldable.foldLeft(fa, b)(f) + + def foldRight[A, B](fa: PSVec[A], lb: Eval[B])( + f: (A, Eval[B]) => Eval[B] + ): Eval[B] = pSVecFoldable.foldRight(fa, lb)(f) + /** Zero-element shared singleton; Prism / Affine miss branches allocate nothing. */ case object Empty extends PSVec[Nothing]: def length: Int = 0 diff --git a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 25a0e664..36236fb3 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala @@ -18,10 +18,10 @@ type AffineFold[S, A] = Optic[S, Unit, A, Unit, Affine] /** Concrete Optic subclass for [[AffineFold]] — a `final class` storing `pick` directly, NOT a * shared anonymous wrapper: a single anon class would host one `pick.apply` bytecode site for * every AffineFold instance, the megamorphic-dispatch trap PrintInlining exposed on the - * abstract-class [[DirectGetter]] (the JIT profiles that one site over all instances' lambdas and - * gives up inlining). Same storage shape as [[PickMendPrism]] (`pick`) and [[ForgetFold]] - * (`read`). Returned by [[AffineFold.apply]] / [[AffineFold.select]] so hand-written affine folds - * pick up the fused members automatically. + * abstract-class [[Getter]] (the JIT profiles that one site over all instances' lambdas and gives + * up inlining). Same storage shape as [[PickMendPrism]] (`pick`) and [[ForgetFold]] (`read`). + * Returned by [[AffineFold.apply]] / [[AffineFold.select]] so hand-written affine folds pick up + * the fused members automatically. */ final class PickFold[S, A](val pick: S => Option[A]) extends Optic[S, Unit, A, Unit, Affine]: type X = (Unit, Unit) @@ -40,7 +40,7 @@ final class PickFold[S, A](val pick: S => Option[A]) extends Optic[S, Unit, A, U /** Fused `AffineFold.andThen(AffineFold)` — `flatMap`s the picks; no `Affine` wrappers, no * `Morph`. `inline` so each compose site splices its own lambda (per-site monomorphic dispatch — - * see [[DirectGetter.andThen]]). + * see [[Getter.andThen]]). */ inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = new PickFold(s => pick(s).flatMap(inner.pick)) @@ -48,9 +48,29 @@ final class PickFold[S, A](val pick: S => Option[A]) extends Optic[S, Unit, A, U /** Fused `AffineFold.andThen(Getter)` — `map`s the read over the pick; the concrete fast path of * the generic `andThenAffineFold` collapse. */ - inline def andThen[C](inner: DirectGetter[A, C]): PickFold[S, C] = + inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = new PickFold(s => pick(s).map(inner.get)) + /** Read-only outer ∘ ANY inner — a `PickFold` only reads (partially), so the inner's write side + * is irrelevant and the composite is the read-only join via [[ReadCompose]] (`PickFold` unless + * the inner has many foci, then `ForgetFold`). Covers the writable inners (`affineFold ∘ lens / + * prism / optional / traversal`); the fused `andThen(Getter)` / `andThen(PickFold)` members + * above stay as the more-specific fast paths. + */ + @annotation.targetName("andThenReadAny") + def andThen[C, IT, IB, FI[_, _]](inner: Optic[A, IT, C, IB, FI])(using + rc: ReadCompose[Affine, FI] + ): rc.Out[S, C] = + rc.compose(this, inner) + + /** Override of the trait's read-only-inner overload, re-homed here so it shares an owner with + * `andThenReadAny` above — see [[Getter]]'s twin override for the ambiguity rationale. + */ + override def andThen[C, IB, G[_, _]](inner: Optic[A, Unit, C, IB, G])(using + rc: ReadCompose[Affine, G] + ): rc.Out[S, C] = + rc.compose(this, inner) + /** Constructors for [[AffineFold]]. */ object AffineFold: diff --git a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 784bb38f..bea56c93 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -3,7 +3,7 @@ package optics import cats.instances.option.given import cats.{Foldable, Monoid} -import dev.constructive.eo.data.Forget +import dev.constructive.eo.data.{Forget, ForgetK} /** Constructors for `Fold` — read-only multi-focus optic, backed by `Forget[F]` (`Forget[F][X, A] = * F[A]`). `T = Unit` rules out the write path; `.foldMap` is the consumption surface. @@ -49,8 +49,8 @@ final class ForgetFold[S, F[_], A]( )(using FF: Foldable[F]) extends Optic[S, Unit, A, Unit, Forget[F]]: type X = Nothing - def to(s: S): F[A] = read(s) - def from(u: F[Unit]): Unit = () + def to(s: S): Forget[F][X, A] = ForgetK(read(s)) + def from(u: Forget[F][X, Unit]): Unit = () /** Eager `foldMap` — folds `s` straight through the captured `Foldable[F]`, returning `M` with no * intermediate `S => M` closure and no `ForgetfulFold` summon. Wins over the [[Optic.foldMap]] @@ -58,4 +58,32 @@ final class ForgetFold[S, F[_], A]( * shape (`fold.foldMap(f)(s)`) is identical, so this is a transparent drop-in. */ def foldMap[M](f: A => M)(s: S)(using Monoid[M]): M = - FF.foldMap(to(s))(f) + FF.foldMap(read(s))(f) + + /** Fused `Fold.andThen(Fold)` — same-carrier flatMap (`read(s).flatMap(inner.read)`); the + * read-only-triple join cell for the *many* rung. Being the most specific overload, it resolves + * the otherwise-ambiguous `fold ∘ fold` (a read-only outer where the structure-preserving and + * read-collapse trait members both match a read-only inner). Requires `FlatMap[F]`, exactly as + * the generic `assocForgetMonad` path did. + */ + def andThen[C](inner: ForgetFold[A, F, C])(using FM: cats.FlatMap[F]): ForgetFold[S, F, C] = + new ForgetFold[S, F, C](s => FM.flatMap(read(s))(inner.read)) + + /** Read-only outer ∘ ANY inner — a `Fold` only reads (many), so the inner's write side is + * irrelevant and the composite is always a `ForgetFold` (many ∘ anything = many) via + * [[ReadCompose]]. Covers the writable inners (`fold ∘ lens / prism / optional / traversal`); + * the fused same-`F` `andThen` above stays as the more-specific fast path. + */ + @annotation.targetName("andThenReadAny") + def andThen[C, IT, IB, FI[_, _]](inner: Optic[A, IT, C, IB, FI])(using + rc: ReadCompose[Forget[F], FI] + ): rc.Out[S, C] = + rc.compose(this, inner) + + /** Override of the trait's read-only-inner overload, re-homed here so it shares an owner with + * `andThenReadAny` above — see [[Getter]]'s twin override for the ambiguity rationale. + */ + override def andThen[C, IB, G[_, _]](inner: Optic[A, Unit, C, IB, G])(using + rc: ReadCompose[Forget[F], G] + ): rc.Out[S, C] = + rc.compose(this, inner) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala index aa005b8b..c8c43523 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala @@ -11,7 +11,7 @@ import data.Direct * the same shape as [[BijectionIso]] / [[GetReplaceLens]]. Returned by [[Getter.apply]] so * hand-written getters pick up the fused path automatically. */ -final class DirectGetter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, Direct]: +final class Getter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, Direct]: type X = Nothing def to(s: S): Direct[X, A] = Direct(get(s)) @@ -27,17 +27,47 @@ final class DirectGetter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, * the deep tail as virtual `Function1.apply`. Distinct per-level methods sidestep that cap with * no JVM flag, the same way Monocle's per-compose anonymous classes do. */ - inline def andThen[B](inner: DirectGetter[A, B]): DirectGetter[S, B] = - new DirectGetter(s => inner.get(get(s))) + inline def andThen[B](inner: Getter[A, B]): Getter[S, B] = + new Getter(s => inner.get(get(s))) + + /** Fused `Getter.andThen(AffineFold)` — total read then partial read = partial. The concrete + * read-only-triple join cell; being the most specific overload, it resolves the otherwise + * ambiguous `getter ∘ affinefold` (broad-member vs inherited trait-member) outright. + */ + inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = + new PickFold(s => inner.pick(get(s))) + + /** Read-only outer ∘ ANY inner — a `Getter` only reads, so the inner's write side is irrelevant + * and the composite is the read-only join via [[ReadCompose]] (`Getter` when the inner reads + * totally, `PickFold` when partially, `ForgetFold` when it has many foci). This member covers + * the writable inners (`getter ∘ lens / prism / optional / traversal`) that the trait's + * read-only-inner overload cannot reach; the fused `andThen(Getter)` / `andThen(PickFold)` + * members above stay as the more-specific fast paths. + */ + @annotation.targetName("andThenReadAny") + def andThen[C, IT, IB, FI[_, _]](inner: Optic[A, IT, C, IB, FI])(using + rc: ReadCompose[Direct, FI] + ): rc.Out[S, C] = + rc.compose(this, inner) + + /** Override of the trait's read-only-inner overload, re-homed in this class so it shares an owner + * with `andThenReadAny` above — otherwise a read-only inner is ambiguous between the two (the + * trait one wins on signature specificity, the class one on owner derivation, and dotty calls + * the split a tie). Same `ReadCompose` route either way. + */ + override def andThen[C, IB, G[_, _]](inner: Optic[A, Unit, C, IB, G])(using + rc: ReadCompose[Direct, G] + ): rc.Out[S, C] = + rc.compose(this, inner) /** Constructor for `Getter` — read-only single-focus optic, backed by `Direct` with `T = B = Unit`. * `.get(s)` is the only meaningful operation; the write path is vestigial. * * Both the leftover `T` *and* the back-focus `B` are `Unit`, which makes the read-only-ness * explicit in the type (there is no `B` to put back). [[Getter.apply]] returns a concrete - * [[DirectGetter]], so a Getter composes with another Getter through the ordinary `andThen` (the - * fused `DirectGetter.andThen`) — `g1.andThen(g2)` reads `s => g2.get(g1.get(s))` — exactly as - * `Iso` / `Lens` compose via their own fused subclasses. + * [[Getter]], so a Getter composes with another Getter through the ordinary `andThen` (the fused + * `Getter.andThen`) — `g1.andThen(g2)` reads `s => g2.get(g1.get(s))` — exactly as `Iso` / `Lens` + * compose via their own fused subclasses. */ object Getter: @@ -51,5 +81,5 @@ object Getter: * val nameLength = Getter[Person, Int](_.name.length) * }}} */ - def apply[S, A](get: S => A): DirectGetter[S, A] = - new DirectGetter(get) + def apply[S, A](get: S => A): Getter[S, A] = + new Getter(get) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala index 38cabda8..f49c5c86 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala @@ -48,7 +48,7 @@ final class BijectionIso[S, T, A, B]( /** Fused `Iso.andThen(Iso)` — composes `get`s and `reverseGet`s directly. `inline` so each * compose site splices distinct lambdas, keeping a deep `iso.andThen(iso)…` chain under C2's - * recursive-inline cap (see [[DirectGetter.andThen]] / [[GetReplaceLens.andThen]]). + * recursive-inline cap (see [[Getter.andThen]] / [[GetReplaceLens.andThen]]). */ inline def andThen[C, D](inner: BijectionIso[A, B, C, D]): BijectionIso[S, T, C, D] = new BijectionIso( @@ -73,6 +73,14 @@ final class BijectionIso[S, T, A, B]( mend = d => reverseGet(inner.mend(d)), ) + /** Fused `Iso.andThen(Getter)` — read collapse on the hot path: composes the `get`s directly into + * a concrete [[Getter]], skipping the `ReadCompose` dispatch the trait's read-only-inner + * overload performs. `inline` so each compose site splices a distinct lambda (see + * [[Getter.andThen]]). + */ + inline def andThen[C](inner: Getter[A, C]): Getter[S, C] = + Getter(s => inner.get(get(s))) + /** Fused `Iso.andThen(Optional)` — iso is transparent; result is `Optional`. */ def andThen[C, D](inner: Optional[A, B, C, D]): Optional[S, T, C, D] = new Optional( diff --git a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala index 20e55fbd..d4934145 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala @@ -136,6 +136,14 @@ class GetReplaceLens[S, T, A, B]( reverseGet = (s, d) => enplace(s, ev(inner.mend(d))), ) + /** Fused `Lens.andThen(Getter)` — read collapse on the hot path: composes the `get`s directly + * into a concrete [[Getter]], skipping the `ReadCompose` dispatch the trait's read-only-inner + * overload performs. `inline` so each compose site splices a distinct lambda (see + * [[Getter.andThen]]). + */ + inline def andThen[C](inner: Getter[A, C]): Getter[S, C] = + Getter(s => inner.get(get(s))) + /** Fused `Lens.andThen(Optional)` — outer always hits, inner may miss. Result is `Optional`. */ def andThen[C, D](inner: Optional[A, B, C, D]): Optional[S, T, C, D] = new Optional( diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index 322fb58b..811edaff 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -61,15 +61,34 @@ trait Optic[S, T, A, B, F[_, _]]: * val streetLens = lens[Person](_.address).andThen(lens[Address](_.street)) * }}} */ - def andThen[C, D](o: Optic[A, B, C, D, F])(using - af: AssociativeFunctor[F, self.X, o.X] - ): Optic[S, T, C, D, F] = - val outerRef = self.asInstanceOf[Optic[S, T, A, B, F] { type X = self.X }] - val innerRef = o.asInstanceOf[Optic[A, B, C, D, F] { type X = o.X }] + @annotation.nowarn("id=E197") + inline def andThen[C, D](o: Optic[A, B, C, D, F]): Optic[S, T, C, D, F] = + val af = summonInline[AssociativeFunctor[F, self.X, o.X]] new Optic: type X = af.Z - def to(s: S): F[X, C] = af.composeTo(s, outerRef, innerRef) - def from(xd: F[X, D]): T = af.composeFrom(xd, innerRef, outerRef) + def to(s: S): F[X, C] = af.composeTo(s, self, o) + def from(xd: F[X, D]): T = af.composeFrom(xd, o, self) + + /** ANY outer ∘ read-only inner — the inner is honestly one-way (`T = Unit`: a Getter, AffineFold, + * or Fold), so only the two READ sides matter and the composite collapses to the read-only join + * of their strengths via [[ReadCompose]] (`Getter` / `PickFold` / `ForgetFold`). + * + * A trait *member* (not an extension in the companion) deliberately: once a receiver is + * statically one of the fused concrete classes, its `andThen` member overloads enter resolution + * and Scala 3 never falls back to extension methods when they all fail — the collapse must be in + * the member overload set to be reachable without an expected-type ascription. + * + * Only the inner's `T` is pinned to `Unit`; its `B` stays free (`IB`) even though read-only + * inners always have `B = Unit`. That keeps this overload strictly LESS specific than the + * same-carrier `andThen` above (which accepts every argument this one does whenever `B = Unit` + * at the receiver), so a same-carrier read-only ∘ read-only call resolves unambiguously to the + * `AssociativeFunctor` path and this one fires exactly on the cross-seam cells the generic + * member cannot type. + */ + def andThen[C, IB, G[_, _]](inner: Optic[A, Unit, C, IB, G])(using + rc: ReadCompose[F, G] + ): rc.Out[S, C] = + rc.compose(self, inner) /** Build-then-observe across the *build-output ⇄ read-input* seam, **preserving structure**, on a * **shared carrier** `F`. Flip `self` (it must be reversible — `Accessor[F]` *and* @@ -87,12 +106,10 @@ trait Optic[S, T, A, B, F[_, _]]: * * @group Operations */ - inline def cross[C, D](o: Optic[T, S, C, D, F])(using - A: Accessor[F], - RA: ReverseAccessor[F], - ): Optic[B, A, C, D, F] = - val r = self.reverse - r.andThen(o)(using summonInline[AssociativeFunctor[F, r.X, o.X]]) + inline def cross[C, D]( + o: Optic[T, S, C, D, F] + )(using Accessor[F], ReverseAccessor[F]): Optic[B, A, C, D, F] = + self.reverse.andThen(o) /** Companion for [[Optic]]. Hosts the profunctor instances and the capability-gated extension * catalogue — `.get`, `.modify`, `.replace`, `.foldMap`, `.modifyA`, `.all`, `.reverseGet`, @@ -103,6 +120,15 @@ trait Optic[S, T, A, B, F[_, _]]: */ object Optic: + /** The identity optic — `S` is its own focus and modification has no effect. Carrier is + * [[data.Direct]] (no leftover). Returns the concrete [[BijectionIso]] (not an anonymous + * `Optic`) so `id.andThen(x)` picks up the fused compose members like any other Iso. + * + * @group Constructors + */ + def id[A]: BijectionIso[A, A, A, A] = + Iso[A, A, A, A](identity, identity) + /** Profunctor over `(S, T)` — `dimap` on the outer parameter pair, letting callers pre-compose * the source side and post-compose the result side of a fixed-focus optic. * @@ -135,15 +161,6 @@ object Optic: def to(s: S): F[X, C] = F.map(o.to(s), g) def from(xd: F[X, D]): T = o.from(F.map(xd, f)) - /** The identity optic — `S` is its own focus and modification has no effect. Carrier is - * [[data.Direct]] (no leftover). Returns the concrete [[BijectionIso]] (not an anonymous - * `Optic`) so `id.andThen(x)` picks up the fused compose members like any other Iso. - * - * @group Constructors - */ - def id[A]: BijectionIso[A, A, A, A] = - Iso[A, A, A, A](identity, identity) - /** Cross-carrier `.andThen` — picks the direction via a summoned [[Morph]] when the two optics' * carriers differ. With one exception (`forget2multifocus` / `multifocus2forget`), cats-eo ships * no bidirectional Composer pairs, so at most one Morph applies per carrier pair. That one pair @@ -163,114 +180,7 @@ object Optic: inline def andThen[G[_, _], C, D](o: Optic[A, B, C, D, G])(using m: Morph[F, G] ): Optic[S, T, C, D, m.Out] = - val morphedSelf = m.morphSelf(self) - val morphedO = m.morphO(o) - morphedSelf.andThen(morphedO)(using - summonInline[AssociativeFunctor[m.Out, morphedSelf.X, morphedO.X]] - ) - - /** Compose with a read-only [[Getter]] → the **read-only collapse** of this optic, for a - * **total reader** (`Accessor[F]` — `Iso`/`Getter` on `Direct`, `Lens` on `Tuple2`). A - * `Getter`'s back-focus is `Unit`, so it can never thread through a writable `B` as a writable - * optic; the only sound result is read-only, and that is exactly what's wanted ("focus `A`, - * then read `C`"): read `self` through the accessor, then `o.get` — a [[DirectGetter]]. - * Partial readers take the `andThenAffineFold` overload below; together they give - * `lens.andThen(getter) = Getter`, `optional/prism.andThen(getter) = AffineFold` — the - * read-side dual of the `andThen(Setter)` write-side collapse. Routed through the carrier's - * capability, not per-class fused overloads. - * - * @group Operations - */ - @annotation.targetName("andThenReadOnly") - inline def andThen[C](o: DirectGetter[A, C])(using af: Accessor[F]): DirectGetter[S, C] = - Getter[S, C](s => o.get(af.get(self.to(s)))) - - /** Compose with a read-only [[Getter]] → the **read-only collapse** of this optic, for a - * **partial reader** (no `Accessor[F]` — `Optional`/[[AffineFold]] on `Affine`, `Prism` on - * `Either`). Takes the [[readOnly]] projection (write side forgotten, so the `Unit` seam lines - * up against the getter's `T = Unit`) and composes through `Morph[F, Direct]` pinned to the - * `Affine` carrier (`rightToLeft` for `Affine` itself, `bothViaAffine` for `Either`) — an - * [[AffineFold]]. The `NotGiven[Accessor[F]]` guard keeps total readers on the - * `andThenReadOnly` overload above, unambiguously. - * - * @group Operations - */ - @annotation.targetName("andThenAffineFold") - inline def andThen[C](o: DirectGetter[A, C])(using - ng: scala.util.NotGiven[Accessor[F]], - m: Morph[F, Direct] { type Out[X, Y] = Affine[X, Y] }, - ): AffineFold[S, C] = - val ms = m.morphSelf(self.readOnly) - val mo = m.morphO(o) - ms.andThen(mo)(using summonInline[AssociativeFunctor[Affine, ms.X, mo.X]]) - - /** Compose with a write-only [[Setter]] → a concrete [[SetterOptic]] (the **write-side dual** - * of `andThen(Getter)`). A `Setter`'s read side is trivial, so the composite is write-only: it - * modifies `this` optic's focus through the inner setter. Routed through `Morph[F, SetterF]` - * (every writable carrier ships a `Composer[·, SetterF]`), then re-wrapped as a `SetterOptic` - * so the concrete fused `.modify` surface is preserved. So `lens.andThen(setter)` / - * `optional.andThen(setter)` / `prism.andThen(setter)` are all `Setter`s. - * - * @group Operations - */ - @annotation.targetName("andThenSetter") - inline def andThen[C, D](o: SetterOptic[A, B, C, D])(using - m: Morph[F, SetterF] { type Out[X, Y] = SetterF[X, Y] } - ): SetterOptic[S, T, C, D] = - val ms = m.morphSelf(self) - val mo = m.morphO(o) - val composed = ms.andThen(mo)(using summonInline[AssociativeFunctor[SetterF, ms.X, mo.X]]) - Setter[S, T, C, D](f => composed.modify(f)) - - /** Compose with a build-only [[Review]] → a [[Review]] (the build-direction collapse). A - * `Review`'s source/read is trivial (`Unit`), so the composite is build-only: build the inner - * focus, then rebuild `T` through `self`. Requires `this` to be reversible - * (`ReverseAccessor[F]`). - * - * @group Operations - */ - @annotation.targetName("andThenReview") - def andThen[C](o: Optic[Unit, B, Unit, C, F])(using RA: ReverseAccessor[F]): Review[T, C] = - Review[T, C](c => self.from(RA.reverseGet(o.from(RA.reverseGet(c))))) - - /** The **read-only projection** of this optic — same `to`, write side forgotten (`T = B = - * Unit`). Sound for every carrier (`from` ignores its input); this is the seam adapter the - * partial-reader `andThen(Getter)` collapse composes through. - * - * `inline` so each call site splices its own anonymous class (E197 is the point, not a smell): - * a single shared wrapper class would host one `self.to` dispatch site profiled over every - * projected optic — the megamorphic trap PrintInlining exposed on the abstract-class - * `DirectGetter`. Per-site splicing keeps each projection's `to` monomorphic. - * - * @group Operations - */ - @annotation.nowarn("id=E197") - inline def readOnly: Optic[S, Unit, A, Unit, F] = - new Optic[S, Unit, A, Unit, F]: - type X = self.X - def to(s: S): F[X, A] = self.to(s) - def from(b: F[X, Unit]): Unit = () - - /** Cross-carrier [[cross]] (mirrors `andThen` vs `andThenMorphed`) — when `that` sits on a - * *different* carrier `G`, a `Morph[F, G]` bridges the two before composing, so the result's - * read capability follows the *composed* carrier: `.getOption` through a Prism, `.foldMap` - * through a `Fold` (the latter via the `Composer[Direct, Forget]` bridge — read-many falls out - * of this overload). Same seam and structure-preservation as the same-carrier `cross`. - * Reversibility still excludes `Prism`-as-`self` (`Either` has no `Accessor`); build from a - * Prism by wrapping its `mend` in a `Review` first. - * - * @group Operations - */ - @annotation.targetName("crossMorphed") - inline def cross[G[_, _], C, D](o: Optic[T, S, C, D, G])(using - A: Accessor[F], - RA: ReverseAccessor[F], - m: Morph[F, G], - ): Optic[B, A, C, D, m.Out] = - val r = self.reverse - val ms = m.morphSelf(r) - val mo = m.morphO(o) - ms.andThen(mo)(using summonInline[AssociativeFunctor[m.Out, ms.X, mo.X]]) + m.morphSelf(self).andThen(m.morphO(o)) /** Re-express this optic over a different carrier `G`. Package-private; users compose * cross-carrier via [[andThen]] above. Reachable for law / behaviour specs inside `eo.*`. @@ -278,17 +188,24 @@ object Optic: def morph[G[_, _]](using cf: Composer[F, G]): Optic[S, T, A, B, G] = cf.to(self) - // Capability-gated extensions follow. The `(using …)` clause names the typeclass each extension - // requires; carriers without that instance simply don't see the method. + extension [S, T, A, B, F[_, _]]( + self: Optic[S, T, A, B, F] + )(using accessor: Accessor[F]) + inline def get(s: S): A = accessor.get(self.to(s)) + + inline def andThen[C](o: Getter[A, C]): Getter[S, C] = + Getter(s => o.get(get(s))) + + inline def readOnly: Getter[S, A] = + Getter(get) - /** Read the focus out of `s`. Available when the carrier has an `Accessor[F]` instance (today: - * `Tuple2` and `Direct`). - * - * @group Operations - */ extension [S, T, A, B, F[_, _]]( - o: Optic[S, T, A, B, F] - )(using A: Accessor[F]) inline def get(s: S): A = A.get(o.to(s)) + self: Optic[S, T, A, B, F] + )(using A: PartialAccessor[F]) + inline def getOption(s: S): Option[A] = A.getOption(self.to(s)) + + inline def andThen[C](o: Getter[A, C]): PickFold[S, C] = + PickFold(s => getOption(s).map(o.get)) /** Build the "no context" reverse — takes a fresh `B` and produces the corresponding `T`. * Available when the carrier has a `ReverseAccessor[F]` instance (today: `Either` and `Direct`). @@ -296,8 +213,15 @@ object Optic: * @group Operations */ extension [S, T, A, B, F[_, _]]( - o: Optic[S, T, A, B, F] - )(using RA: ReverseAccessor[F]) inline def reverseGet(b: B): T = o.from(RA.reverseGet(b)) + self: Optic[S, T, A, B, F] + )(using ra: ReverseAccessor[F]) + inline def reverseGet(b: B): T = self.from(ra.reverseGet(b)) + + inline def andThen[D](o: Review[B, D]): Review[T, D] = + Review(d => reverseGet(o.reverseGet(d))) + + inline def writeOnly: Review[T, B] = + Review(reverseGet) /** Flip the direction of an optic — only defined when the carrier admits both `Accessor[F]` and * `ReverseAccessor[F]` (i.e. iso-shaped carriers). @@ -305,16 +229,20 @@ object Optic: * @group Operations */ extension [S, T, A, B, F[_, _]]( - o: Optic[S, T, A, B, F] + self: Optic[S, T, A, B, F] )(using A: Accessor[F], RA: ReverseAccessor[F]) - // Not `inline`: the body builds a fresh anonymous `Optic`; `inline` would duplicate that - // class definition per call site (E197) without eliminating the allocation. def reverse: Optic[B, A, T, S, F] = new Optic[B, A, T, S, F]: - type X = o.X - def to(b: B): F[X, T] = RA.reverseGet(o.from(RA.reverseGet(b))) - def from(fs: F[X, S]): A = A.get(o.to(A.get(fs))) + type X = self.X + def to(b: B): F[X, T] = RA.reverseGet(self.from(RA.reverseGet(b))) + def from(fs: F[X, S]): A = A.get(self.to(A.get(fs))) + + @annotation.targetName("crossMorphed") + inline def cross[G[_, _], C, D](o: Optic[T, S, C, D, G])(using + m: Morph[F, G] + ): Optic[B, A, C, D, m.Out] = + m.morphSelf(reverse).andThen(m.morphO(o)) /** Modify (`A => B`) or replace (by constant `B`) the focus in-place. Available for every carrier * with a `ForgetfulFunctor[F]` instance — i.e. every current optic family. @@ -322,14 +250,17 @@ object Optic: * @group Operations */ extension [S, T, A, B, F[_, _]]( - o: Optic[S, T, A, B, F] + self: Optic[S, T, A, B, F] )(using FF: ForgetfulFunctor[F]) inline def modify(f: A => B): S => T = - s => o.from(FF.map(o.to(s), f)) + s => self.from(FF.map(self.to(s), f)) inline def replace(b: B): S => T = - s => o.from(FF.map(o.to(s), _ => b)) + s => self.from(FF.map(self.to(s), _ => b)) + + inline def andThen[C, D](o: Setter[A, B, C, D]): Setter[S, T, C, D] = + Setter(f => modify(o.modify(f))) /** Overwrite a `T`-shaped value at the focus — available when the carrier can witness `T => F[X, * B]` (e.g. `Direct`, where `F[X, B] = B`). [[transfer]] lifts a `C => B` into this same shape @@ -446,31 +377,3 @@ object Optic: */ def exists(p: A => Boolean)(s: S): Boolean = o.foldMap[Boolean](p)(using Monoid.instance[Boolean](false, _ || _))(s) - - /** `getOption` over an `Affine`-carrier optic — the canonical read for [[Optional]] and - * [[AffineFold]]. Pattern-matches the Affine directly; miss produces `None`, hit wraps the focus - * in `Some` (no `Monoid[A]` needed, unlike [[foldMap]]). - * - * @group Operations - */ - extension [S, T, A, B](o: Optic[S, T, A, B, Affine]) - - inline def getOption(s: S): Option[A] = - o.to(s).fold(_ => None, (_, a) => Some(a)) - - /** `getOption` over an `Either`-carrier optic — the canonical read for a [[Prism]]. Mirrors the - * `Affine` overload above so a derived `prism` (which surfaces as the bare - * `Optic[S, T, A, B, Either]`, not the concrete [[Prism]] subclass) reads through the same - * `getOption` call. `Either#toOption` discards the residual `Left` (the miss branch); a hit's - * `Right(a)` becomes `Some(a)`. The concrete `Prism`'s own `getOption` member still wins at its - * static type (member over extension), so this only fills the gap for the erased carrier. - * - * @group Operations - */ - extension [S, T, A, B](o: Optic[S, T, A, B, Either]) - - // `@targetName` disambiguates from the `Affine` overload above: the carrier type param erases, - // so both `getOption`s collide at the JVM level. Call sites still resolve by carrier statically. - @annotation.targetName("getOptionEither") - inline def getOption(s: S): Option[A] = - o.to(s).toOption diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala index c0e21c68..5e8dbe7b 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala @@ -99,7 +99,7 @@ final class Optional[S, T, A, B]( /** Fused `Optional.andThen(Optional)` — two partial focuses compose into another. `inline` so a * deep `optional.andThen(optional)…` chain splices distinct lambdas per level, under C2's - * recursive-inline cap (see [[DirectGetter.andThen]]). + * recursive-inline cap (see [[Getter.andThen]]). */ inline def andThen[C, D](inner: Optional[A, B, C, D]): Optional[S, T, C, D] = fuseToOptional( @@ -110,6 +110,9 @@ final class Optional[S, T, A, B]( innerWrite = (a, d) => inner.reverseGet(a, d), ) + inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = + PickFold(s => getOrModify(s).toOption.map(inner.get)) + /** Fused `Optional.andThen(Lens)` — always-present inner inside a partial outer. Stays partial. */ def andThen[C, D](inner: GetReplaceLens[A, B, C, D]): Optional[S, T, C, D] = diff --git a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala index 870b3f6c..8b2b6092 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala @@ -38,7 +38,7 @@ trait Plated[S]: * instance) override it to return the children with no tuple allocation. Either way nothing is * converted to a `List` and back — that round-trip was the read/write hot-path tax. */ - def childrenVec(s: S): PSVec[S] = plate.to(s)._2 + def childrenVec(s: S): PSVec[S] = plate.to(s).foci /** The immediate children as a **fresh `Array[AnyRef]` the caller may mutate in place**. The * default copies (`childrenVec(s).toAnyRefArray`) so it is safe for any instance; @@ -58,7 +58,7 @@ trait Plated[S]: */ def rebuild(parent: S, children: PSVec[S]): S = val p = plate // bind once so `p.X` is a single stable existential across `to` / `from` - p.from((p.to(parent)._1, children)) + p.from(data.MultiFocusK.wrap(p.to(parent).context, children)) /** Combinators over [[Plated]] — recursion schemes faithful to `Control.Lens.Plated`, all * stack-safe. Each is offered both as a `using Plated[S]` method here and as an extension on any diff --git a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala index 78a7c88f..93cf2d44 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala @@ -127,7 +127,7 @@ final class MendTearPrism[S, T, A, B]( /** Fused `Prism.andThen(Prism)` — composed tear + `mend = outer.mend ∘ inner.mend`, no nested * Either wrappers. Picked when both sides are statically `MendTearPrism`. `inline` so a deep * prism chain splices distinct lambdas per level, under C2's recursive-inline cap (see - * [[DirectGetter.andThen]]). + * [[Getter.andThen]]). */ inline def andThen[C, D](inner: MendTearPrism[A, B, C, D]): MendTearPrism[S, T, C, D] = fuseToMendTear( @@ -178,6 +178,9 @@ final class MendTearPrism[S, T, A, B]( innerWrite = (a, d) => inner.reverseGet(a, d), ) + inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = + PickFold(s => getOption(s).map(inner.get)) + /** Concrete Optic subclass for the `Option`-shaped Prism (`Prism.optional` / `Prism.pOptional`). * Stores `pick` and `mend` directly; the fused extensions pattern-match on `Option` so the hot * path never builds the intermediate `Either[S, A]` the generic `MendTearPrism` would. @@ -215,8 +218,7 @@ final class PickMendPrism[S, A, B]( /** Fused `PickMend.andThen(PickMend)` — outer mono in focus; preserves the Option-fast-path shape * rather than materialising an intermediate Either. `inline` so a deep fast-path prism chain - * splices distinct lambdas per level, under C2's recursive-inline cap (see - * [[DirectGetter.andThen]]). + * splices distinct lambdas per level, under C2's recursive-inline cap (see [[Getter.andThen]]). */ inline def andThen[C, D]( inner: PickMendPrism[A, C, D] @@ -247,3 +249,6 @@ final class PickMendPrism[S, A, B]( pick = s => pick(s).map(inner.get), mend = d => mend(inner.reverseGet(d)), ) + + inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = + PickFold(s => pick(s).map(inner.get)) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Review.scala b/core/src/main/scala/dev/constructive/eo/optics/Review.scala index 11eaca45..ff63c6b8 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Review.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Review.scala @@ -1,10 +1,10 @@ package dev.constructive.eo package optics -import data.Direct +import data.{Direct, ReverseAccessor} /** Reverse-only counterpart to [[Getter]] — wraps `reverseGet: A => S` and is the exact **mirror of - * [[DirectGetter]]**. `Getter` is `Optic[S, Unit, A, Unit, Direct]` (a real `to` reading `S => A`, + * [[Getter]]**. `Getter` is `Optic[S, Unit, A, Unit, Direct]` (a real `to` reading `S => A`, * vestigial `from`); `Review` is the dual `Optic[Unit, S, Unit, A, Direct]` — a vestigial `to` * (reads `Unit`) and a real `from` that *builds* `S` from the focus `A`. * @@ -15,27 +15,32 @@ import data.Direct * do, and slots into the `Direct`-carrier optic surface. * * A `final class` storing `reverseGet` directly — NOT an abstract class with an abstract member — - * for the same composed-dispatch reason documented on [[DirectGetter]]. + * for the same composed-dispatch reason documented on [[Getter]]. * * To get the build direction out of an `Iso` or `Prism`, wrap its reverse directly — * `Review(iso.reverseGet)` / `Review(prism.mend)` — rather than via a bespoke factory: an * `Iso`/`Prism` already *is* a build direction, so cross-optic `from*` constructors would be * redundant (eo has no `Prism.fromIso` etc. for the same reason). */ -final class Review[S, A](val reverseGet: A => S) extends Optic[Unit, S, Unit, A, Direct]: +final class Review[T, B](val reverseGet: B => T) extends Optic[Unit, T, Unit, B, Direct]: type X = Nothing def to(u: Unit): Direct[X, Unit] = Direct(u) - def from(d: Direct[X, A]): S = reverseGet(d.value) + def from(d: Direct[X, B]): T = reverseGet(d.value) - /** Fused `Review.andThen(Review)` — the build-direction mirror of [[DirectGetter.andThen]]. - * `this` builds `S` from `A`, `inner` builds `A` from `B`, so the composite builds `S` from `B` + /** Fused `Review.andThen(Review)` — the build-direction mirror of [[Getter.andThen]]. `this` + * builds `S` from `A`, `inner` builds `A` from `B`, so the composite builds `S` from `B` * (`reverseGet ∘ inner.reverseGet`). `inline` so each composition splices a distinct per-level - * lambda, sidestepping C2's recursive-inlining cap — the same reason [[DirectGetter.andThen]] is + * lambda, sidestepping C2's recursive-inlining cap — the same reason [[Getter.andThen]] is * inline. */ - inline def andThen[B](inner: Review[A, B]): Review[S, B] = - new Review(b => reverseGet(inner.reverseGet(b))) + inline def andThen[D](inner: Review[B, D]): Review[T, D] = + Review(d => reverseGet(inner.reverseGet(d))) + + inline def andThen[G[_, _], D](inner: Optic[?, B, ?, D, G])(using + ReverseAccessor[G] + ): Review[T, D] = + Review(d => reverseGet(inner.reverseGet(d))) /** Constructors for [[Review]]. */ object Review: diff --git a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala b/core/src/main/scala/dev/constructive/eo/optics/Setter.scala index 38cac0c4..f0f76c8c 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Setter.scala @@ -27,17 +27,16 @@ object Setter: * bumpAll.modify(_ + 1)(cfg) * }}} */ - def apply[S, T, A, B](modify: (A => B) => S => T): SetterOptic[S, T, A, B] = - new SetterOptic(modify) + def apply[S, T, A, B](modify: (A => B) => S => T): Setter[S, T, A, B] = + new Setter(modify) /** Concrete Optic subclass for a write-only setter. Stores the writer `modifyFn` directly (so the * hot path skips the `SetterF` carrier round-trip the generic extension performs) and carries a * fused `andThen` for setter∘setter composition — the same shape as [[GetReplaceLens]] / - * [[DirectGetter]]. Returned by [[Setter.apply]] so hand-written setters pick up the fused path + * [[Getter]]. Returned by [[Setter.apply]] so hand-written setters pick up the fused path * automatically. */ -final class SetterOptic[S, T, A, B](val modifyFn: (A => B) => S => T) - extends Optic[S, T, A, B, SetterF]: +final class Setter[S, T, A, B](val modifyFn: (A => B) => S => T) extends Optic[S, T, A, B, SetterF]: type X = (S, A) def to(s: S): SetterF[X, A] = SetterF(s, identity[A]) @@ -58,7 +57,7 @@ final class SetterOptic[S, T, A, B](val modifyFn: (A => B) => S => T) * `Optic.andThen`. * * `inline` so a deep `setter.andThen(setter)…` chain splices distinct writer lambdas per level, - * staying under C2's recursive-inline cap (see [[DirectGetter.andThen]]). + * staying under C2's recursive-inline cap (see [[Getter.andThen]]). */ - inline def andThen[C, D](inner: SetterOptic[A, B, C, D]): SetterOptic[S, T, C, D] = - new SetterOptic(cd => modifyFn(inner.modifyFn(cd))) + inline def andThen[C, D](inner: Setter[A, B, C, D]): Setter[S, T, C, D] = + new Setter(cd => modifyFn(inner.modifyFn(cd))) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala index bb59a062..2ede2862 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala @@ -5,7 +5,7 @@ import scala.collection.immutable.ArraySeq import cats.Traverse -import data.{MultiFocus, ObjArrBuilder, PSVec} +import data.{MultiFocus, MultiFocusK, ObjArrBuilder, PSVec} /** Constructors for `Traversal`. [[each]] / [[pEach]] use the `MultiFocus[PSVec]` carrier; the * [[two]] / [[three]] / [[four]] fixed-arity variants ride `MultiFocus[Function1[Int, *]]` over a @@ -35,19 +35,22 @@ object Traversal: new Optic[T[A], T[B], A, B, MultiFocus[PSVec]]: type X = T[A] - def to(ta: T[A]): (T[A], PSVec[A]) = + def to(ta: T[A]): MultiFocus[PSVec][X, A] = ta match case refArr: ArraySeq.ofRef[?] => // ArraySeq.ofRef.unsafeArray is already an Array[AnyRef]; aliasing is safe because // ArraySeq is immutable and Functor[PSVec].map allocates fresh output. - (ta, PSVec.unsafeWrap[A](refArr.unsafeArray.asInstanceOf[Array[AnyRef]])) + MultiFocusK.wrap( + ta, + PSVec.unsafeWrap[A](refArr.unsafeArray.asInstanceOf[Array[AnyRef]]), + ) case _ => val buf = new ObjArrBuilder() Traverse[T].foldLeft(ta, ())((_, a) => { buf.append(a.asInstanceOf[AnyRef]); () }) - (ta, buf.freezeAsPSVec[A]) + MultiFocusK.wrap(ta, buf.freezeAsPSVec[A]) - def from(pair: (T[A], PSVec[B])): T[B] = - val (xo, vec) = pair + def from(pair: MultiFocus[PSVec][X, B]): T[B] = + val (xo, vec) = (pair.context, pair.foci) xo match case _: ArraySeq[?] => // `unsafeShareableArray` returns the Slice's backing array when it densely covers @@ -84,8 +87,8 @@ object Traversal: ): Optic[S, S, S, S, MultiFocus[PSVec]] = new Optic[S, S, S, S, MultiFocus[PSVec]]: type X = S - def to(s: S): (S, PSVec[S]) = (s, children(s)) - def from(pair: (S, PSVec[S])): S = rebuild(pair._1, pair._2) + def to(s: S): MultiFocus[PSVec][X, S] = MultiFocusK.wrap(s, children(s)) + def from(pair: MultiFocus[PSVec][X, S]): S = rebuild(pair.context, pair.foci) /** Traversal over two per-element getters with a `reverse` reassembly. Carrier is * `MultiFocus[Function1[Int, *]]` — the homogeneous-arity Tuple_N-shaped traversal encoded as an @@ -102,14 +105,14 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - def to(s: S): (Unit, Int => A) = + def to(s: S): MultiFocus[Function1[Int, *]][X, A] = val read: Int => A = (i: Int) => i match case 0 => a(s) case 1 => b(s) - ((), read) + MultiFocusK.wrap((), read) - def from(pair: (Unit, Int => B)): T = reverse(pair._2(0), pair._2(1)) + def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = reverse(pair.foci(0), pair.foci(1)) /** Fixed-arity-3 — see [[two]]. @group Constructors */ def three[S, T, A, B]( @@ -121,15 +124,16 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - def to(s: S): (Unit, Int => A) = + def to(s: S): MultiFocus[Function1[Int, *]][X, A] = val read: Int => A = (i: Int) => i match case 0 => a(s) case 1 => b(s) case 2 => c(s) - ((), read) + MultiFocusK.wrap((), read) - def from(pair: (Unit, Int => B)): T = reverse(pair._2(0), pair._2(1), pair._2(2)) + def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = + reverse(pair.foci(0), pair.foci(1), pair.foci(2)) /** Fixed-arity-4 — see [[two]]. @group Constructors */ def four[S, T, A, B]( @@ -142,14 +146,14 @@ object Traversal: new Optic[S, T, A, B, MultiFocus[Function1[Int, *]]]: type X = Unit - def to(s: S): (Unit, Int => A) = + def to(s: S): MultiFocus[Function1[Int, *]][X, A] = val read: Int => A = (i: Int) => i match case 0 => a(s) case 1 => b(s) case 2 => c(s) case 3 => d(s) - ((), read) + MultiFocusK.wrap((), read) - def from(pair: (Unit, Int => B)): T = - reverse(pair._2(0), pair._2(1), pair._2(2), pair._2(3)) + def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = + reverse(pair.foci(0), pair.foci(1), pair.foci(2), pair.foci(3)) diff --git a/core/src/test/scala/dev/constructive/eo/FoldSpec.scala b/core/src/test/scala/dev/constructive/eo/FoldSpec.scala index fbe7d936..0deffd77 100644 --- a/core/src/test/scala/dev/constructive/eo/FoldSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/FoldSpec.scala @@ -15,5 +15,5 @@ class FoldSpec extends Specification with ScalaCheck: "Fold.select keeps matching values and drops the rest" >> { val evenFold = Fold.select[Int](_ % 2 == 0) - forAll((n: Int) => evenFold.to(n) == (if n % 2 == 0 then Some(n) else None)) + forAll((n: Int) => evenFold.to(n).value == (if n % 2 == 0 then Some(n) else None)) } diff --git a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala index 47ebd67e..85d84bb8 100644 --- a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala +++ b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.jsoniter import com.github.plokhotnyuk.jsoniter_scala.core.{readFromSubArray, JsonValueCodec} -import dev.constructive.eo.data.{MultiFocus, PSVec} +import dev.constructive.eo.data.{MultiFocus, MultiFocusK, PSVec} import dev.constructive.eo.optics.Optic /** Read-only Traversal over JSON byte buffers. Resolves a wildcard-bearing JSONPath against @@ -67,10 +67,10 @@ object JsoniterTraversal: new Optic[Array[Byte], Array[Byte], A, A, MultiFocus[PSVec]]: type X = (Array[Byte], List[JsonPathScanner.Span]) - def to(bytes: Array[Byte]): (X, PSVec[A]) = + def to(bytes: Array[Byte]): MultiFocus[PSVec][X, A] = val spans = JsonPathScanner.findAll(bytes, steps) val n = spans.length - if n == 0 then ((bytes, spans), PSVec.empty[A]) + if n == 0 then MultiFocusK.wrap((bytes, spans), PSVec.empty[A]) else // Single Array[AnyRef] allocation; tight-pack only on partial decode failure. val arr = new Array[AnyRef](n) @@ -89,6 +89,6 @@ object JsoniterTraversal: val tight = new Array[AnyRef](written) System.arraycopy(arr, 0, tight, 0, written) PSVec.unsafeWrap[A](tight) - ((bytes, spans), psv) + MultiFocusK.wrap((bytes, spans), psv) - def from(pair: (X, PSVec[A])): Array[Byte] = pair._1._1 + def from(pair: MultiFocus[PSVec][X, A]): Array[Byte] = pair.context._1 diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala index c8e4e989..dbc540d9 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -2,14 +2,14 @@ package dev.constructive.eo package schemes import data.PSVec -import optics.{DirectGetter, Getter, Plated, Review} +import optics.{Getter, Plated, Review} /** Recursion schemes as composable optics, built on the core optic surface. * - * - [[cata]] is a `DirectGetter[S, A]` driven by `Plated[S]` — the structural fold, generalising + * - [[cata]] is a `Getter[S, A]` driven by `Plated[S]` — the structural fold, generalising * `Plated.transform` from `S => S` to `S => A`. * - [[ana]] is a `Review[S, Seed]` — the unfold (build `S` from a seed), taking a [[Coalg]]. - * - [[hylo]] is a **fused** `DirectGetter[Seed, A]` — refold with **no intermediate `S`** built. + * - [[hylo]] is a **fused** `Getter[Seed, A]` — refold with **no intermediate `S`** built. * * Because they produce core optic types, they compose with the rest of the optic algebra: * `someLens.andThen(cata(alg))`, and the materializing `ana(…).cross(cata(…))` (via the core @@ -182,7 +182,7 @@ object Schemes: * node `S` (paramorphism-flavored) plus its already-folded children. Stack-safe; folds child * results in place (see [[foldInPlace]]) so it allocates one array per node, not two. */ - def cata[S, A](alg: (S, PSVec[A]) => A)(using P: Plated[S]): DirectGetter[S, A] = + def cata[S, A](alg: (S, PSVec[A]) => A)(using P: Plated[S]): Getter[S, A] = Getter[S, A](foldInPlace[S, A](P.childrenArray, alg)) /** Anamorphism as a `Review` (reverse-construction optic): a stack-safe unfold `Seed => S` driven @@ -199,5 +199,5 @@ object Schemes: def hylo[Seed, A]( expand: Seed => PSVec[Seed], alg: (Seed, PSVec[A]) => A, - ): DirectGetter[Seed, A] = + ): Getter[Seed, A] = Getter[Seed, A](unfoldFold(expand, alg)) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala index 91a3fc9f..333e5f68 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -5,7 +5,7 @@ import io.circe.Json import org.specs2.mutable.Specification import data.PSVec -import optics.{DirectGetter, Getter, Plated} +import optics.{Getter, Plated} import optics.Optic.* // cross, andThen, get import generics.plate @@ -42,7 +42,7 @@ class SchemesSpec extends Specification: private val expr: Expr = Expr.Add(Expr.Lit(1.0), Expr.Mul(Expr.Lit(2.0), Expr.Lit(3.0))) "cata is a Getter that folds an Expr to a value" >> { - val evalG: DirectGetter[Expr, Double] = Schemes.cata(eval) + val evalG: Getter[Expr, Double] = Schemes.cata(eval) (evalG.get(expr) == 7.0) must beTrue } @@ -52,7 +52,7 @@ class SchemesSpec extends Specification: } "cata-as-Getter composes onto an outer Getter via andThen" >> { - val composed: DirectGetter[Wrapped, Double] = + val composed: Getter[Wrapped, Double] = Getter[Wrapped, Expr](_.expr).andThen(Schemes.cata(eval)) (composed.get(Wrapped("x", expr)) == 7.0) must beTrue } @@ -69,13 +69,13 @@ class SchemesSpec extends Specification: } "fused hylo folds a seed to a value (no intermediate Expr) and agrees with cata∘ana" >> { - val h: DirectGetter[Int, Double] = Schemes.hylo(expandFib, fusedFib) + val h: Getter[Int, Double] = Schemes.hylo(expandFib, fusedFib) val viaCross = Schemes.ana(buildExprCoalg).cross(Schemes.cata(eval)).get(3) (h.get(3) == 4.0) && (viaCross == 4.0) must beTrue } "the fused hylo Getter composes further into the pipeline" >> { - val toStr: DirectGetter[Int, String] = + val toStr: Getter[Int, String] = Schemes.hylo(expandFib, fusedFib).andThen(Getter[Double, String](_.toString)) (toStr.get(3) == "4.0") must beTrue } diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 673e4a79..7f92c4dc 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -24,7 +24,7 @@ circe `Plated[Json]` from `cats-eo-circe` as a concrete recursive `S`. ```scala mdoc:silent import dev.constructive.eo.schemes.Schemes import dev.constructive.eo.data.PSVec -import dev.constructive.eo.optics.{Getter, DirectGetter} +import dev.constructive.eo.optics.Getter import dev.constructive.eo.optics.Optic.* // get, andThen, cross import dev.constructive.eo.circe.platedJson // given Plated[Json] import io.circe.Json @@ -36,7 +36,7 @@ The algebra sees each node plus its already-folded children. Here, sum every num in a JSON document: ```scala mdoc:silent -val sumNumbers: DirectGetter[Json, Int] = +val sumNumbers: Getter[Json, Int] = Schemes.cata[Json, Int]((node, folded) => node.asNumber.flatMap(_.toInt).getOrElse(0) + folded.toList.sum ) @@ -56,7 +56,7 @@ Because `cata` is a `Getter`, it composes onto any optic that ends in the recurs ```scala mdoc:silent final case class Payload(label: String, body: Json) -val bodySum: DirectGetter[Payload, Int] = +val bodySum: Getter[Payload, Int] = Getter[Payload, Json](_.body).andThen(sumNumbers) ``` @@ -90,7 +90,7 @@ buildTree.reverseGet(2) ```scala mdoc:silent // fused: count the leaves of that same tree, with no Json ever constructed -val countLeaves: DirectGetter[Int, Int] = +val countLeaves: Getter[Int, Int] = Schemes.hylo[Int, Int]( expand = n => if n <= 0 then PSVec.empty[Int] else PSVec.of(0, n - 1), alg = (n, rs) => if n <= 0 then 1 else rs.toList.sum, diff --git a/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala new file mode 100644 index 00000000..01e1a92e --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala @@ -0,0 +1,591 @@ +package dev.constructive.eo + +// ===================================================================== +// GENERATED-THEN-MAINTAINED — regenerate with tools from the matrix +// harness lineage if the family list changes, otherwise edit in place. +// +// The composition matrix: every (outer family ∘ inner family) pair is +// asserted to either compose — WITHOUT a type ascription, landing at +// the expected strength — or to NOT compile (semantically void cells: +// building through non-invertible optics, writing through read-only +// ones, reading through write-only ones). +// +// Deliberately NO `data.*.given` imports: the matrix pins the +// out-of-the-box UX. If a cell starts requiring an import or an +// expected-type ascription to compose, this spec goes red. +// ===================================================================== + +import scala.compiletime.testing.typeChecks + +import org.specs2.mutable.Specification + +import optics.* +import data.{Affine, Direct, Forget, MultiFocus, PSVec, SetterF} + +object MatrixFixtures: + case class Box[A](a: A) + val o_iso = Iso[Box[Box[Int]], Box[Box[Int]], Box[Int], Box[Int]](_.a, Box(_)) + val o_isoL = Iso[Box[List[Int]], Box[List[Int]], List[Int], List[Int]](_.a, Box(_)) + val o_lens = Lens[Box[Box[Int]], Box[Int]](_.a, (s, m) => Box(m)) + val o_lensL = Lens[Box[List[Int]], List[Int]](_.a, (s, m) => Box(m)) + val o_prism = Prism[Box[Box[Int]], Box[Int]](b => Right(b.a), Box(_)) + val o_prismL = Prism[Box[List[Int]], List[Int]](b => Right(b.a), Box(_)) + + val o_optional = Optional[Box[Box[Int]], Box[Box[Int]], Box[Int], Box[Int], Affine]( + b => Right(b.a), + sb => Box(sb._2), + ) + + val o_optionalL = Optional[Box[List[Int]], Box[List[Int]], List[Int], List[Int], Affine]( + b => Right(b.a), + sb => Box(sb._2), + ) + + val o_trav = Traversal.each[List, Box[Int]] + val o_travL = Traversal.each[List, List[Int]] + val o_getter = Getter[Box[Box[Int]], Box[Int]](_.a) + val o_getterL = Getter[Box[List[Int]], List[Int]](_.a) + val o_affold = AffineFold[Box[Box[Int]], Box[Int]](b => Some(b.a)) + val o_affoldL = AffineFold[Box[List[Int]], List[Int]](b => Some(b.a)) + val o_fold = Fold[List, Box[Int]] + val o_foldL = Fold[List, List[Int]] + val o_setter = Setter[Box[Box[Int]], Box[Box[Int]], Box[Int], Box[Int]](f => b => Box(f(b.a))) + + val o_setterL = + Setter[Box[List[Int]], Box[List[Int]], List[Int], List[Int]](f => b => Box(f(b.a))) + + val o_review = Review[Box[Box[Int]], Box[Int]](Box(_)) + val o_reviewL = Review[Box[List[Int]], List[Int]](Box(_)) + val i_iso = Iso[Box[Int], Box[Int], Int, Int](_.a, Box(_)) + val i_lens = Lens[Box[Int], Int](_.a, (s, m) => Box(m)) + val i_prism = Prism[Box[Int], Int](b => Right(b.a), Box(_)) + val i_optional = Optional[Box[Int], Box[Int], Int, Int, Affine](b => Right(b.a), sb => Box(sb._2)) + val i_trav = Traversal.each[List, Int] + val i_getter = Getter[Box[Int], Int](_.a) + val i_affold = AffineFold[Box[Int], Int](b => Some(b.a)) + val i_fold = Fold[List, Int] + val i_setter = Setter[Box[Int], Box[Int], Int, Int](f => b => Box(f(b.a))) + val i_review = Review[Box[Int], Int](Box(_)) + +class CompositionMatrixSpec extends Specification: + import MatrixFixtures.* + + "iso (outer) composition row" >> { + "iso ∘ iso → Optic" >> { + typeChecks("o_iso.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Direct] = o_iso.andThen(i_iso)" + ) must beTrue + } + "iso ∘ lens → Optic" >> { + typeChecks("o_iso.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Tuple2] = o_iso.andThen(i_lens)" + ) must beTrue + } + "iso ∘ prism → Optic" >> { + typeChecks("o_iso.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Either] = o_iso.andThen(i_prism)" + ) must beTrue + } + "iso ∘ optional → Optic" >> { + typeChecks("o_iso.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_iso.andThen(i_optional)" + ) must beTrue + } + "iso ∘ trav → Optic" >> { + typeChecks("o_isoL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, MultiFocus[PSVec]] = o_isoL.andThen(i_trav)" + ) must beTrue + } + "iso ∘ getter → Getter" >> { + typeChecks("o_iso.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: Getter[Box[Box[Int]], Int] = o_iso.andThen(i_getter)") must beTrue + } + "iso ∘ affold → AffineFold" >> { + typeChecks("o_iso.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_iso.andThen(i_affold)") must beTrue + } + "iso ∘ fold → Optic" >> { + typeChecks("o_isoL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_isoL.andThen(i_fold)" + ) must beTrue + } + "iso ∘ setter → Optic" >> { + typeChecks("o_iso.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_iso.andThen(i_setter)" + ) must beTrue + } + "iso ∘ review → Review" >> { + typeChecks("o_iso.andThen(i_review)") must beTrue // resolves with no expected type + typeChecks("val r: Review[Box[Box[Int]], Int] = o_iso.andThen(i_review)") must beTrue + } + } + + "lens (outer) composition row" >> { + "lens ∘ iso → Optic" >> { + typeChecks("o_lens.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Tuple2] = o_lens.andThen(i_iso)" + ) must beTrue + } + "lens ∘ lens → Optic" >> { + typeChecks("o_lens.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Tuple2] = o_lens.andThen(i_lens)" + ) must beTrue + } + "lens ∘ prism → Optic" >> { + typeChecks("o_lens.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_lens.andThen(i_prism)" + ) must beTrue + } + "lens ∘ optional → Optic" >> { + typeChecks("o_lens.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_lens.andThen(i_optional)" + ) must beTrue + } + "lens ∘ trav → Optic" >> { + typeChecks("o_lensL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, MultiFocus[PSVec]] = o_lensL.andThen(i_trav)" + ) must beTrue + } + "lens ∘ getter → Getter" >> { + typeChecks("o_lens.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: Getter[Box[Box[Int]], Int] = o_lens.andThen(i_getter)") must beTrue + } + "lens ∘ affold → AffineFold" >> { + typeChecks("o_lens.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_lens.andThen(i_affold)") must beTrue + } + "lens ∘ fold → Optic" >> { + typeChecks("o_lensL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_lensL.andThen(i_fold)" + ) must beTrue + } + "lens ∘ setter → Optic" >> { + typeChecks("o_lens.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_lens.andThen(i_setter)" + ) must beTrue + } + "lens ∘ review must not compile" >> { + typeChecks("o_lens.andThen(i_review)") must beFalse + } + } + + "prism (outer) composition row" >> { + "prism ∘ iso → Optic" >> { + typeChecks("o_prism.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Either] = o_prism.andThen(i_iso)" + ) must beTrue + } + "prism ∘ lens → Optic" >> { + typeChecks("o_prism.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_prism.andThen(i_lens)" + ) must beTrue + } + "prism ∘ prism → Optic" >> { + typeChecks("o_prism.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Either] = o_prism.andThen(i_prism)" + ) must beTrue + } + "prism ∘ optional → Optic" >> { + typeChecks("o_prism.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_prism.andThen(i_optional)" + ) must beTrue + } + "prism ∘ trav → Optic" >> { + typeChecks("o_prismL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, MultiFocus[PSVec]] = o_prismL.andThen(i_trav)" + ) must beTrue + } + "prism ∘ getter → AffineFold" >> { + typeChecks("o_prism.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_prism.andThen(i_getter)") must beTrue + } + "prism ∘ affold → AffineFold" >> { + typeChecks("o_prism.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_prism.andThen(i_affold)") must beTrue + } + "prism ∘ fold → Optic" >> { + typeChecks("o_prismL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_prismL.andThen(i_fold)" + ) must beTrue + } + "prism ∘ setter → Optic" >> { + typeChecks("o_prism.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_prism.andThen(i_setter)" + ) must beTrue + } + "prism ∘ review → Review" >> { + typeChecks("o_prism.andThen(i_review)") must beTrue // resolves with no expected type + typeChecks("val r: Review[Box[Box[Int]], Int] = o_prism.andThen(i_review)") must beTrue + } + } + + "optional (outer) composition row" >> { + "optional ∘ iso → Optic" >> { + typeChecks("o_optional.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_optional.andThen(i_iso)" + ) must beTrue + } + "optional ∘ lens → Optic" >> { + typeChecks("o_optional.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_optional.andThen(i_lens)" + ) must beTrue + } + "optional ∘ prism → Optic" >> { + typeChecks("o_optional.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_optional.andThen(i_prism)" + ) must beTrue + } + "optional ∘ optional → Optic" >> { + typeChecks("o_optional.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, Affine] = o_optional.andThen(i_optional)" + ) must beTrue + } + "optional ∘ trav → Optic" >> { + typeChecks("o_optionalL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, MultiFocus[PSVec]] = o_optionalL.andThen(i_trav)" + ) must beTrue + } + "optional ∘ getter → AffineFold" >> { + typeChecks("o_optional.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_optional.andThen(i_getter)") must beTrue + } + "optional ∘ affold → AffineFold" >> { + typeChecks("o_optional.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_optional.andThen(i_affold)") must beTrue + } + "optional ∘ fold → Optic" >> { + typeChecks("o_optionalL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_optionalL.andThen(i_fold)" + ) must beTrue + } + "optional ∘ setter → Optic" >> { + typeChecks("o_optional.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_optional.andThen(i_setter)" + ) must beTrue + } + "optional ∘ review must not compile" >> { + typeChecks("o_optional.andThen(i_review)") must beFalse + } + } + + "trav (outer) composition row" >> { + "trav ∘ iso → Optic" >> { + typeChecks("o_trav.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, MultiFocus[PSVec]] = o_trav.andThen(i_iso)" + ) must beTrue + } + "trav ∘ lens → Optic" >> { + typeChecks("o_trav.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, MultiFocus[PSVec]] = o_trav.andThen(i_lens)" + ) must beTrue + } + "trav ∘ prism → Optic" >> { + typeChecks("o_trav.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, MultiFocus[PSVec]] = o_trav.andThen(i_prism)" + ) must beTrue + } + "trav ∘ optional → Optic" >> { + typeChecks("o_trav.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, MultiFocus[PSVec]] = o_trav.andThen(i_optional)" + ) must beTrue + } + "trav ∘ trav → Optic" >> { + typeChecks("o_travL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[List[Int]], List[List[Int]], Int, Int, MultiFocus[PSVec]] = o_travL.andThen(i_trav)" + ) must beTrue + } + "trav ∘ getter → Optic" >> { + typeChecks("o_trav.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_trav.andThen(i_getter)" + ) must beTrue + } + "trav ∘ affold → Optic" >> { + typeChecks("o_trav.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_trav.andThen(i_affold)" + ) must beTrue + } + "trav ∘ fold → Optic" >> { + typeChecks("o_travL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[List[Int]], Unit, Int, Unit, Forget[List]] = o_travL.andThen(i_fold)" + ) must beTrue + } + "trav ∘ setter → Optic" >> { + typeChecks("o_trav.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, SetterF] = o_trav.andThen(i_setter)" + ) must beTrue + } + "trav ∘ review must not compile" >> { + typeChecks("o_trav.andThen(i_review)") must beFalse + } + } + + "getter (outer) composition row" >> { + "getter ∘ iso → Getter" >> { + typeChecks("o_getter.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks("val r: Getter[Box[Box[Int]], Int] = o_getter.andThen(i_iso)") must beTrue + } + "getter ∘ lens → Getter" >> { + typeChecks("o_getter.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks("val r: Getter[Box[Box[Int]], Int] = o_getter.andThen(i_lens)") must beTrue + } + "getter ∘ prism → AffineFold" >> { + typeChecks("o_getter.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_getter.andThen(i_prism)") must beTrue + } + "getter ∘ optional → AffineFold" >> { + typeChecks("o_getter.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_getter.andThen(i_optional)") must beTrue + } + "getter ∘ trav → Optic" >> { + typeChecks("o_getterL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_getterL.andThen(i_trav)" + ) must beTrue + } + "getter ∘ getter → Getter" >> { + typeChecks("o_getter.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: Getter[Box[Box[Int]], Int] = o_getter.andThen(i_getter)") must beTrue + } + "getter ∘ affold → AffineFold" >> { + typeChecks("o_getter.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_getter.andThen(i_affold)") must beTrue + } + "getter ∘ fold → Optic" >> { + typeChecks("o_getterL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_getterL.andThen(i_fold)" + ) must beTrue + } + "getter ∘ setter must not compile" >> { + typeChecks("o_getter.andThen(i_setter)") must beFalse + } + "getter ∘ review must not compile" >> { + typeChecks("o_getter.andThen(i_review)") must beFalse + } + } + + "affold (outer) composition row" >> { + "affold ∘ iso → AffineFold" >> { + typeChecks("o_affold.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_iso)") must beTrue + } + "affold ∘ lens → AffineFold" >> { + typeChecks("o_affold.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_lens)") must beTrue + } + "affold ∘ prism → AffineFold" >> { + typeChecks("o_affold.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_prism)") must beTrue + } + "affold ∘ optional → AffineFold" >> { + typeChecks("o_affold.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_optional)") must beTrue + } + "affold ∘ trav → Optic" >> { + typeChecks("o_affoldL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_affoldL.andThen(i_trav)" + ) must beTrue + } + "affold ∘ getter → AffineFold" >> { + typeChecks("o_affold.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_getter)") must beTrue + } + "affold ∘ affold → AffineFold" >> { + typeChecks("o_affold.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks("val r: AffineFold[Box[Box[Int]], Int] = o_affold.andThen(i_affold)") must beTrue + } + "affold ∘ fold → Optic" >> { + typeChecks("o_affoldL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_affoldL.andThen(i_fold)" + ) must beTrue + } + "affold ∘ setter must not compile" >> { + typeChecks("o_affold.andThen(i_setter)") must beFalse + } + "affold ∘ review must not compile" >> { + typeChecks("o_affold.andThen(i_review)") must beFalse + } + } + + "fold (outer) composition row" >> { + "fold ∘ iso → Optic" >> { + typeChecks("o_fold.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_iso)" + ) must beTrue + } + "fold ∘ lens → Optic" >> { + typeChecks("o_fold.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_lens)" + ) must beTrue + } + "fold ∘ prism → Optic" >> { + typeChecks("o_fold.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_prism)" + ) must beTrue + } + "fold ∘ optional → Optic" >> { + typeChecks("o_fold.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_optional)" + ) must beTrue + } + "fold ∘ trav → Optic" >> { + typeChecks("o_foldL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[List[Int]], Unit, Int, Unit, Forget[List]] = o_foldL.andThen(i_trav)" + ) must beTrue + } + "fold ∘ getter → Optic" >> { + typeChecks("o_fold.andThen(i_getter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_getter)" + ) must beTrue + } + "fold ∘ affold → Optic" >> { + typeChecks("o_fold.andThen(i_affold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[Box[Int]], Unit, Int, Unit, Forget[List]] = o_fold.andThen(i_affold)" + ) must beTrue + } + "fold ∘ fold → Optic" >> { + typeChecks("o_foldL.andThen(i_fold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[List[List[Int]], Unit, Int, Unit, Forget[List]] = o_foldL.andThen(i_fold)" + ) must beTrue + } + "fold ∘ setter must not compile" >> { + typeChecks("o_fold.andThen(i_setter)") must beFalse + } + "fold ∘ review must not compile" >> { + typeChecks("o_fold.andThen(i_review)") must beFalse + } + } + + "setter (outer) composition row" >> { + "setter ∘ iso → Optic" >> { + typeChecks("o_setter.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_iso)" + ) must beTrue + } + "setter ∘ lens → Optic" >> { + typeChecks("o_setter.andThen(i_lens)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_lens)" + ) must beTrue + } + "setter ∘ prism → Optic" >> { + typeChecks("o_setter.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_prism)" + ) must beTrue + } + "setter ∘ optional → Optic" >> { + typeChecks("o_setter.andThen(i_optional)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_optional)" + ) must beTrue + } + "setter ∘ trav → Optic" >> { + typeChecks("o_setterL.andThen(i_trav)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, SetterF] = o_setterL.andThen(i_trav)" + ) must beTrue + } + "setter ∘ getter must not compile" >> { + typeChecks("o_setter.andThen(i_getter)") must beFalse + } + "setter ∘ affold must not compile" >> { + typeChecks("o_setter.andThen(i_affold)") must beFalse + } + "setter ∘ fold must not compile" >> { + typeChecks("o_setterL.andThen(i_fold)") must beFalse + } + "setter ∘ setter → Optic" >> { + typeChecks("o_setter.andThen(i_setter)") must beTrue // resolves with no expected type + typeChecks( + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_setter)" + ) must beTrue + } + "setter ∘ review must not compile" >> { + typeChecks("o_setter.andThen(i_review)") must beFalse + } + } + + "review (outer) composition row" >> { + "review ∘ iso → Review" >> { + typeChecks("o_review.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks("val r: Review[Box[Box[Int]], Int] = o_review.andThen(i_iso)") must beTrue + } + "review ∘ lens must not compile" >> { + typeChecks("o_review.andThen(i_lens)") must beFalse + } + "review ∘ prism → Review" >> { + typeChecks("o_review.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks("val r: Review[Box[Box[Int]], Int] = o_review.andThen(i_prism)") must beTrue + } + "review ∘ optional must not compile" >> { + typeChecks("o_review.andThen(i_optional)") must beFalse + } + "review ∘ trav must not compile" >> { + typeChecks("o_reviewL.andThen(i_trav)") must beFalse + } + "review ∘ getter must not compile" >> { + typeChecks("o_review.andThen(i_getter)") must beFalse + } + "review ∘ affold must not compile" >> { + typeChecks("o_review.andThen(i_affold)") must beFalse + } + "review ∘ fold must not compile" >> { + typeChecks("o_reviewL.andThen(i_fold)") must beFalse + } + "review ∘ setter must not compile" >> { + typeChecks("o_review.andThen(i_setter)") must beFalse + } + "review ∘ review → Review" >> { + typeChecks("o_review.andThen(i_review)") must beTrue // resolves with no expected type + typeChecks("val r: Review[Box[Box[Int]], Int] = o_review.andThen(i_review)") must beTrue + } + } diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index 8346d8d5..47060514 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -23,11 +23,20 @@ import optics.{ Prism, Review, Setter, - SetterOptic, Traversal, } import optics.Optic.* -import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, SetterF} +import data.{ + Affine, + Forget, + ForgetK, + Direct, + MultiFocus, + MultiFocusK, + MultiFocusSingleton, + PSVec, + SetterF, +} import data.Direct.given import data.Forget.given import data.Affine.given @@ -68,8 +77,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: ): Optic[Int, Int, Int, Int, Forget[F]] = new Optic[Int, Int, Int, Int, Forget[F]]: type X = Unit - def to(s: Int): F[Int] = toF(s) - def from(b: F[Int]): Int = fromF(b) + def to(s: Int): Forget[F][X, Int] = ForgetK(toF(s)) + def from(b: Forget[F][X, Int]): Int = fromF(b.value) // ----- Basic-factory smoke tests ------------------------------------- // @@ -167,7 +176,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val sumOk = forAll((xs: List[Int]) => f.foldMap(identity[Int])(xs) == xs.sum) val evenFold: Optic[Int, Unit, Int, Unit, Forget[Option]] = Fold.select[Int](_ % 2 == 0) - val selectOk = forAll((n: Int) => evenFold.to(n) == (if n % 2 == 0 then Some(n) else None)) + val selectOk = + forAll((n: Int) => evenFold.to(n).value == (if n % 2 == 0 then Some(n) else None)) val evenFstOpt: Optic[(Int, Int), (Int, Int), Int, Int, Affine] = Optional[(Int, Int), (Int, Int), Int, Int, Tuple2]( @@ -328,11 +338,11 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: // total readers -> Getter val fstLens = Lens[(Int, String), Int](_._1, (t, n) => (n, t._2)) - val lensThen: Optic[(Int, String), Unit, String, Unit, Direct] = fstLens.andThen(toStr) + val lensThen: Getter[(Int, String), String] = fstLens.andThen(toStr) val lensOk = lensThen.get((7, "x")) === "7" val idIso = Iso[Int, Int, Int, Int](identity, identity) - val isoThen: Optic[Int, Unit, String, Unit, Direct] = idIso.andThen(toStr) + val isoThen: Getter[Int, String] = idIso.andThen(toStr) val isoOk = isoThen.get(9) === "9" // partial readers -> AffineFold @@ -356,7 +366,7 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: // write-side dual: any writable optic .andThen(Setter) -> Setter val plusSetter = Setter[Int, Int, Int, Int](f => n => f(n)) - val lensThenSet: SetterOptic[(Int, String), (Int, String), Int, Int] = + val lensThenSet: Setter[(Int, String), (Int, String), Int, Int] = fstLens.andThen(plusSetter) val setLensOk = lensThenSet.modify(_ + 1)((7, "x")) === ((8, "x")) @@ -461,11 +471,11 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: _.fold(-1)(_ + 1), ) val sameCarrier = outer.andThen(inner) - val sameOk = (sameCarrier.to(5) === Some(51)) - .and(sameCarrier.to(-2) === None) - .and(sameCarrier.to(200) === None) - .and(sameCarrier.from(Some(7)) === 1016) - .and(sameCarrier.from(None) === 998) + val sameOk = (sameCarrier.to(5).value === Some(51)) + .and(sameCarrier.to(-2).value === None) + .and(sameCarrier.to(200).value === None) + .and(sameCarrier.from(ForgetK(Some(7))) === 1016) + .and(sameCarrier.from(ForgetK(None)) === 998) val pureForget = forgetOpt[Option]( n => Option.when(n > 0)(n * 10), @@ -477,10 +487,10 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val inner2: Optic[Int, Int, Int, Int, MultiFocus[Option]] = new Optic[Int, Int, Int, Int, MultiFocus[Option]]: type X = String - def to(n: Int): (String, Option[Int]) = (s"tag-$n", Option.when(n < 1000)(n + 1)) - def from(pair: (String, Option[Int])): Int = - val (tag, fb) = pair - fb.fold(tag.length)(_ * 100 + tag.length) + def to(n: Int): MultiFocus[Option][X, Int] = + MultiFocusK.wrap(s"tag-$n", Option.when(n < 1000)(n + 1)) + def from(pair: MultiFocus[Option][X, Int]): Int = + pair.foci.fold(pair.context.length)(_ * 100 + pair.context.length) val composed = lifted.andThen(inner2) val composedOk = (composed.modify(identity)(5) === 5113).and(composed.modify(identity)(-2) === -1) @@ -713,13 +723,13 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val triplet: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - def to(n: Int): Forget[List][Unit, Int] = List(n - 1, n, n + 1) + def to(n: Int): Forget[List][Unit, Int] = ForgetK(List(n - 1, n, n + 1)) def from(u: Forget[List][Unit, Unit]): Unit = () val pair: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - def to(n: Int): Forget[List][Unit, Int] = List(n, n + 1) + def to(n: Int): Forget[List][Unit, Int] = ForgetK(List(n, n + 1)) def from(u: Forget[List][Unit, Unit]): Unit = () val composed = triplet.andThen(pair) diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index 6cf9e04d..b20511a9 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -6,7 +6,7 @@ import org.scalacheck.{Arbitrary, Cogen, Gen} import org.specs2.mutable.Specification import optics.{AffineFold, Fold, Getter, Iso, Lens, Optic, Optional, Prism, Setter, Traversal} -import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} +import data.{Affine, Forget, ForgetK, Direct, MultiFocus, MultiFocusK, PSVec, SetterF} import data.Affine.given import data.Forget.given import data.MultiFocus.given @@ -205,7 +205,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: (n: Int) => val neverFold: Optic[Int, Unit, Int, Unit, data.Forget[Option]] = Fold.select[Int](_ => false) - val toOk = evenSelectFold.to(n) == (if n % 2 == 0 then Some(n) else None) + val toOk = evenSelectFold.to(n).value == (if n % 2 == 0 then Some(n) else None) val neverOk = neverFold.foldMap[Int](identity)(n) == 0 toOk && neverOk } @@ -256,7 +256,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: // identical (the X leftover and the PSVec[A] focus vector); only the // top-level carrier name changed. - private given arbMultiFocusPSVec: Arbitrary[((Int, Int), PSVec[Int])] = + private given arbMultiFocusPSVec: Arbitrary[MultiFocus[PSVec][(Int, Int), Int]] = Arbitrary( for x <- Arbitrary.arbitrary[Int] @@ -268,7 +268,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: arr(0) = a0.asInstanceOf[AnyRef] arr(1) = a1.asInstanceOf[AnyRef] arr(2) = a2.asInstanceOf[AnyRef] - ((x, x), PSVec.unsafeWrap[Int](arr)) + MultiFocusK.wrap((x, x), PSVec.unsafeWrap[Int](arr)) ) // ----- MultiFocus[Function1[Int, *]] carrier laws ---------------- @@ -360,6 +360,9 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: // covers: ForgetfulFunctor + ForgetfulTraverse over Forget[List] — // exercises Direct.scala's traverse2 and bifunctor paths that pure Direct // fixtures don't land. + private given arbForgetListUnitInt: Arbitrary[Forget[List][Unit, Int]] = + Arbitrary(Arbitrary.arbitrary[List[Int]].map(ForgetK(_))) + checkAllForgetfulFunctorFor[Forget[List], Unit, Int]( "ForgetfulFunctor[Forget[List]] on Forget[List][Unit, Int]" ) @@ -403,7 +406,9 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: ) private given arbMultiFocusListIntInt: Arbitrary[MultiFocus[List][Int, Int]] = - Arbitrary(Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map((x, _)))) + Arbitrary( + Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map(MultiFocusK.wrap(x, _))) + ) private given arbMultiFocusOptionIntInt: Arbitrary[MultiFocus[Option][Int, Int]] = Arbitrary( @@ -413,17 +418,21 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: Gen.const(Option.empty[Int]), Arbitrary.arbitrary[Int].map(Option(_)), ) - yield (x, o) + yield MultiFocusK.wrap(x, o) ) private given arbMultiFocusVectorIntInt: Arbitrary[MultiFocus[Vector][Int, Int]] = Arbitrary( - Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map(xs => (x, xs.toVector))) + Arbitrary + .arbitrary[Int] + .flatMap(x => weightedListOf[Int].map(xs => MultiFocusK.wrap(x, xs.toVector))) ) private given arbMultiFocusChainIntInt: Arbitrary[MultiFocus[Chain][Int, Int]] = Arbitrary( - Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map(xs => (x, Chain.fromSeq(xs)))) + Arbitrary + .arbitrary[Int] + .flatMap(x => weightedListOf[Int].map(xs => MultiFocusK.wrap(x, Chain.fromSeq(xs)))) ) // covers: ForgetfulFunctor + ForgetfulTraverse over MultiFocus[List] @@ -475,8 +484,8 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: val ap = summon[ForgetfulApplicative[data.Forget[List]]] forAll { (a: Int, f: Int => Int, n: Int) => val putOk = intDouble.put(f)(a) == intDouble.reverseGet(f(a)) - val pureOk = ap.pure[Unit, Int](n) == List(n) - val mapOk = ap.map[Unit, Int, Int](List(n), _ + 1) == List(n + 1) + val pureOk = ap.pure[Unit, Int](n).value == List(n) + val mapOk = ap.map[Unit, Int, Int](ForgetK(List(n)), _ + 1).value == List(n + 1) putOk && pureOk && mapOk } } diff --git a/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala b/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala index 01c2352d..1ffabf28 100644 --- a/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala @@ -78,7 +78,7 @@ class PowerSeriesSpec extends Specification with ScalaCheck: val lens = Lens[(Int, String), Int](_._1, (s, a) => (a, s._2)) val morphd = lens.morph[MultiFocus[PSVec]] - val sizeOk = forAll { (p: (Int, String)) => morphd.to(p)._2.length == 1 } + val sizeOk = forAll { (p: (Int, String)) => morphd.to(p).foci.length == 1 } val morphIdOk = forAll { (p: (Int, String)) => morphd.modify(identity[Int])(p) == p } val agreeOk = forAll { (p: (Int, String), f: Int => Int) => morphd.modify(f)(p) == lens.modify(f)(p) } From 9ac6925bfdbdf808186370b4b3d25b6f3c5f7d26 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 22:09:02 +0200 Subject: [PATCH 06/14] refactor(core): conventional F/G carrier param names in ReadCompose Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/ReadCompose.scala | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/ReadCompose.scala index 96f570d7..79586f77 100644 --- a/core/src/main/scala/dev/constructive/eo/ReadCompose.scala +++ b/core/src/main/scala/dev/constructive/eo/ReadCompose.scala @@ -22,21 +22,21 @@ import optics.{ForgetFold, Getter, Optic, PickFold} * [[optics.PickFold]] / [[optics.ForgetFold]]. Instances live in this companion, so they are in * implicit scope for every carrier pair with no import. * - * @tparam FO + * @tparam F * outer optic's carrier - * @tparam FI + * @tparam G * inner optic's carrier */ -trait ReadCompose[FO[_, _], FI[_, _]]: +trait ReadCompose[F[_, _], G[_, _]]: /** Concrete read-only optic family the composite lands in — `Getter`, `PickFold`, or * `ForgetFold[*, List, *]`; concrete so collapsed chains keep collapsing through the fused * members. */ type Out[_, _] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): Out[S, C] /** Typeclass instances for [[ReadCompose]] — the read-arity lattice. The four total/partial cells @@ -47,54 +47,54 @@ trait ReadCompose[FO[_, _], FI[_, _]]: object ReadCompose extends LowPriorityReadCompose: /** total ∘ total → [[optics.Getter]]. @group Instances */ - given totalTotal[FO[_, _], FI[_, _]](using - AO: Accessor[FO], - AI: Accessor[FI], - ): ReadCompose[FO, FI] with + given totalTotal[F[_, _], G[_, _]](using + AO: Accessor[F], + AI: Accessor[G], + ): ReadCompose[F, G] with type Out[S, C] = Getter[S, C] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): Getter[S, C] = Getter(s => AI.get(inner.to(AO.get(outer.to(s))))) /** total ∘ partial → [[optics.PickFold]]. @group Instances */ - given totalPartial[FO[_, _], FI[_, _]](using - AO: Accessor[FO], - PI: PartialAccessor[FI], - ): ReadCompose[FO, FI] with + given totalPartial[F[_, _], G[_, _]](using + AO: Accessor[F], + PI: PartialAccessor[G], + ): ReadCompose[F, G] with type Out[S, C] = PickFold[S, C] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): PickFold[S, C] = PickFold(s => PI.getOption(inner.to(AO.get(outer.to(s))))) /** partial ∘ total → [[optics.PickFold]]. @group Instances */ - given partialTotal[FO[_, _], FI[_, _]](using - PO: PartialAccessor[FO], - AI: Accessor[FI], - ): ReadCompose[FO, FI] with + given partialTotal[F[_, _], G[_, _]](using + PO: PartialAccessor[F], + AI: Accessor[G], + ): ReadCompose[F, G] with type Out[S, C] = PickFold[S, C] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): PickFold[S, C] = PickFold(s => PO.getOption(outer.to(s)).map(a => AI.get(inner.to(a)))) /** partial ∘ partial → [[optics.PickFold]]. @group Instances */ - given partialPartial[FO[_, _], FI[_, _]](using - PO: PartialAccessor[FO], - PI: PartialAccessor[FI], - ): ReadCompose[FO, FI] with + given partialPartial[F[_, _], G[_, _]](using + PO: PartialAccessor[F], + PI: PartialAccessor[G], + ): ReadCompose[F, G] with type Out[S, C] = PickFold[S, C] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): PickFold[S, C] = PickFold(s => PO.getOption(outer.to(s)).flatMap(a => PI.getOption(inner.to(a)))) @@ -107,19 +107,19 @@ trait LowPriorityReadCompose: * * @group Instances */ - given foldFold[FO[_, _], FI[_, _]](using - FOF: ForgetfulFold[FO], - FIF: ForgetfulFold[FI], - ): ReadCompose[FO, FI] with + given foldFold[F[_, _], G[_, _]](using + FF: ForgetfulFold[F], + FG: ForgetfulFold[G], + ): ReadCompose[F, G] with type Out[S, C] = ForgetFold[S, List, C] - def compose[S, OT, A, OB, IT, C, IB]( - outer: Optic[S, OT, A, OB, FO], - inner: Optic[A, IT, C, IB, FI], + def compose[S, T, A, B, V, C, D]( + outer: Optic[S, T, A, B, F], + inner: Optic[A, V, C, D, G], ): ForgetFold[S, List, C] = new ForgetFold[S, List, C](s => - FOF.foldMap( - (a: A) => FIF.foldMap((c: C) => c :: Nil, inner.to(a))(using Monoid[List[C]]), + FF.foldMap( + (a: A) => FG.foldMap((c: C) => c :: Nil, inner.to(a))(using Monoid[List[C]]), outer.to(s), )(using Monoid[List[C]]) ) From 29609efcd8dacec9ceb0efcce0d4f376db4a06f1 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 22:09:49 +0200 Subject: [PATCH 07/14] chore: drop stray editor lock file Co-Authored-By: Claude Fable 5 --- core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala | 1 - 1 file changed, 1 deletion(-) delete mode 120000 core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala diff --git a/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala deleted file mode 120000 index 85bc9efb..00000000 --- a/core/src/main/scala/dev/constructive/eo/.#ReadCompose.scala +++ /dev/null @@ -1 +0,0 @@ -rhansen@rh-anine.hr-home.xyz.830766:1780957860 \ No newline at end of file From 827f3cbf9bbafb7df692d59e615afe3950e21cd2 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 22:09:50 +0200 Subject: [PATCH 08/14] chore: ignore editor lock files Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 975f6f2c..9a1f5214 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ site/target/ # wrangler's local Pages state .wrangler/ +.#* From 100b7061671e49e650a5671054aa401045e7ea42 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 22:40:21 +0200 Subject: [PATCH 09/14] refactor(core): hide MultiFocusK/ForgetK, drop redundant carrier given-imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanup to the opaque-carrier conversion. - Construct via the public carrier names: MultiFocus(x, fa) (façade apply) and Forget(fa) (exported ForgetK.apply); removed the now-dead MultiFocusK.wrap. No K-name escapes the two carrier definition files. - Removed 33 redundant `import data.{MultiFocus,Forget,SetterF,Direct,Affine}.given` across core/tests/laws/jsoniter/benchmarks. With every carrier's instances on its own companion (opaque types are now implicit-scope anchors; SetterF/Direct/ Affine always had companions), `Foo[Carrier]` resolves with no import. - Killed two byte-identical duplicate instances that the explicit imports had been masking (imported givens outrank implicit-scope ones): the Affine ForgetfulTraverse and ForgetfulFold now live ONLY on object Affine (Affine.traverse / Affine.fold), matching the carrier-owns convention used by Direct/SetterF/MultiFocus/Forget. object ForgetfulTraverse / ForgetfulFold now hold only the stdlib carriers (Tuple2, Either) whose companions can't be extended. This removes the latent ambiguity that surfaced once the imports were gone, so no call site needs to disambiguate anymore. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/JsoniterBench.scala | 1 - .../eo/bench/MultiFocusBench.scala | 1 - .../eo/bench/MultiFocusCollectBench.scala | 1 - .../constructive/eo/bench/OpticsBench.scala | 1 - .../eo/bench/OrderJsoniterBench.scala | 1 - .../eo/bench/PowerSeriesBench.scala | 1 - .../eo/bench/PowerSeriesNestedBench.scala | 1 - .../eo/bench/PowerSeriesPrismBench.scala | 1 - .../eo/bench/fixture/DomainCirce.scala | 1 - .../circe/CrossCarrierCompositionSpec.scala | 5 +- .../eo/circe/NamedTupleCodecSpike.scala | 57 ------------------- .../scala/dev/constructive/eo/Composer.scala | 2 +- .../dev/constructive/eo/ForgetfulFold.scala | 12 +--- .../constructive/eo/ForgetfulTraverse.scala | 11 ++-- .../dev/constructive/eo/data/Affine.scala | 13 ++++- .../dev/constructive/eo/data/MultiFocus.scala | 10 ++-- .../dev/constructive/eo/optics/Fold.scala | 4 +- .../dev/constructive/eo/optics/Plated.scala | 3 +- .../constructive/eo/optics/Traversal.scala | 14 ++--- .../eo/MultiFocusFunction1Spec.scala | 1 - .../dev/constructive/eo/MultiFocusSpec.scala | 1 - .../eo/jsoniter/JsoniterTraversal.scala | 6 +- .../eo/jsoniter/JsoniterPrismWriteSpec.scala | 1 - .../eo/jsoniter/JsoniterTraversalSpec.scala | 1 - .../dev/constructive/eo/laws/GetterLaws.scala | 1 - .../dev/constructive/eo/laws/IsoLaws.scala | 1 - .../constructive/eo/laws/MultiFocusLaws.scala | 1 - .../constructive/eo/laws/OptionalLaws.scala | 1 - .../dev/constructive/eo/laws/PlatedLaws.scala | 1 - .../dev/constructive/eo/laws/SetterLaws.scala | 1 - .../constructive/eo/laws/TraversalLaws.scala | 1 - .../constructive/eo/laws/eo/ComposeLaws.scala | 2 - .../eo/laws/eo/ReverseAndTransformLaws.scala | 1 - .../eo/laws/typeclass/ComposerLaws.scala | 1 - .../constructive/eo/EoSpecificLawsSpec.scala | 4 -- .../constructive/eo/OpticsBehaviorSpec.scala | 29 +++------- .../dev/constructive/eo/OpticsLawsSpec.scala | 21 +++---- .../dev/constructive/eo/PlatedSpec.scala | 1 - .../dev/constructive/eo/PowerSeriesSpec.scala | 1 - .../scala/dev/constructive/eo/Samples.scala | 1 - .../eo/examples/CrudRoundtripSpec.scala | 1 - 41 files changed, 54 insertions(+), 165 deletions(-) delete mode 100644 circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpike.scala diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/JsoniterBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/JsoniterBench.scala index 2108b3b2..91057917 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/JsoniterBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/JsoniterBench.scala @@ -9,7 +9,6 @@ import java.util.concurrent.TimeUnit import com.github.plokhotnyuk.jsoniter_scala.core.writeToArray import dev.constructive.eo.bench.fixture.* -import dev.constructive.eo.data.MultiFocus.given import io.circe.Json import io.circe.parser.parse as circeParse diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusBench.scala index b11884f1..05e97653 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusBench.scala @@ -10,7 +10,6 @@ import cats.instances.list.* import dev.constructive.eo.bench.fixture.* import dev.constructive.eo.data.MultiFocus -import dev.constructive.eo.data.MultiFocus.given /** `MultiFocus[List]` (via `fromLensF`) vs `MultiFocus[PSVec]` (via `Traversal.each[List, _]`) on * the same `Lens[Order, List[LineItem]] → inner → Lens[LineItem, Int]` chain over the canonical diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusCollectBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusCollectBench.scala index 359a348b..8ac47899 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusCollectBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/MultiFocusCollectBench.scala @@ -7,7 +7,6 @@ import java.util.concurrent.TimeUnit import cats.data.{Const, ZipList} import dev.constructive.eo.data.MultiFocus -import dev.constructive.eo.data.MultiFocus.given import dev.constructive.eo.data.MultiFocus.{collectList, collectMap} import dev.constructive.eo.optics.Optic diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/OpticsBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/OpticsBench.scala index 1564eef9..84ab1a4a 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/OpticsBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/OpticsBench.scala @@ -15,7 +15,6 @@ import dev.constructive.eo.optics.{ Traversal => EoTraversal, } import dev.constructive.eo.data.{MultiFocus, PSVec} -import dev.constructive.eo.data.MultiFocus.given import cats.instances.list.given diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/OrderJsoniterBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/OrderJsoniterBench.scala index e47adb29..1c31ab3e 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/OrderJsoniterBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/OrderJsoniterBench.scala @@ -9,7 +9,6 @@ import java.util.concurrent.TimeUnit import com.github.plokhotnyuk.jsoniter_scala.core.{readFromArray, writeToArray} import dev.constructive.eo.bench.fixture.* -import dev.constructive.eo.data.MultiFocus.given /** Canonical-schema jsoniter bench (plan 009, Phase 1). * diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesBench.scala index ada88018..45980b5f 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesBench.scala @@ -10,7 +10,6 @@ import java.util.concurrent.TimeUnit import cats.instances.arraySeq.* import dev.constructive.eo.bench.fixture.{Person, Phone} -import dev.constructive.eo.data.MultiFocus.given import monocle.{Lens => MLens, Traversal => MTraversal} diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesNestedBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesNestedBench.scala index 8baafee0..c59a2eb3 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesNestedBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesNestedBench.scala @@ -11,7 +11,6 @@ import cats.instances.arraySeq.given import cats.instances.list.given import dev.constructive.eo.bench.fixture.{Company, Department, Employee} -import dev.constructive.eo.data.MultiFocus.given import monocle.{Lens => MLens, Traversal => MTraversal} diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesPrismBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesPrismBench.scala index 7387dc1f..9f2919af 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesPrismBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/PowerSeriesPrismBench.scala @@ -10,7 +10,6 @@ import java.util.concurrent.TimeUnit import cats.instances.arraySeq.given import dev.constructive.eo.bench.fixture.Result -import dev.constructive.eo.data.MultiFocus.given import monocle.{Prism => MPrism, Traversal => MTraversal} diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainCirce.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainCirce.scala index 38c53875..1ba5f6ca 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainCirce.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainCirce.scala @@ -3,7 +3,6 @@ package bench package fixture import dev.constructive.eo.circe.{JsonFailure, JsonPrism, codecPrism} -import dev.constructive.eo.data.MultiFocus.given import dev.constructive.eo.optics.{Lens => EoLens, Traversal => EoTraversal} import cats.data.{Chain, Ior} diff --git a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala index 2cfb2880..d21150de 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala @@ -182,8 +182,7 @@ class CrossCarrierCompositionSpec extends Specification: // counts correctly). "(6) JsonPrism → MultiFocus[List]: Composer summons + chain compiles" >> { import cats.instances.list.given // Alternative[List] + Foldable[List] - import dev.constructive.eo.data.{MultiFocus, MultiFocusK} - import dev.constructive.eo.data.MultiFocus.given // mfFunctor + either2multifocus + import dev.constructive.eo.data.MultiFocus import dev.constructive.eo.Composer // Type-level: the Composer summons cleanly under List's Alternative+Foldable. @@ -195,7 +194,7 @@ class CrossCarrierCompositionSpec extends Specification: val tagsMF: Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]] = new Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]]: type X = Unit - def to(xs: List[Int]): MultiFocus[List][X, List[Int]] = MultiFocusK.wrap((), List(xs)) + def to(xs: List[Int]): MultiFocus[List][X, List[Int]] = MultiFocus((), List(xs)) def from(pair: MultiFocus[List][X, List[Int]]): List[Int] = pair.foci.head // The cross-carrier .andThen resolves through `either2multifocus[List]`. diff --git a/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpike.scala b/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpike.scala deleted file mode 100644 index b28e58f6..00000000 --- a/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpike.scala +++ /dev/null @@ -1,57 +0,0 @@ -package dev.constructive.eo.circe - -import hearth.kindlings.circederivation.KindlingsCodecAsObject -import io.circe.syntax.* -import io.circe.{Codec, Json} -import org.specs2.mutable.Specification - -/** Unit 0 spike: confirm that `KindlingsCodecAsObject.derive[NT]` produces a usable - * `Codec.AsObject[NT]` for a Scala 3 NamedTuple, and that `Expr.summon[Encoder[NT]]` resolves it - * when NT is synthesised inside a macro. - * - * If either test fails, the plan halts and OQ1 fallback is adopted per the plan's Unit 0 exit - * condition. - */ -class NamedTupleCodecSpike extends Specification: - - import NamedTupleCodecSpike.given - - // 2026-04-29 consolidation: 4 spike-block tests → 1 composite. - - // covers: KindlingsCodecAsObject.derive for NamedTuple round-trips a hand-declared codec - // (decode of asJson(nt) returns Right(nt)), - // round-trip preserves field presence + value in the resulting JSON object, - // decode failure on a missing field surfaces as Left, - // macro-side Expr.summon resolves Encoder[NT] and Decoder[NT] (the spike's exit-condition - // for OQ1 fallback) - "KindlingsCodecAsObject.derive for NamedTuple: round-trip + field-projection + decode fail + Expr.summon" >> { - val nt: NamedTupleCodecSpike.Pair = (name = "Alice", age = 30) - val j: Json = nt.asJson - val rt = j.as[NamedTupleCodecSpike.Pair] === Right(nt) - - val nt2: NamedTupleCodecSpike.Pair = (name = "Bob", age = 42) - val j2: Json = nt2.asJson - val fieldsOk = (j2.hcursor.downField("name").as[String] === Right("Bob")) - .and(j2.hcursor.downField("age").as[Int] === Right(42)) - - val bad: Json = Json.obj("name" -> Json.fromString("Carol")) - val decFailOk = bad.as[NamedTupleCodecSpike.Pair].isLeft === true - - val (encOk, decOk) = NamedTupleCodecSpikeMacro.summonEncDec - val summonOk = (encOk === true).and(decOk === true) - - rt.and(fieldsOk).and(decFailOk).and(summonOk) - } - -object NamedTupleCodecSpike: - - type Pair = NamedTuple.NamedTuple[("name", "age"), (String, Int)] - - given Codec.AsObject[Pair] = KindlingsCodecAsObject.derive - - // Singleton NamedTuple shape matching the one the spike macro synthesises. - // Use `Tuple1[...]` form because the `(x,)` surface syntax is not - // available inside a type position in 3.8.3. - type Singleton = NamedTuple.NamedTuple[Tuple1["a"], Tuple1[Int]] - - given Codec.AsObject[Singleton] = KindlingsCodecAsObject.derive diff --git a/core/src/main/scala/dev/constructive/eo/Composer.scala b/core/src/main/scala/dev/constructive/eo/Composer.scala index 84ef34d0..5f3157fe 100644 --- a/core/src/main/scala/dev/constructive/eo/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/Composer.scala @@ -74,7 +74,7 @@ object Composer extends LowPriorityComposerInstances: def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, data.Forget[F]] = new Optic[S, T, A, B, data.Forget[F]]: type X = Nothing - def to(s: S): data.Forget[F][X, A] = data.ForgetK(F.pure(o.to(s).value)) + def to(s: S): data.Forget[F][X, A] = data.Forget(F.pure(o.to(s).value)) def from(u: data.Forget[F][X, B]): T = ??? /** Low-priority `Composer` instances — diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala b/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala index 1b30f58b..c22c0ea0 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala +++ b/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala @@ -2,8 +2,6 @@ package dev.constructive.eo import cats.Monoid -import data.Affine - /** `foldMap` over the focus of `F[_, _]`. Miss / absent contributes `Monoid.empty`; hit runs `f` * and folds. * @@ -28,10 +26,6 @@ object ForgetfulFold: def foldMap[X, A, M: Monoid](f: A => M, ea: Either[X, A]): M = ea.fold(_ => Monoid[M].empty, f) - /** `Affine` — miss empty, hit runs `f` on the focus. @group Instances */ - given affineFFold: ForgetfulFold[Affine] with - - def foldMap[X, A, M: Monoid](f: A => M, fa: Affine[X, A]): M = - fa match - case _: Affine.Miss[X, A] => Monoid[M].empty - case h: Affine.Hit[X, A] => f(h.b) + // `ForgetfulFold[Affine]` is NOT here — it is carrier-owned (`Affine.fold`), matching + // Direct / SetterF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this + // companion, since their own companions can't be extended. diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala b/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala index 9a35e93b..b960afcf 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala +++ b/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala @@ -5,8 +5,6 @@ import cats.syntax.either.* import cats.syntax.functor.* import cats.{Applicative, Functor} -import data.Affine - /** Traverse the focus of `F[_, _]` under an effectful `A => G[B]`. Parameterised by the applicative * constraint `C[_[_]]` — `Applicative` for carriers with miss branches, `Functor` for Tuple2, * `Distributive` for SetterF. @@ -47,8 +45,7 @@ object ForgetfulTraverse: a => f(a).map(_.asRight[X]), ) - /** `Affine` — miss via `pure`, hit through `f`. @group Instances */ - given affineFTraverse: ForgetfulTraverse[Affine, Applicative] with - - def traverse[X, A, B, G[_]: Applicative](fa: Affine[X, A], f: A => G[B]): G[Affine[X, B]] = - fa.aTraverse(f) + // `ForgetfulTraverse[Affine]` is NOT here — it is carrier-owned (`Affine.traverse`), matching + // Direct / SetterF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this + // companion, since their own companions can't be extended. A duplicate here would shadow-tie the + // carrier-side instance and force every call site to disambiguate via `import data.Affine.given`. diff --git a/core/src/main/scala/dev/constructive/eo/data/Affine.scala b/core/src/main/scala/dev/constructive/eo/data/Affine.scala index c62a121b..ff7c7414 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -1,7 +1,7 @@ package dev.constructive.eo package data -import cats.Applicative +import cats.{Applicative, Monoid} import optics.Optic @@ -146,6 +146,17 @@ object Affine: case m: Miss[X, A] => new Miss[X, B](m.fst) case h: Hit[X, A] => new Hit[X, B](h.snd, f(h.b)) + /** `ForgetfulFold[Affine]` — miss empty, hit runs `f` on the focus. Carrier-owned (like [[map]] / + * [[traverse]]), so it resolves through Affine's companion with no import. + * + * @group Instances + */ + given fold: ForgetfulFold[Affine] with + + def foldMap[X, A, M: Monoid](f: A => M, fa: Affine[X, A]): M = fa match + case _: Miss[X, A] => Monoid[M].empty + case h: Hit[X, A] => f(h.b) + /** `ForgetfulTraverse[Affine, Applicative]` — lifts a focus-level `A => G[B]` into an * `Affine[X, A] => G[Affine[X, B]]`. Unlocks `.modifyA` / `.all` / `.modifyF` on Affine- carrier * optics. diff --git a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index 8a5bcb76..4a739328 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -132,12 +132,8 @@ private[eo] trait MultiFocusPSMaybeHit[S, T, A, B]: object MultiFocusK: - /** Wrap a leftover + focus vector into the `MultiFocus[F]` carrier. Identity at runtime (the - * opaque type erases to the pair); needed only so construction sites outside this file satisfy - * the `MultiFocus[F][X, A]` type. - */ - transparent inline def wrap[F[_], X, A](x: X, fa: F[A]): MultiFocusK[F, X, A] = (x, fa) - + // Construct via the façade `MultiFocus(x, fa)` (an `apply` on the public object below); these + // are the unwrap boundary. Both are identity at runtime (the opaque erases to the pair). extension [F[_], X, A](self: MultiFocusK[F, X, A]) /** The structural leftover. Identity at runtime. */ transparent inline def context: X = self._1 @@ -822,3 +818,5 @@ object MultiFocusK: */ object MultiFocus: export MultiFocusK.{given, *} + + transparent inline def apply[F[_], X, A](x: X, fa: F[A]): MultiFocusK[F, X, A] = (x, fa) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index bea56c93..3b0d6713 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -3,7 +3,7 @@ package optics import cats.instances.option.given import cats.{Foldable, Monoid} -import dev.constructive.eo.data.{Forget, ForgetK} +import dev.constructive.eo.data.Forget /** Constructors for `Fold` — read-only multi-focus optic, backed by `Forget[F]` (`Forget[F][X, A] = * F[A]`). `T = Unit` rules out the write path; `.foldMap` is the consumption surface. @@ -49,7 +49,7 @@ final class ForgetFold[S, F[_], A]( )(using FF: Foldable[F]) extends Optic[S, Unit, A, Unit, Forget[F]]: type X = Nothing - def to(s: S): Forget[F][X, A] = ForgetK(read(s)) + def to(s: S): Forget[F][X, A] = Forget(read(s)) def from(u: Forget[F][X, Unit]): Unit = () /** Eager `foldMap` — folds `s` straight through the captured `Foldable[F]`, returning `M` with no diff --git a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala index 8b2b6092..06e838ad 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala @@ -4,7 +4,6 @@ package optics import cats.Eval import data.{MultiFocus, PSVec, SetterF} -import data.MultiFocus.given // ForgetfulFunctor / Fold / Traverse for MultiFocus[PSVec] /** A self-similar structure: a value of `S` whose immediate sub-terms are themselves `S`. The * single member [[plate]] is a `Traversal[S, S]` focusing those immediate children — the cats-eo @@ -58,7 +57,7 @@ trait Plated[S]: */ def rebuild(parent: S, children: PSVec[S]): S = val p = plate // bind once so `p.X` is a single stable existential across `to` / `from` - p.from(data.MultiFocusK.wrap(p.to(parent).context, children)) + p.from(MultiFocus(p.to(parent).context, children)) /** Combinators over [[Plated]] — recursion schemes faithful to `Control.Lens.Plated`, all * stack-safe. Each is offered both as a `using Plated[S]` method here and as an extension on any diff --git a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala index 2ede2862..a2d91c67 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala @@ -5,7 +5,7 @@ import scala.collection.immutable.ArraySeq import cats.Traverse -import data.{MultiFocus, MultiFocusK, ObjArrBuilder, PSVec} +import data.{MultiFocus, ObjArrBuilder, PSVec} /** Constructors for `Traversal`. [[each]] / [[pEach]] use the `MultiFocus[PSVec]` carrier; the * [[two]] / [[three]] / [[four]] fixed-arity variants ride `MultiFocus[Function1[Int, *]]` over a @@ -40,14 +40,14 @@ object Traversal: case refArr: ArraySeq.ofRef[?] => // ArraySeq.ofRef.unsafeArray is already an Array[AnyRef]; aliasing is safe because // ArraySeq is immutable and Functor[PSVec].map allocates fresh output. - MultiFocusK.wrap( + MultiFocus( ta, PSVec.unsafeWrap[A](refArr.unsafeArray.asInstanceOf[Array[AnyRef]]), ) case _ => val buf = new ObjArrBuilder() Traverse[T].foldLeft(ta, ())((_, a) => { buf.append(a.asInstanceOf[AnyRef]); () }) - MultiFocusK.wrap(ta, buf.freezeAsPSVec[A]) + MultiFocus(ta, buf.freezeAsPSVec[A]) def from(pair: MultiFocus[PSVec][X, B]): T[B] = val (xo, vec) = (pair.context, pair.foci) @@ -87,7 +87,7 @@ object Traversal: ): Optic[S, S, S, S, MultiFocus[PSVec]] = new Optic[S, S, S, S, MultiFocus[PSVec]]: type X = S - def to(s: S): MultiFocus[PSVec][X, S] = MultiFocusK.wrap(s, children(s)) + def to(s: S): MultiFocus[PSVec][X, S] = MultiFocus(s, children(s)) def from(pair: MultiFocus[PSVec][X, S]): S = rebuild(pair.context, pair.foci) /** Traversal over two per-element getters with a `reverse` reassembly. Carrier is @@ -110,7 +110,7 @@ object Traversal: i match case 0 => a(s) case 1 => b(s) - MultiFocusK.wrap((), read) + MultiFocus((), read) def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = reverse(pair.foci(0), pair.foci(1)) @@ -130,7 +130,7 @@ object Traversal: case 0 => a(s) case 1 => b(s) case 2 => c(s) - MultiFocusK.wrap((), read) + MultiFocus((), read) def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = reverse(pair.foci(0), pair.foci(1), pair.foci(2)) @@ -153,7 +153,7 @@ object Traversal: case 1 => b(s) case 2 => c(s) case 3 => d(s) - MultiFocusK.wrap((), read) + MultiFocus((), read) def from(pair: MultiFocus[Function1[Int, *]][X, B]): T = reverse(pair.foci(0), pair.foci(1), pair.foci(2), pair.foci(3)) diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala index 14f01f25..8a2100f4 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala @@ -9,7 +9,6 @@ import org.specs2.ScalaCheck import org.specs2.mutable.Specification import data.MultiFocus -import data.MultiFocus.given import data.MultiFocus.at import optics.Optic import optics.Optic.* diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala index 2630cf85..9026237b 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala @@ -11,7 +11,6 @@ import org.specs2.ScalaCheck import org.specs2.mutable.Specification import data.{Direct, MultiFocus, SetterF} -import data.MultiFocus.given import data.MultiFocus.{collectList, collectMap} import optics.* import optics.Optic.* diff --git a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala index 85d84bb8..7ea3b211 100644 --- a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala +++ b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.jsoniter import com.github.plokhotnyuk.jsoniter_scala.core.{readFromSubArray, JsonValueCodec} -import dev.constructive.eo.data.{MultiFocus, MultiFocusK, PSVec} +import dev.constructive.eo.data.{MultiFocus, PSVec} import dev.constructive.eo.optics.Optic /** Read-only Traversal over JSON byte buffers. Resolves a wildcard-bearing JSONPath against @@ -70,7 +70,7 @@ object JsoniterTraversal: def to(bytes: Array[Byte]): MultiFocus[PSVec][X, A] = val spans = JsonPathScanner.findAll(bytes, steps) val n = spans.length - if n == 0 then MultiFocusK.wrap((bytes, spans), PSVec.empty[A]) + if n == 0 then MultiFocus((bytes, spans), PSVec.empty[A]) else // Single Array[AnyRef] allocation; tight-pack only on partial decode failure. val arr = new Array[AnyRef](n) @@ -89,6 +89,6 @@ object JsoniterTraversal: val tight = new Array[AnyRef](written) System.arraycopy(arr, 0, tight, 0, written) PSVec.unsafeWrap[A](tight) - MultiFocusK.wrap((bytes, spans), psv) + MultiFocus((bytes, spans), psv) def from(pair: MultiFocus[PSVec][X, A]): Array[Byte] = pair.context._1 diff --git a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterPrismWriteSpec.scala b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterPrismWriteSpec.scala index 4abbbecc..b93a4f91 100644 --- a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterPrismWriteSpec.scala +++ b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterPrismWriteSpec.scala @@ -5,7 +5,6 @@ import scala.language.implicitConversions import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker import dev.constructive.eo.data.Affine -import dev.constructive.eo.data.Affine.given import dev.constructive.eo.optics.Optic import dev.constructive.eo.optics.Optic.* import org.specs2.mutable.Specification diff --git a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterTraversalSpec.scala b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterTraversalSpec.scala index 27dffef9..ef96cc6e 100644 --- a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterTraversalSpec.scala +++ b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsoniterTraversalSpec.scala @@ -4,7 +4,6 @@ import scala.language.implicitConversions import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker -import dev.constructive.eo.data.MultiFocus.given import dev.constructive.eo.data.{MultiFocus, PSVec} import dev.constructive.eo.optics.Optic import dev.constructive.eo.optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala index 3c44f899..bfd9aced 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala @@ -2,7 +2,6 @@ package dev.constructive.eo package laws import _root_.dev.constructive.eo.data.Direct -import _root_.dev.constructive.eo.data.Direct.given import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/IsoLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/IsoLaws.scala index 335fccca..70d92b31 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/IsoLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/IsoLaws.scala @@ -2,7 +2,6 @@ package dev.constructive.eo package laws import _root_.dev.constructive.eo.data.Direct -import _root_.dev.constructive.eo.data.Direct.given import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/MultiFocusLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/MultiFocusLaws.scala index 3f0e7238..dd4309aa 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/MultiFocusLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/MultiFocusLaws.scala @@ -3,7 +3,6 @@ package laws import _root_.dev.constructive.eo.data.MultiFocus import _root_.dev.constructive.eo.data.MultiFocus.collectMap -import _root_.dev.constructive.eo.data.MultiFocus.given import cats.Functor import optics.Optic diff --git a/laws/src/main/scala/dev/constructive/eo/laws/OptionalLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/OptionalLaws.scala index c78a2891..31d29f8b 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/OptionalLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/OptionalLaws.scala @@ -2,7 +2,6 @@ package dev.constructive.eo package laws import _root_.dev.constructive.eo.data.Affine -import _root_.dev.constructive.eo.data.Affine.given import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/PlatedLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/PlatedLaws.scala index c13fbe7b..e00183e4 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/PlatedLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/PlatedLaws.scala @@ -1,7 +1,6 @@ package dev.constructive.eo package laws -import _root_.dev.constructive.eo.data.MultiFocus.given import _root_.dev.constructive.eo.optics.Plated import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala index 5b22916b..81b2a578 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala @@ -2,7 +2,6 @@ package dev.constructive.eo package laws import _root_.dev.constructive.eo.data.SetterF -import _root_.dev.constructive.eo.data.SetterF.given import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/TraversalLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/TraversalLaws.scala index cc4c5529..256f9ab6 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/TraversalLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/TraversalLaws.scala @@ -1,7 +1,6 @@ package dev.constructive.eo package laws -import _root_.dev.constructive.eo.data.MultiFocus.given import _root_.dev.constructive.eo.data.{MultiFocus, PSVec} import cats.Functor diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala index 5d1a5515..fbbe0f9e 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala @@ -2,8 +2,6 @@ package dev.constructive.eo package laws package eo -import _root_.dev.constructive.eo.data.Affine.given -import _root_.dev.constructive.eo.data.Direct.given import _root_.dev.constructive.eo.data.{Affine, Direct} import optics.Optic diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/ReverseAndTransformLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/ReverseAndTransformLaws.scala index b30f53a3..52ab6fd0 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/ReverseAndTransformLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/ReverseAndTransformLaws.scala @@ -3,7 +3,6 @@ package laws package eo import _root_.dev.constructive.eo.data.Direct -import _root_.dev.constructive.eo.data.Direct.given import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala index 08f34e91..007c320c 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala @@ -2,7 +2,6 @@ package dev.constructive.eo package laws package typeclass -import _root_.dev.constructive.eo.data.Affine.given import _root_.dev.constructive.eo.data.{Affine, Direct} import optics.Optic diff --git a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala index 70275d41..0fc796f5 100644 --- a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala @@ -8,10 +8,6 @@ import org.specs2.mutable.Specification import optics.{Iso, Lens, Optic, Optional, Prism, Traversal} import optics.Optic.* import data.{Affine, Direct, MultiFocus, PSVec, SetterF} -import data.Direct.given -import data.Affine.given -import data.MultiFocus.given -import data.SetterF.given import laws.eo.{ IsoComposeLaws, LensComposeLaws, diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index 47060514..286149b1 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -26,22 +26,7 @@ import optics.{ Traversal, } import optics.Optic.* -import data.{ - Affine, - Forget, - ForgetK, - Direct, - MultiFocus, - MultiFocusK, - MultiFocusSingleton, - PSVec, - SetterF, -} -import data.Direct.given -import data.Forget.given -import data.Affine.given -import data.MultiFocus.given -import data.SetterF.given +import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, SetterF} /** Non-law behavioural coverage for EO's optics: exercises the extension methods (`andThen`, * `reverse`, `foldMap`, `modifyA`, `morph`), the Lens/Prism/Traversal alternative constructors, @@ -77,7 +62,7 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: ): Optic[Int, Int, Int, Int, Forget[F]] = new Optic[Int, Int, Int, Int, Forget[F]]: type X = Unit - def to(s: Int): Forget[F][X, Int] = ForgetK(toF(s)) + def to(s: Int): Forget[F][X, Int] = Forget(toF(s)) def from(b: Forget[F][X, Int]): Int = fromF(b.value) // ----- Basic-factory smoke tests ------------------------------------- @@ -474,8 +459,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val sameOk = (sameCarrier.to(5).value === Some(51)) .and(sameCarrier.to(-2).value === None) .and(sameCarrier.to(200).value === None) - .and(sameCarrier.from(ForgetK(Some(7))) === 1016) - .and(sameCarrier.from(ForgetK(None)) === 998) + .and(sameCarrier.from(Forget(Some(7))) === 1016) + .and(sameCarrier.from(Forget(None)) === 998) val pureForget = forgetOpt[Option]( n => Option.when(n > 0)(n * 10), @@ -488,7 +473,7 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: new Optic[Int, Int, Int, Int, MultiFocus[Option]]: type X = String def to(n: Int): MultiFocus[Option][X, Int] = - MultiFocusK.wrap(s"tag-$n", Option.when(n < 1000)(n + 1)) + MultiFocus(s"tag-$n", Option.when(n < 1000)(n + 1)) def from(pair: MultiFocus[Option][X, Int]): Int = pair.foci.fold(pair.context.length)(_ * 100 + pair.context.length) val composed = lifted.andThen(inner2) @@ -723,13 +708,13 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val triplet: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - def to(n: Int): Forget[List][Unit, Int] = ForgetK(List(n - 1, n, n + 1)) + def to(n: Int): Forget[List][Unit, Int] = Forget(List(n - 1, n, n + 1)) def from(u: Forget[List][Unit, Unit]): Unit = () val pair: Optic[Int, Unit, Int, Unit, Forget[List]] = new Optic[Int, Unit, Int, Unit, Forget[List]]: type X = Unit - def to(n: Int): Forget[List][Unit, Int] = ForgetK(List(n, n + 1)) + def to(n: Int): Forget[List][Unit, Int] = Forget(List(n, n + 1)) def from(u: Forget[List][Unit, Unit]): Unit = () val composed = triplet.andThen(pair) diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index b20511a9..546918b4 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -6,11 +6,7 @@ import org.scalacheck.{Arbitrary, Cogen, Gen} import org.specs2.mutable.Specification import optics.{AffineFold, Fold, Getter, Iso, Lens, Optic, Optional, Prism, Setter, Traversal} -import data.{Affine, Forget, ForgetK, Direct, MultiFocus, MultiFocusK, PSVec, SetterF} -import data.Affine.given -import data.Forget.given -import data.MultiFocus.given -import data.SetterF.given +import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} import laws.{AffineFoldLaws, GetterLaws, IsoLaws, LensLaws, OptionalLaws, PrismLaws, SetterLaws} import laws.discipline.{ AffineFoldTests, @@ -268,7 +264,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: arr(0) = a0.asInstanceOf[AnyRef] arr(1) = a1.asInstanceOf[AnyRef] arr(2) = a2.asInstanceOf[AnyRef] - MultiFocusK.wrap((x, x), PSVec.unsafeWrap[Int](arr)) + MultiFocus((x, x), PSVec.unsafeWrap[Int](arr)) ) // ----- MultiFocus[Function1[Int, *]] carrier laws ---------------- @@ -361,7 +357,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: // exercises Direct.scala's traverse2 and bifunctor paths that pure Direct // fixtures don't land. private given arbForgetListUnitInt: Arbitrary[Forget[List][Unit, Int]] = - Arbitrary(Arbitrary.arbitrary[List[Int]].map(ForgetK(_))) + Arbitrary(Arbitrary.arbitrary[List[Int]].map(Forget(_))) checkAllForgetfulFunctorFor[Forget[List], Unit, Int]( "ForgetfulFunctor[Forget[List]] on Forget[List][Unit, Int]" @@ -387,7 +383,6 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: // classifier shapes. Custom Arbitraries below mix empty / singleton / multi-element // payloads so `ForgetfulTraverse`'s sequencing law sees non-trivial cardinalities (the // Scalacheck defaults skew to short lists and under-sample the empty edge). - import data.MultiFocus.given import cats.data.Chain // Weighted generator aimed at the cardinality edges most likely to break carrier-level laws: @@ -407,7 +402,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: private given arbMultiFocusListIntInt: Arbitrary[MultiFocus[List][Int, Int]] = Arbitrary( - Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map(MultiFocusK.wrap(x, _))) + Arbitrary.arbitrary[Int].flatMap(x => weightedListOf[Int].map(MultiFocus(x, _))) ) private given arbMultiFocusOptionIntInt: Arbitrary[MultiFocus[Option][Int, Int]] = @@ -418,21 +413,21 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: Gen.const(Option.empty[Int]), Arbitrary.arbitrary[Int].map(Option(_)), ) - yield MultiFocusK.wrap(x, o) + yield MultiFocus(x, o) ) private given arbMultiFocusVectorIntInt: Arbitrary[MultiFocus[Vector][Int, Int]] = Arbitrary( Arbitrary .arbitrary[Int] - .flatMap(x => weightedListOf[Int].map(xs => MultiFocusK.wrap(x, xs.toVector))) + .flatMap(x => weightedListOf[Int].map(xs => MultiFocus(x, xs.toVector))) ) private given arbMultiFocusChainIntInt: Arbitrary[MultiFocus[Chain][Int, Int]] = Arbitrary( Arbitrary .arbitrary[Int] - .flatMap(x => weightedListOf[Int].map(xs => MultiFocusK.wrap(x, Chain.fromSeq(xs)))) + .flatMap(x => weightedListOf[Int].map(xs => MultiFocus(x, Chain.fromSeq(xs)))) ) // covers: ForgetfulFunctor + ForgetfulTraverse over MultiFocus[List] @@ -485,7 +480,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: forAll { (a: Int, f: Int => Int, n: Int) => val putOk = intDouble.put(f)(a) == intDouble.reverseGet(f(a)) val pureOk = ap.pure[Unit, Int](n).value == List(n) - val mapOk = ap.map[Unit, Int, Int](ForgetK(List(n)), _ + 1).value == List(n + 1) + val mapOk = ap.map[Unit, Int, Int](Forget(List(n)), _ + 1).value == List(n + 1) putOk && pureOk && mapOk } } diff --git a/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala b/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala index 306660be..0cb75d8b 100644 --- a/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala @@ -1,6 +1,5 @@ package dev.constructive.eo -import dev.constructive.eo.data.MultiFocus.given import dev.constructive.eo.generics.plate import dev.constructive.eo.optics.Optic.* // .andThen / .modify diff --git a/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala b/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala index 1ffabf28..567b38a2 100644 --- a/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/PowerSeriesSpec.scala @@ -12,7 +12,6 @@ import org.specs2.mutable.Specification import optics.{Lens, Optic, Traversal} import optics.Optic.* import data.{MultiFocus, PSVec} -import data.MultiFocus.given /** Behaviour-level spec for `PowerSeries`, `Traversal.each`, and the * `Composer[Tuple2 → PowerSeries]` bridge. diff --git a/tests/src/test/scala/dev/constructive/eo/Samples.scala b/tests/src/test/scala/dev/constructive/eo/Samples.scala index dcebaa0e..e17c3aaf 100644 --- a/tests/src/test/scala/dev/constructive/eo/Samples.scala +++ b/tests/src/test/scala/dev/constructive/eo/Samples.scala @@ -11,7 +11,6 @@ case class UP(a: Int, b: Boolean) @main def samples = import Optic.* - import MultiFocus.given val aLens = Lens[UP, Int](_.a, (s, b) => s.copy(a = b)) val bLens = Lens.second[Int, Boolean] diff --git a/tests/src/test/scala/dev/constructive/eo/examples/CrudRoundtripSpec.scala b/tests/src/test/scala/dev/constructive/eo/examples/CrudRoundtripSpec.scala index 21429cf9..4f2cbb19 100644 --- a/tests/src/test/scala/dev/constructive/eo/examples/CrudRoundtripSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/examples/CrudRoundtripSpec.scala @@ -18,7 +18,6 @@ import optics.{Optic, Traversal} import optics.Optic.* import generics.lens import data.{MultiFocus, PSVec} -import data.MultiFocus.given /** Motivating example for cats-eo — the "why this library exists" showcase. * From b5687724c371b4d76d7b6ace98d4f4bb988d59ff Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 23:04:25 +0200 Subject: [PATCH 10/14] chore(circe): remove orphaned NamedTupleCodecSpikeMacro Its only consumer, NamedTupleCodecSpike.scala, was deleted in 100b706; the macro object referenced it solely in its own docstring. Dead spike code. Co-Authored-By: Claude Fable 5 --- .../eo/circe/NamedTupleCodecSpikeMacro.scala | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpikeMacro.scala diff --git a/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpikeMacro.scala b/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpikeMacro.scala deleted file mode 100644 index b3ba7460..00000000 --- a/circe/src/test/scala/dev/constructive/eo/circe/NamedTupleCodecSpikeMacro.scala +++ /dev/null @@ -1,36 +0,0 @@ -package dev.constructive.eo.circe - -import scala.quoted.* - -import io.circe.{Decoder, Encoder} - -/** Companion macro for `NamedTupleCodecSpike`. Lives in a sibling file because Scala 3 forbids - * calling a macro in the same compilation unit that defines its impl. - * - * Synthesises a NamedTuple type via `TypeRepr.of[*:].appliedTo(...)` (the same mechanism the real - * `.fields` macro will use in Unit 3), then runs `Expr.summon[Encoder[nt]]` / - * `Expr.summon[Decoder[nt]]` on the synthesised type. Returns a pair of booleans indicating - * whether each summon succeeded — the call-site must have `given Codec.AsObject[]` in - * scope for the summons to resolve. - */ -object NamedTupleCodecSpikeMacro: - - inline def summonEncDec: (Boolean, Boolean) = ${ summonEncDecImpl } - - def summonEncDecImpl(using q: Quotes): Expr[(Boolean, Boolean)] = - import quotes.reflect.* - - val namesTpe: TypeRepr = - TypeRepr.of[*:].appliedTo(List(ConstantType(StringConstant("a")), TypeRepr.of[EmptyTuple])) - val valuesTpe: TypeRepr = - TypeRepr.of[*:].appliedTo(List(TypeRepr.of[Int], TypeRepr.of[EmptyTuple])) - val ntTpe: TypeRepr = - TypeRepr.of[scala.NamedTuple.NamedTuple].appliedTo(List(namesTpe, valuesTpe)) - - ntTpe.asType match - case '[nt] => - val enc = Expr.summon[Encoder[nt]] - val dec = Expr.summon[Decoder[nt]] - val encOk = Expr(enc.isDefined) - val decOk = Expr(dec.isDefined) - '{ ($encOk, $decOk) } From 7ff9ae32627ce7dabf86b821c27c6920e68390c4 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 23:07:02 +0200 Subject: [PATCH 11/14] chore: gitignore .context/ (compound-engineering tooling scratch) ce-review summaries and similar agent-tooling output land under .context/; they are per-run scratch, not project sources. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9a1f5214..049df3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Skills are checked-in agent guidance — track them across the .claude/ # blanket ignore for per-user worktree / scheduling state. !.claude/skills/ +# Compound-engineering tooling scratch (ce-review summaries, etc.) +.context/ .envrc .envrc.local .idea/ From 80d3c35e941d651e070e98441e2568216733a378 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 23:15:37 +0200 Subject: [PATCH 12/14] ci(benchmarks): add optional -prof input (e.g. gc for allocation B/op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blank by default, so push / default dispatches are unchanged. Lets a manual run request `-prof gc` to capture allocation B/op — the metric that survives a noisy shared runner — without the filter-injection trick. Co-Authored-By: Claude Fable 5 --- .github/workflows/benchmarks.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1d1b62f9..b9fbdf65 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -30,6 +30,10 @@ on: description: "Warmup iterations (-wi)" required: false default: "3" + prof: + description: "JMH profiler (-prof), e.g. 'gc' for allocation B/op; blank = none" + required: false + default: "" permissions: contents: read @@ -65,9 +69,12 @@ jobs: # NB: under sbt, JMH's working dir is the `benchmarks/` subproject, so # `-rff jmh-results.json` writes to `benchmarks/jmh-results.json` at the # repo level — which the summary / upload steps below read. + # `inputs.prof` adds a JMH profiler (e.g. `-prof gc` for allocation + # B/op — the metric that survives a noisy shared runner). Blank emits + # nothing, so the default / push path is unchanged. run: | sbt -batch -Dsbt.color=false \ - "benchmarks/Jmh/run -i ${{ inputs.iterations || '5' }} -wi ${{ inputs.warmups || '3' }} -f ${{ inputs.forks || '3' }} -t 1 -foe true -rf json -rff jmh-results.json ${{ inputs.filter || '(OrderCirceBench|OrderJsoniterBench|OrderAvroBench).*' }}" + "benchmarks/Jmh/run -i ${{ inputs.iterations || '5' }} -wi ${{ inputs.warmups || '3' }} -f ${{ inputs.forks || '3' }} -t 1 -foe true ${{ inputs.prof != '' && format('-prof {0}', inputs.prof) || '' }} -rf json -rff jmh-results.json ${{ inputs.filter || '(OrderCirceBench|OrderJsoniterBench|OrderAvroBench).*' }}" - name: Render summary if: always() From cf26169c95e15c4895f21eb21eb77fbc5ac2c0af Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 10 Jun 2026 23:38:11 +0200 Subject: [PATCH 13/14] refactor(core): drop read-collapse andThen members now subsumed by ReadCompose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ReadCompose typeclass + the trait-level any∘read-only andThen overload form a correct generic for the whole read-collapse triangle, demoting the bespoke read-collapse members from load-bearing-for-compilation to perf fast-paths over a correct generic. Removed the redundant ones: - Tier A (generic, strictly dominated by the trait member): the two Optic.* extension `andThen(Getter)` collapses (Accessor -> Getter, PartialAccessor -> PickFold). A member always beats an extension, so these were already shadowed; they merged "two competing generic read-collapse paths" into the single ReadCompose path. - Tier B (single-seam terminal bespoke read-collapses): Prism∘Getter, PickMendPrism∘Getter, Optional∘Getter. - Tier C (the hot-path fast-paths added with ReadCompose): Iso∘Getter, Lens∘Getter, Getter∘PickFold, AffineFold∘Getter. All correctness-neutral: CompositionMatrixSpec stays 100/100 (ReadCompose returns the identical concrete type for every removed cell), full suite green, benchmarks compile. The KEPT fusions are the ones the generic cannot replace: same-family same-carrier (dodge the AssociativeFunctor round-trip + C2's recursive-inline cap on deep chains) and cross-family structure-preserving (writable concrete results; Morph would erase the type). The read-collapse result is single-seam terminal (B=Unit, can't extend the write side), so the removed members were never a deep-chain spine — the only cost is the fixed, depth-independent ReadCompose dispatch tax, to be characterised on CI bench. Co-Authored-By: Claude Fable 5 --- .../scala/dev/constructive/eo/optics/AffineFold.scala | 6 ------ .../main/scala/dev/constructive/eo/optics/Getter.scala | 7 ------- core/src/main/scala/dev/constructive/eo/optics/Iso.scala | 8 -------- .../src/main/scala/dev/constructive/eo/optics/Lens.scala | 8 -------- .../main/scala/dev/constructive/eo/optics/Optic.scala | 9 +-------- .../main/scala/dev/constructive/eo/optics/Optional.scala | 3 --- .../main/scala/dev/constructive/eo/optics/Prism.scala | 6 ------ 7 files changed, 1 insertion(+), 46 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 36236fb3..61131aec 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala @@ -45,12 +45,6 @@ final class PickFold[S, A](val pick: S => Option[A]) extends Optic[S, Unit, A, U inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = new PickFold(s => pick(s).flatMap(inner.pick)) - /** Fused `AffineFold.andThen(Getter)` — `map`s the read over the pick; the concrete fast path of - * the generic `andThenAffineFold` collapse. - */ - inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = - new PickFold(s => pick(s).map(inner.get)) - /** Read-only outer ∘ ANY inner — a `PickFold` only reads (partially), so the inner's write side * is irrelevant and the composite is the read-only join via [[ReadCompose]] (`PickFold` unless * the inner has many foci, then `ForgetFold`). Covers the writable inners (`affineFold ∘ lens / diff --git a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala index c8c43523..703700ca 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala @@ -30,13 +30,6 @@ final class Getter[S, A](val get: S => A) extends Optic[S, Unit, A, Unit, Direct inline def andThen[B](inner: Getter[A, B]): Getter[S, B] = new Getter(s => inner.get(get(s))) - /** Fused `Getter.andThen(AffineFold)` — total read then partial read = partial. The concrete - * read-only-triple join cell; being the most specific overload, it resolves the otherwise - * ambiguous `getter ∘ affinefold` (broad-member vs inherited trait-member) outright. - */ - inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = - new PickFold(s => inner.pick(get(s))) - /** Read-only outer ∘ ANY inner — a `Getter` only reads, so the inner's write side is irrelevant * and the composite is the read-only join via [[ReadCompose]] (`Getter` when the inner reads * totally, `PickFold` when partially, `ForgetFold` when it has many foci). This member covers diff --git a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala index f49c5c86..ed299874 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Iso.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Iso.scala @@ -73,14 +73,6 @@ final class BijectionIso[S, T, A, B]( mend = d => reverseGet(inner.mend(d)), ) - /** Fused `Iso.andThen(Getter)` — read collapse on the hot path: composes the `get`s directly into - * a concrete [[Getter]], skipping the `ReadCompose` dispatch the trait's read-only-inner - * overload performs. `inline` so each compose site splices a distinct lambda (see - * [[Getter.andThen]]). - */ - inline def andThen[C](inner: Getter[A, C]): Getter[S, C] = - Getter(s => inner.get(get(s))) - /** Fused `Iso.andThen(Optional)` — iso is transparent; result is `Optional`. */ def andThen[C, D](inner: Optional[A, B, C, D]): Optional[S, T, C, D] = new Optional( diff --git a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala index d4934145..20e55fbd 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala @@ -136,14 +136,6 @@ class GetReplaceLens[S, T, A, B]( reverseGet = (s, d) => enplace(s, ev(inner.mend(d))), ) - /** Fused `Lens.andThen(Getter)` — read collapse on the hot path: composes the `get`s directly - * into a concrete [[Getter]], skipping the `ReadCompose` dispatch the trait's read-only-inner - * overload performs. `inline` so each compose site splices a distinct lambda (see - * [[Getter.andThen]]). - */ - inline def andThen[C](inner: Getter[A, C]): Getter[S, C] = - Getter(s => inner.get(get(s))) - /** Fused `Lens.andThen(Optional)` — outer always hits, inner may miss. Result is `Optional`. */ def andThen[C, D](inner: Optional[A, B, C, D]): Optional[S, T, C, D] = new Optional( diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index 811edaff..7f467cf0 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -193,19 +193,12 @@ object Optic: )(using accessor: Accessor[F]) inline def get(s: S): A = accessor.get(self.to(s)) - inline def andThen[C](o: Getter[A, C]): Getter[S, C] = - Getter(s => o.get(get(s))) - inline def readOnly: Getter[S, A] = Getter(get) extension [S, T, A, B, F[_, _]]( self: Optic[S, T, A, B, F] - )(using A: PartialAccessor[F]) - inline def getOption(s: S): Option[A] = A.getOption(self.to(s)) - - inline def andThen[C](o: Getter[A, C]): PickFold[S, C] = - PickFold(s => getOption(s).map(o.get)) + )(using A: PartialAccessor[F]) inline def getOption(s: S): Option[A] = A.getOption(self.to(s)) /** Build the "no context" reverse — takes a fresh `B` and produces the corresponding `T`. * Available when the carrier has a `ReverseAccessor[F]` instance (today: `Either` and `Direct`). diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala index 5e8dbe7b..f18c4218 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optional.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optional.scala @@ -110,9 +110,6 @@ final class Optional[S, T, A, B]( innerWrite = (a, d) => inner.reverseGet(a, d), ) - inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = - PickFold(s => getOrModify(s).toOption.map(inner.get)) - /** Fused `Optional.andThen(Lens)` — always-present inner inside a partial outer. Stays partial. */ def andThen[C, D](inner: GetReplaceLens[A, B, C, D]): Optional[S, T, C, D] = diff --git a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala index 93cf2d44..85645edd 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala @@ -178,9 +178,6 @@ final class MendTearPrism[S, T, A, B]( innerWrite = (a, d) => inner.reverseGet(a, d), ) - inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = - PickFold(s => getOption(s).map(inner.get)) - /** Concrete Optic subclass for the `Option`-shaped Prism (`Prism.optional` / `Prism.pOptional`). * Stores `pick` and `mend` directly; the fused extensions pattern-match on `Option` so the hot * path never builds the intermediate `Either[S, A]` the generic `MendTearPrism` would. @@ -249,6 +246,3 @@ final class PickMendPrism[S, A, B]( pick = s => pick(s).map(inner.get), mend = d => mend(inner.reverseGet(d)), ) - - inline def andThen[C](inner: Getter[A, C]): PickFold[S, C] = - PickFold(s => pick(s).map(inner.get)) From 2ae615ec459ce55426f6e1b3c8348cb656ee9080 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 00:31:37 +0200 Subject: [PATCH 14/14] refactor(core): split carrier/composition typeclasses into accessor, forgetful, compose packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 11 typeclasses that lived directly in `dev.constructive.eo` (and the accessor trio that was awkwardly in `eo.data`) now sit in three packages by responsibility: - eo.accessor — Accessor, PartialAccessor, ReverseAccessor (focus extract / inject primitives) - eo.forgetful — ForgetfulFunctor, ForgetfulApplicative, ForgetfulFold, ForgetfulTraverse (cats-lifted focus operations) - eo.compose — AssociativeFunctor, Composer, Morph, ReadCompose (optic-combination machinery; all import Optic) The accessor/forgetful boundary is the read-primitive vs cats-operation split; the compose boundary is objective — those four are exactly the typeclasses that depend on Optic. Choice B of the three layouts considered. These were visible to optics/ and data/ for free via enclosing-package scope and to downstream modules via `import dev.constructive.eo.X`; the move makes every use site import explicitly. No behaviour change — pure namespacing. Full suite green, CompositionMatrixSpec still 100/100, benchmarks compile. Doc snippets (optics.md, multifocus.md) repointed too. Co-Authored-By: Claude Fable 5 --- .../eo/circe/CrossCarrierCompositionSpec.scala | 2 +- .../dev/constructive/eo/{ => accessor}/Accessor.scala | 2 +- .../constructive/eo/{ => accessor}/PartialAccessor.scala | 4 +++- .../constructive/eo/{ => accessor}/ReverseAccessor.scala | 2 +- .../eo/{ => compose}/AssociativeFunctor.scala | 1 + .../dev/constructive/eo/{ => compose}/Composer.scala | 1 + .../scala/dev/constructive/eo/{ => compose}/Morph.scala | 1 + .../dev/constructive/eo/{ => compose}/ReadCompose.scala | 4 +++- core/src/main/scala/dev/constructive/eo/data/Affine.scala | 2 ++ core/src/main/scala/dev/constructive/eo/data/Direct.scala | 3 +++ core/src/main/scala/dev/constructive/eo/data/Forget.scala | 2 ++ .../main/scala/dev/constructive/eo/data/MultiFocus.scala | 2 ++ .../src/main/scala/dev/constructive/eo/data/SetterF.scala | 3 +++ .../eo/{ => forgetful}/ForgetfulApplicative.scala | 1 + .../constructive/eo/{ => forgetful}/ForgetfulFold.scala | 1 + .../eo/{ => forgetful}/ForgetfulFunctor.scala | 1 + .../eo/{ => forgetful}/ForgetfulTraverse.scala | 1 + .../scala/dev/constructive/eo/optics/AffineFold.scala | 1 + core/src/main/scala/dev/constructive/eo/optics/Fold.scala | 2 ++ .../main/scala/dev/constructive/eo/optics/Getter.scala | 1 + .../src/main/scala/dev/constructive/eo/optics/Optic.scala | 4 +++- .../main/scala/dev/constructive/eo/optics/Review.scala | 3 ++- .../dev/constructive/eo/MultiFocusFunction1Spec.scala | 1 + .../test/scala/dev/constructive/eo/MultiFocusSpec.scala | 1 + .../main/scala/dev/constructive/eo/laws/FoldLaws.scala | 1 + .../scala/dev/constructive/eo/laws/data/AffineLaws.scala | 2 +- .../scala/dev/constructive/eo/laws/data/SetterFLaws.scala | 2 +- .../eo/laws/data/discipline/AffineTests.scala | 2 +- .../eo/laws/data/discipline/SetterFTests.scala | 2 +- .../dev/constructive/eo/laws/discipline/FoldTests.scala | 1 + .../scala/dev/constructive/eo/laws/eo/ComposeLaws.scala | 1 + .../dev/constructive/eo/laws/eo/FoldAndTraverseLaws.scala | 2 ++ .../scala/dev/constructive/eo/laws/eo/ModifyALaws.scala | 1 + .../scala/dev/constructive/eo/laws/eo/MorphLaws.scala | 7 +++++-- .../eo/laws/eo/discipline/FoldAndTraverseTests.scala | 1 + .../constructive/eo/laws/eo/discipline/ModifyATests.scala | 1 + .../constructive/eo/laws/eo/discipline/MorphTests.scala | 6 ++++-- .../eo/laws/typeclass/AssociativeFunctorLaws.scala | 2 ++ .../dev/constructive/eo/laws/typeclass/ComposerLaws.scala | 6 ++++-- .../eo/laws/typeclass/ForgetfulFunctorLaws.scala | 2 +- .../eo/laws/typeclass/ForgetfulTraverseLaws.scala | 2 +- .../laws/typeclass/discipline/ForgetfulFunctorTests.scala | 2 +- .../typeclass/discipline/ForgetfulTraverseTests.scala | 2 +- site/docs/multifocus.md | 2 +- site/docs/optics.md | 2 +- .../test/scala/dev/constructive/eo/CheckAllHelpers.scala | 6 ++++-- .../scala/dev/constructive/eo/EoSpecificLawsSpec.scala | 8 +++++--- tests/src/test/scala/dev/constructive/eo/JsonOptic.scala | 1 + .../scala/dev/constructive/eo/OpticsBehaviorSpec.scala | 2 ++ .../test/scala/dev/constructive/eo/OpticsLawsSpec.scala | 5 ++++- 50 files changed, 88 insertions(+), 29 deletions(-) rename core/src/main/scala/dev/constructive/eo/{ => accessor}/Accessor.scala (96%) rename core/src/main/scala/dev/constructive/eo/{ => accessor}/PartialAccessor.scala (91%) rename core/src/main/scala/dev/constructive/eo/{ => accessor}/ReverseAccessor.scala (96%) rename core/src/main/scala/dev/constructive/eo/{ => compose}/AssociativeFunctor.scala (99%) rename core/src/main/scala/dev/constructive/eo/{ => compose}/Composer.scala (99%) rename core/src/main/scala/dev/constructive/eo/{ => compose}/Morph.scala (99%) rename core/src/main/scala/dev/constructive/eo/{ => compose}/ReadCompose.scala (98%) rename core/src/main/scala/dev/constructive/eo/{ => forgetful}/ForgetfulApplicative.scala (95%) rename core/src/main/scala/dev/constructive/eo/{ => forgetful}/ForgetfulFold.scala (98%) rename core/src/main/scala/dev/constructive/eo/{ => forgetful}/ForgetfulFunctor.scala (98%) rename core/src/main/scala/dev/constructive/eo/{ => forgetful}/ForgetfulTraverse.scala (99%) diff --git a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala index d21150de..5ca58fbd 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/CrossCarrierCompositionSpec.scala @@ -183,7 +183,7 @@ class CrossCarrierCompositionSpec extends Specification: "(6) JsonPrism → MultiFocus[List]: Composer summons + chain compiles" >> { import cats.instances.list.given // Alternative[List] + Foldable[List] import dev.constructive.eo.data.MultiFocus - import dev.constructive.eo.Composer + import dev.constructive.eo.compose.Composer // Type-level: the Composer summons cleanly under List's Alternative+Foldable. val composerOk = summon[Composer[Either, MultiFocus[List]]] != null diff --git a/core/src/main/scala/dev/constructive/eo/Accessor.scala b/core/src/main/scala/dev/constructive/eo/accessor/Accessor.scala similarity index 96% rename from core/src/main/scala/dev/constructive/eo/Accessor.scala rename to core/src/main/scala/dev/constructive/eo/accessor/Accessor.scala index a7068f0e..a28a60dd 100644 --- a/core/src/main/scala/dev/constructive/eo/Accessor.scala +++ b/core/src/main/scala/dev/constructive/eo/accessor/Accessor.scala @@ -1,5 +1,5 @@ package dev.constructive.eo -package data +package accessor /** Read the focus `A` out of a two-parameter carrier `F[X, A]`. Required by `Optic.get`; * always-hitting carriers (`Tuple2` for Lens, `Direct` for Iso / Getter) supply one. diff --git a/core/src/main/scala/dev/constructive/eo/PartialAccessor.scala b/core/src/main/scala/dev/constructive/eo/accessor/PartialAccessor.scala similarity index 91% rename from core/src/main/scala/dev/constructive/eo/PartialAccessor.scala rename to core/src/main/scala/dev/constructive/eo/accessor/PartialAccessor.scala index 5183fc52..0219c3af 100644 --- a/core/src/main/scala/dev/constructive/eo/PartialAccessor.scala +++ b/core/src/main/scala/dev/constructive/eo/accessor/PartialAccessor.scala @@ -1,5 +1,7 @@ package dev.constructive.eo -package data +package accessor + +import data.Affine trait PartialAccessor[F[_, _]]: diff --git a/core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala b/core/src/main/scala/dev/constructive/eo/accessor/ReverseAccessor.scala similarity index 96% rename from core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala rename to core/src/main/scala/dev/constructive/eo/accessor/ReverseAccessor.scala index ceeab3eb..d00ffbd7 100644 --- a/core/src/main/scala/dev/constructive/eo/ReverseAccessor.scala +++ b/core/src/main/scala/dev/constructive/eo/accessor/ReverseAccessor.scala @@ -1,5 +1,5 @@ package dev.constructive.eo -package data +package accessor /** Dual of [[Accessor]] — build a fresh `F[X, A]` from an `A`. Required by `Optic.reverseGet`. * diff --git a/core/src/main/scala/dev/constructive/eo/AssociativeFunctor.scala b/core/src/main/scala/dev/constructive/eo/compose/AssociativeFunctor.scala similarity index 99% rename from core/src/main/scala/dev/constructive/eo/AssociativeFunctor.scala rename to core/src/main/scala/dev/constructive/eo/compose/AssociativeFunctor.scala index 51930070..f656d141 100644 --- a/core/src/main/scala/dev/constructive/eo/AssociativeFunctor.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/AssociativeFunctor.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package compose import cats.syntax.either.* diff --git a/core/src/main/scala/dev/constructive/eo/Composer.scala b/core/src/main/scala/dev/constructive/eo/compose/Composer.scala similarity index 99% rename from core/src/main/scala/dev/constructive/eo/Composer.scala rename to core/src/main/scala/dev/constructive/eo/compose/Composer.scala index 5f3157fe..5a0200de 100644 --- a/core/src/main/scala/dev/constructive/eo/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/Composer.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package compose import optics.Optic diff --git a/core/src/main/scala/dev/constructive/eo/Morph.scala b/core/src/main/scala/dev/constructive/eo/compose/Morph.scala similarity index 99% rename from core/src/main/scala/dev/constructive/eo/Morph.scala rename to core/src/main/scala/dev/constructive/eo/compose/Morph.scala index 6b70c46c..64d00aba 100644 --- a/core/src/main/scala/dev/constructive/eo/Morph.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/Morph.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package compose import optics.Optic import data.Affine diff --git a/core/src/main/scala/dev/constructive/eo/ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/compose/ReadCompose.scala similarity index 98% rename from core/src/main/scala/dev/constructive/eo/ReadCompose.scala rename to core/src/main/scala/dev/constructive/eo/compose/ReadCompose.scala index 79586f77..460f2f12 100644 --- a/core/src/main/scala/dev/constructive/eo/ReadCompose.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/ReadCompose.scala @@ -1,8 +1,10 @@ package dev.constructive.eo +package compose import cats.Monoid -import data.{Accessor, PartialAccessor} +import forgetful.ForgetfulFold +import accessor.{Accessor, PartialAccessor} import optics.{ForgetFold, Getter, Optic, PickFold} /** Read-side composition across the read/write seam — the join that `AssociativeFunctor` (which diff --git a/core/src/main/scala/dev/constructive/eo/data/Affine.scala b/core/src/main/scala/dev/constructive/eo/data/Affine.scala index ff7c7414..e9ee2420 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -3,6 +3,8 @@ package data import cats.{Applicative, Monoid} +import forgetful.* +import compose.* import optics.Optic /** Extract the first element type of a `Tuple2`. Stays as an unreduced match type when `T` is not a diff --git a/core/src/main/scala/dev/constructive/eo/data/Direct.scala b/core/src/main/scala/dev/constructive/eo/data/Direct.scala index d41099fc..bad1212a 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Direct.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Direct.scala @@ -3,6 +3,9 @@ package data import cats.Invariant +import accessor.* +import forgetful.* +import compose.* import optics.Optic /** Identity carrier: `Direct[X, A] = A` (opaque). `X` is phantom. Used by `Iso` and `Getter` where diff --git a/core/src/main/scala/dev/constructive/eo/data/Forget.scala b/core/src/main/scala/dev/constructive/eo/data/Forget.scala index e5424d07..1364335f 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Forget.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Forget.scala @@ -6,6 +6,8 @@ import cats.syntax.flatMap.* import cats.syntax.functor.* import cats.{Applicative, Bifunctor, Comonad, FlatMap, Foldable, Functor, Monad, Traverse} +import forgetful.* +import compose.* import optics.Optic /** Adapt a `F[_]` container to the two-parameter carrier shape by wrapping it under the phantom diff --git a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index 4a739328..aa125be4 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -3,6 +3,8 @@ package data import cats.{Alternative, Applicative, Foldable, Functor, Monoid, MonoidK, Representable, Traverse} +import forgetful.* +import compose.* import optics.Optic /** Unified pair carrier for the `AlgLens`, `Kaleidoscope`, and `Grate` optic families — diff --git a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala index 898e5993..ac797ced 100644 --- a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala +++ b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala @@ -5,6 +5,9 @@ import cats.Distributive import cats.instances.function.* import cats.syntax.functor.* +import forgetful.* +import compose.* + /** Carrier for the `Setter` family — pairs a source `Fst[A]` with a continuation `Snd[A] => B`. * * Same-carrier composition (`setter.andThen(setter)`) ships via [[SetterF.assocSetterF]] — diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulApplicative.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulApplicative.scala similarity index 95% rename from core/src/main/scala/dev/constructive/eo/ForgetfulApplicative.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulApplicative.scala index 4c028eae..b6372e4d 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulApplicative.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulApplicative.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package forgetful /** Adds `pure` to [[ForgetfulFunctor]]. Needed by `Optic.put`, which builds the carrier from * scratch ignoring the source. The `Forget[F]` instance lives in [[data.Forget]]. diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala similarity index 98% rename from core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala index c22c0ea0..2d1075c4 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package forgetful import cats.Monoid diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulFunctor.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFunctor.scala similarity index 98% rename from core/src/main/scala/dev/constructive/eo/ForgetfulFunctor.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFunctor.scala index 084a8006..208ca49b 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulFunctor.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFunctor.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package forgetful /** Functor over the second parameter of `F[_, _]`. Uncurried for allocation-free hot paths. * diff --git a/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala similarity index 99% rename from core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala index b960afcf..b8e58309 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala @@ -1,4 +1,5 @@ package dev.constructive.eo +package forgetful import cats.syntax.applicative.* import cats.syntax.either.* diff --git a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 61131aec..dacdf58c 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala @@ -1,6 +1,7 @@ package dev.constructive.eo package optics +import compose.* import data.Affine /** Read-only 0-or-1-focus optic — `Optic[S, Unit, A, Unit, Affine]`. Both `T` and the write-focus diff --git a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 3b0d6713..80a41250 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -5,6 +5,8 @@ import cats.instances.option.given import cats.{Foldable, Monoid} import dev.constructive.eo.data.Forget +import compose.* + /** Constructors for `Fold` — read-only multi-focus optic, backed by `Forget[F]` (`Forget[F][X, A] = * F[A]`). `T = Unit` rules out the write path; `.foldMap` is the consumption surface. * `Fold.select(p)` narrows to a one-element `Option` stream. diff --git a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala index 703700ca..2d302f27 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala @@ -1,6 +1,7 @@ package dev.constructive.eo package optics +import compose.* import data.Direct /** Concrete Optic subclass for a read-only getter. A `final class` storing `get` directly — NOT an diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index 7f467cf0..f0a9fc09 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -7,7 +7,9 @@ import cats.arrow.Profunctor import cats.syntax.functor.* import cats.{Applicative, Functor, Monoid} -import data.* +import accessor.* +import forgetful.* +import compose.* /** Existential encoding of a profunctor optic — the single trait behind every optic family in * `cats-eo`. The optic is a pair of functions `(S => F[X, A], F[X, B] => T)` over a carrier diff --git a/core/src/main/scala/dev/constructive/eo/optics/Review.scala b/core/src/main/scala/dev/constructive/eo/optics/Review.scala index ff63c6b8..cf6297f7 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Review.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Review.scala @@ -1,7 +1,8 @@ package dev.constructive.eo package optics -import data.{Direct, ReverseAccessor} +import accessor.ReverseAccessor +import data.Direct /** Reverse-only counterpart to [[Getter]] — wraps `reverseGet: A => S` and is the exact **mirror of * [[Getter]]**. `Getter` is `Optic[S, Unit, A, Unit, Direct]` (a real `to` reading `S => A`, diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala index 8a2100f4..e87950f5 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala @@ -4,6 +4,7 @@ import scala.language.implicitConversions import cats.Representable import cats.instances.function.given +import dev.constructive.eo.compose.* import org.scalacheck.Prop.forAll import org.specs2.ScalaCheck import org.specs2.mutable.Specification diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala index 9026237b..378a1df5 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala @@ -6,6 +6,7 @@ import cats.data.Chain import cats.instances.list.given import cats.instances.option.given import cats.instances.vector.given +import dev.constructive.eo.compose.* import org.scalacheck.Prop.forAll import org.specs2.ScalaCheck import org.specs2.mutable.Specification diff --git a/laws/src/main/scala/dev/constructive/eo/laws/FoldLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/FoldLaws.scala index c04c1ec9..f15d149e 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/FoldLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/FoldLaws.scala @@ -2,6 +2,7 @@ package dev.constructive.eo package laws import cats.Monoid +import dev.constructive.eo.forgetful.* import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala index 0399a84b..0807e7a8 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala @@ -2,7 +2,7 @@ package dev.constructive.eo.laws.data import cats.{Applicative, Id} import dev.constructive.eo.data.Affine -import dev.constructive.eo.{ForgetfulFunctor, ForgetfulTraverse} +import dev.constructive.eo.forgetful.{ForgetfulFunctor, ForgetfulTraverse} /** Carrier-level laws for `Affine[X, A]`. * diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala index 7bec8df8..07823f1d 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.laws.data -import dev.constructive.eo.ForgetfulFunctor import dev.constructive.eo.data.{Fst, SetterF, Snd} +import dev.constructive.eo.forgetful.ForgetfulFunctor /** Carrier-level laws for `SetterF[X, A]`. * diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala index 46ff4ff2..d34d2334 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala @@ -2,8 +2,8 @@ package dev.constructive.eo.laws.data.discipline import cats.Applicative import dev.constructive.eo.data.Affine +import dev.constructive.eo.forgetful.{ForgetfulFunctor, ForgetfulTraverse} import dev.constructive.eo.laws.data.AffineLaws -import dev.constructive.eo.{ForgetfulFunctor, ForgetfulTraverse} import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala index 86c2fb01..e4a8c201 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.laws.data.discipline -import dev.constructive.eo.ForgetfulFunctor import dev.constructive.eo.data.{Fst, SetterF, Snd} +import dev.constructive.eo.forgetful.ForgetfulFunctor import dev.constructive.eo.laws.data.SetterFLaws import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} diff --git a/laws/src/main/scala/dev/constructive/eo/laws/discipline/FoldTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/discipline/FoldTests.scala index 81b0095c..406198d8 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/discipline/FoldTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/discipline/FoldTests.scala @@ -2,6 +2,7 @@ package dev.constructive.eo package laws package discipline +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala index fbbe0f9e..f696760a 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/ComposeLaws.scala @@ -3,6 +3,7 @@ package laws package eo import _root_.dev.constructive.eo.data.{Affine, Direct} +import dev.constructive.eo.compose.* import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/FoldAndTraverseLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/FoldAndTraverseLaws.scala index f3bd7681..957c2e45 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/FoldAndTraverseLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/FoldAndTraverseLaws.scala @@ -2,6 +2,8 @@ package dev.constructive.eo package laws package eo +import dev.constructive.eo.forgetful.* + import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/ModifyALaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/ModifyALaws.scala index 8098708b..f881b90f 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/ModifyALaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/ModifyALaws.scala @@ -4,6 +4,7 @@ package eo import cats.data.Const import cats.{Applicative, Id} +import dev.constructive.eo.forgetful.* import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/MorphLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/MorphLaws.scala index c3f116fd..1a1e03dc 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/MorphLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/MorphLaws.scala @@ -2,6 +2,9 @@ package dev.constructive.eo package laws package eo +import dev.constructive.eo.accessor.* +import dev.constructive.eo.forgetful.* + import optics.Optic /** Laws that pin down `Optic.morph` — EO's carrier-coercion extension. @@ -29,7 +32,7 @@ trait MorphLaws[S, A, F[_, _], G[_, _]]: /** A2 — morph preserves get (when both carriers have `Accessor`). */ def morphPreservesGet(s: S)(using - _root_.dev.constructive.eo.data.Accessor[F], - _root_.dev.constructive.eo.data.Accessor[G], + _root_.dev.constructive.eo.accessor.Accessor[F], + _root_.dev.constructive.eo.accessor.Accessor[G], ): Boolean = morphed.get(s) == optic.get(s) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/FoldAndTraverseTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/FoldAndTraverseTests.scala index 6d85db18..0599e5cb 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/FoldAndTraverseTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/FoldAndTraverseTests.scala @@ -3,6 +3,7 @@ package laws package eo package discipline +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/ModifyATests.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/ModifyATests.scala index e924eed9..99cf0d48 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/ModifyATests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/ModifyATests.scala @@ -4,6 +4,7 @@ package eo package discipline import cats.Applicative +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws diff --git a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/MorphTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/MorphTests.scala index e9aab63b..234738cf 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/MorphTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/eo/discipline/MorphTests.scala @@ -3,6 +3,8 @@ package laws package eo package discipline +import dev.constructive.eo.accessor.* +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws @@ -29,8 +31,8 @@ abstract class MorphTests[S, A, F[_, _], G[_, _]] extends Laws: def morphPreservesGet(using Arbitrary[S], - _root_.dev.constructive.eo.data.Accessor[F], - _root_.dev.constructive.eo.data.Accessor[G], + _root_.dev.constructive.eo.accessor.Accessor[F], + _root_.dev.constructive.eo.accessor.Accessor[G], ): RuleSet = new SimpleRuleSet( "Morph preserves get", diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/AssociativeFunctorLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/AssociativeFunctorLaws.scala index 1c37f6fc..93154a03 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/AssociativeFunctorLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/AssociativeFunctorLaws.scala @@ -2,6 +2,8 @@ package dev.constructive.eo package laws package typeclass +import dev.constructive.eo.forgetful.* + import optics.Optic import optics.Optic.* diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala index 007c320c..a31fb716 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ComposerLaws.scala @@ -3,6 +3,8 @@ package laws package typeclass import _root_.dev.constructive.eo.data.{Affine, Direct} +import dev.constructive.eo.accessor.* +import dev.constructive.eo.compose.* import optics.Optic import optics.Optic.* @@ -31,8 +33,8 @@ trait ComposerPreservesGetLaws[S, A, F[_, _], G[_, _], H[_, _]]: def fToG: Composer[F, G] def gToH: Composer[G, H] - given accessorF: _root_.dev.constructive.eo.data.Accessor[F] - given accessorH: _root_.dev.constructive.eo.data.Accessor[H] + given accessorF: _root_.dev.constructive.eo.accessor.Accessor[F] + given accessorH: _root_.dev.constructive.eo.accessor.Accessor[H] def preservesGet(s: S): Boolean = gToH.to(fToG.to(optic)).get(s) == optic.get(s) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala index 0722bae8..4cbac2a3 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala @@ -1,6 +1,6 @@ package dev.constructive.eo.laws.typeclass -import dev.constructive.eo.ForgetfulFunctor +import dev.constructive.eo.forgetful.ForgetfulFunctor /** Law equations for any `ForgetfulFunctor[F]` instance — the usual two functor laws, stated in * carrier-carrier form: diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala index fd3eb338..27b309a6 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.laws.typeclass import cats.{Applicative, Id} -import dev.constructive.eo.ForgetfulTraverse +import dev.constructive.eo.forgetful.ForgetfulTraverse /** Law equations for any `ForgetfulTraverse[F, Applicative]` — the identity law stated at `Id`: * diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulFunctorTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulFunctorTests.scala index 68af3b26..7c67426f 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulFunctorTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulFunctorTests.scala @@ -1,6 +1,6 @@ package dev.constructive.eo.laws.typeclass.discipline -import dev.constructive.eo.ForgetfulFunctor +import dev.constructive.eo.forgetful.ForgetfulFunctor import dev.constructive.eo.laws.typeclass.ForgetfulFunctorLaws import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulTraverseTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulTraverseTests.scala index ceab66f0..53f0e16d 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulTraverseTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/discipline/ForgetfulTraverseTests.scala @@ -1,7 +1,7 @@ package dev.constructive.eo.laws.typeclass.discipline import cats.Applicative -import dev.constructive.eo.ForgetfulTraverse +import dev.constructive.eo.forgetful.ForgetfulTraverse import dev.constructive.eo.laws.typeclass.ForgetfulTraverseLaws import org.scalacheck.Arbitrary import org.scalacheck.Prop.forAll diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index 53d5100c..a5f9464b 100644 --- a/site/docs/multifocus.md +++ b/site/docs/multifocus.md @@ -246,7 +246,7 @@ specialised by `F`: ### Outbound — `SetterF` and a read-only `Forget[F]` escape ```scala mdoc:silent -import dev.constructive.eo.Composer +import dev.constructive.eo.compose.Composer import dev.constructive.eo.data.SetterF val setter = summon[Composer[MultiFocus[List], SetterF]].to(listMF) diff --git a/site/docs/optics.md b/site/docs/optics.md index a18a6a9f..41bcb7fd 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -366,7 +366,7 @@ writes into it) and `setter.andThen(setter)` work — `SetterF` ships an `Optic.andThen` resolution picks it up transparently. ```scala mdoc:silent -import dev.constructive.eo.Composer +import dev.constructive.eo.compose.Composer import dev.constructive.eo.data.SetterF import dev.constructive.eo.data.SetterF.given diff --git a/tests/src/test/scala/dev/constructive/eo/CheckAllHelpers.scala b/tests/src/test/scala/dev/constructive/eo/CheckAllHelpers.scala index cc8c87ef..0dcd4261 100644 --- a/tests/src/test/scala/dev/constructive/eo/CheckAllHelpers.scala +++ b/tests/src/test/scala/dev/constructive/eo/CheckAllHelpers.scala @@ -1,6 +1,8 @@ package dev.constructive.eo import cats.{Applicative, Functor} +import dev.constructive.eo.accessor.* +import dev.constructive.eo.forgetful.* import org.scalacheck.{Arbitrary, Cogen} import org.specs2.mutable.SpecificationLike import org.specs2.specification.core.Fragment @@ -142,8 +144,8 @@ trait CheckAllHelpers extends Discipline: morphedOptic: Optic[S, S, A, A, G], )(using Arbitrary[S], - data.Accessor[F], - data.Accessor[G], + accessor.Accessor[F], + accessor.Accessor[G], ): Fragment = checkAll( name, diff --git a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala index 0fc796f5..b4e94060 100644 --- a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala @@ -1,6 +1,8 @@ package dev.constructive.eo import cats.instances.list.given +import dev.constructive.eo.accessor.* +import dev.constructive.eo.compose.* import org.scalacheck.Arbitrary import org.scalacheck.Prop.forAll import org.specs2.mutable.Specification @@ -230,11 +232,11 @@ class EoSpecificLawsSpec extends Specification with CheckAllHelpers: o: Optic[S, T, A, B, Tuple2] ): Optic[S, T, A, B, Tuple2] = o - given accessorF: data.Accessor[Direct] = + given accessorF: accessor.Accessor[Direct] = Direct.accessor - given accessorH: data.Accessor[Tuple2] = - data.Accessor.tupleAccessor + given accessorH: accessor.Accessor[Tuple2] = + accessor.Accessor.tupleAccessor .composerPreservesGet, ) diff --git a/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala b/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala index d3139cf7..e1c8ba3e 100644 --- a/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala +++ b/tests/src/test/scala/dev/constructive/eo/JsonOptic.scala @@ -1,5 +1,6 @@ package dev.constructive.eo +import dev.constructive.eo.forgetful.* import io.circe.{Codec, Decoder, HCursor, Json} import optics.Optic diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index 286149b1..de7f6c88 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -5,6 +5,8 @@ import scala.language.implicitConversions import cats.instances.int.given import cats.instances.list.given import cats.instances.option.given +import dev.constructive.eo.compose.* +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.specs2.ScalaCheck import org.specs2.mutable.Specification diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index 546918b4..f2cbae81 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -1,6 +1,9 @@ package dev.constructive.eo import cats.instances.list.given +import dev.constructive.eo.accessor.* +import dev.constructive.eo.compose.* +import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen, Gen} import org.specs2.mutable.Specification @@ -473,7 +476,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: "ForgetfulApplicative: Optic.put on Direct + forgetFApplicative[List].pure / .map" >> { import Optic.* given ForgetfulApplicative[Direct] = Direct.applicative - given data.ReverseAccessor[Direct] = Direct.reverseAccessor + given accessor.ReverseAccessor[Direct] = Direct.reverseAccessor val intDouble: Optic[Int, Int, Int, Int, Direct] = optics.Iso[Int, Int, Int, Int](_ * 2, _ / 2) val ap = summon[ForgetfulApplicative[data.Forget[List]]]