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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ personStreet.modify(_.toUpperCase)(alice) // address.street := "MAIN ST"
a read-only `Optional`; partial projection without a write side.
- [`Getter`](https://eo.constructive.dev/optics.html#getter) — a
read-only one-focus projection.
- [`Setter`](https://eo.constructive.dev/optics.html#setter) — a
- [`Modify`](https://eo.constructive.dev/optics.html#modify) — a
write-only optic; modify without observing.
- [`Fold`](https://eo.constructive.dev/optics.html#fold) — N foci
summarised through a `Monoid`.
Expand All @@ -76,24 +76,27 @@ personStreet.modify(_.toUpperCase)(alice) // address.street := "MAIN ST"
whole structure.
- [`Review`](https://eo.constructive.dev/optics.html#review) — the
reverse-only half of a `Prism`; build, never observe.
- [`JsonPrism`](https://eo.constructive.dev/circe.html#jsonprism) —
- [`Unfold`](https://eo.constructive.dev/optics.html#unfold) — the
build-only many optic (`embed: F[B] => T`); assemble one whole from
an `F`-layer of parts — the algebra of a recursion scheme.
- [`JsonPrism`](https://eo.constructive.dev/integrations/circe.html#jsonprism) —
cursor-backed JSON optic with observable-by-default `Ior` failures.
- [`JsonFieldsPrism`](https://eo.constructive.dev/circe.html#multi-field-focus----fields-_a-_b) —
- [`JsonFieldsPrism`](https://eo.constructive.dev/integrations/circe.html#multi-field-focus----fields-_a-_b) —
multi-field flavour of `JsonPrism`.
- [`JsonTraversal`](https://eo.constructive.dev/circe.html#jsontraversal-each) —
- [`JsonTraversal`](https://eo.constructive.dev/integrations/circe.html#jsontraversal-each) —
`.each` traversal across JSON arrays.
- [`JsonFieldsTraversal`](https://eo.constructive.dev/circe.html#multi-field-focus----fields-_a-_b) —
- [`JsonFieldsTraversal`](https://eo.constructive.dev/integrations/circe.html#multi-field-focus----fields-_a-_b) —
multi-field flavour of `JsonTraversal`.
- [`AvroPrism`](https://eo.constructive.dev/avro.html) —
- [`AvroPrism`](https://eo.constructive.dev/integrations/avro.html) —
schema-aware Avro optic over `IndexedRecord`, with the same `Ior`
failure surface as `JsonPrism` plus a `.union[Branch]` macro for
Avro union types. Triple input — parsed record, binary wire bytes,
or Avro JSON.
- [`AvroFieldsPrism`](https://eo.constructive.dev/avro.html) — multi-
- [`AvroFieldsPrism`](https://eo.constructive.dev/integrations/avro.html) — multi-
field flavour of `AvroPrism`.
- [`AvroTraversal`](https://eo.constructive.dev/avro.html) — `.each`
- [`AvroTraversal`](https://eo.constructive.dev/integrations/avro.html) — `.each`
traversal across Avro arrays.
- [`AvroFieldsTraversal`](https://eo.constructive.dev/avro.html) —
- [`AvroFieldsTraversal`](https://eo.constructive.dev/integrations/avro.html) —
multi-field flavour of `AvroTraversal`.

Every optic ships a discipline-checked law set in `cats-eo-laws`, so
Expand All @@ -104,8 +107,8 @@ for cats typeclasses.

- Getting started: <https://eo.constructive.dev/getting-started.html>
- Macro-derived optics (`generics`): <https://eo.constructive.dev/generics.html>
- circe integration: <https://eo.constructive.dev/circe.html>
- Avro integration: <https://eo.constructive.dev/avro.html>
- circe integration: <https://eo.constructive.dev/integrations/circe.html>
- Avro integration: <https://eo.constructive.dev/integrations/avro.html>
- Cookbook (recipes): <https://eo.constructive.dev/cookbook.html>
- Composition gap analysis (research):
[`docs/research/2026-04-23-composition-gap-analysis.md`](./docs/research/2026-04-23-composition-gap-analysis.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class CompositionBench extends JmhDefaults:

import NestedOptics.{d3, d6, eoFlag, eoN1, eoN2, eoN3, eoN4, eoN5, eoN6, leaf}

// Leaf Lens on Nested0.value (NestedOptics ships a Getter / Setter for it,
// Leaf Lens on Nested0.value (NestedOptics ships a Getter / Modify for it,
// but composition needs a full Lens).
private val leafValue =
EoLens[Nested0, Int](_.value, (n, v) => n.copy(value = v))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import java.util.concurrent.TimeUnit

import dev.constructive.eo.bench.fixture.*

/** `Setter.modify` at the leaf plus deep composition, paired EO vs Monocle.
/** `Modify.modify` (eo's write-only family, named `Setter` in Monocle) at the leaf plus deep composition, paired EO vs Monocle.
*
* EO's `Setter` carrier `SetterF` has both a `ForgetfulFunctor[SetterF]` (powers `.modify`) and an
* `AssociativeFunctor[SetterF]` (`assocSetterF`), so two Setters compose through the ordinary
* EO's `Modify` carrier `ModifyF` has both a `ForgetfulFunctor[ModifyF]` (powers `.modify`) and an
* `AssociativeFunctor[ModifyF]` (`assocModifyF`), so two Modify optics compose through the ordinary
* `andThen` — `s1.andThen(s2).modify(f) == s1.modify(s2.modify(f))`. The depth-3 / depth-6 rows
* build a *composed* `Setter` on both sides (EO's `s3.andThen(s2)…` vs Monocle's
* build a *composed* write-only optic on both sides (EO's `s3.andThen(s2)…` vs Monocle's
* `mS3.andThen(mS2)…`) and dispatch through it once, rather than hand-nesting `modify` on the EO
* side.
*/
Expand Down Expand Up @@ -47,7 +47,7 @@ class SetterBench extends JmhDefaults:

// ---- Nested depth sweep (composition, which Order can't express) --

// Both sides build a composed Setter and dispatch through it once.
// Both sides build a composed write-only optic and dispatch through it once.
private val eoSet3 = eoS3.andThen(eoS2).andThen(eoS1).andThen(eoSetValue)

private val eoSet6 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ package fixture
* schema — the precondition for apples-to-apples cross-backend numbers.
*
* Foci the schema unlocks:
* - `order.id: Long` — depth-1 scalar (Lens / Getter / Setter; jsoniter `$.id`).
* - `order.id: Long` — depth-1 scalar (Lens / Getter / Modify; jsoniter `$.id`).
* - `customer.address.street: String` — depth-3 product path (deep Lens / JsonPrism).
* - `customer.loyaltyId: Option[String]` — Optional / AffineFold focus.
* - `lines[*].name: String` — array Traversal (`$.lines[*].name`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import dev.constructive.eo.optics.{
AffineFold,
Getter => EoGetter,
Optional => EoOptional,
Setter => EoSetter,
Modify => EoModify,
}

import monocle.{Getter => MGetter, Optional => MOptional, Setter => MSetter}
Expand All @@ -17,7 +17,7 @@ import monocle.{Getter => MGetter, Optional => MOptional, Setter => MSetter}
* `OptionalBench`, `AffineFoldBench`).
*
* These cover the two canonical foci the integration benches couldn't reach in their own carrier:
* - `order.id: Long` — the advertised Getter / Setter scalar (`$.id`).
* - `order.id: Long` — the advertised Getter / Modify scalar (`$.id`).
* - `customer.loyaltyId: Option[String]` — the advertised Optional / AffineFold focus. Avro
* omits it (kindlings encodes `Option` as a union, not a transparent field — plan 009 caveat),
* but in memory it is exactly an `Optional`/`AffineFold`, so it lives here, not on a synthetic
Expand All @@ -41,9 +41,9 @@ object DomainOptics:
val eoGetId = EoGetter[Order, Long](_.id)
val mGetId: MGetter[Order, Long] = MGetter[Order, Long](_.id)

// ---- Setter on order.id -------------------------------------------
// ---- Modify on order.id -------------------------------------------

val eoSetId = EoSetter[Order, Order, Long, Long](f => o => o.copy(id = f(o.id)))
val eoSetId = EoModify[Order, Order, Long, Long](f => o => o.copy(id = f(o.id)))
val mSetId: MSetter[Order, Long] = MSetter[Order, Long](f => o => o.copy(id = f(o.id)))

// ---- Optional / AffineFold on customer.loyaltyId ------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ package fixture
* stress case.
*
* Each leaf (`Nested0`) carries three kinds of focus:
* - `value: Int` — drives Lens / Getter / Setter.
* - `value: Int` — drives Lens / Getter / Modify.
* - `flag: Option[String]` — drives Optional (the conditional branch is exactly the `None` side
* of the `Option`).
* - `items: List[Int]` — drives Fold over a `Foldable` carrier.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import dev.constructive.eo.optics.{
Lens => EoLens,
Optional => EoOptional,
Review => EoReview,
Setter => EoSetter,
Modify => EoModify,
}

import monocle.{Getter => MGetter, Lens => MLens, Optional => MOptional, Setter => MSetter}
Expand Down Expand Up @@ -111,17 +111,17 @@ object NestedOptics:
val mGet6 =
mG6.andThen(mG5).andThen(mG4).andThen(mG3).andThen(mG2).andThen(mG1).andThen(mGetValue)

// ---- Per-level EO Setters (SetterF carrier) ---------------------
// ---- Per-level EO Modify optics (ModifyF carrier) ---------------

val eoSetValue =
EoSetter[Nested0, Nested0, Int, Int](f => n0 => n0.copy(value = f(n0.value)))

val eoS1 = EoSetter[Nested1, Nested1, Nested0, Nested0](f => x => x.copy(n = f(x.n)))
val eoS2 = EoSetter[Nested2, Nested2, Nested1, Nested1](f => x => x.copy(n = f(x.n)))
val eoS3 = EoSetter[Nested3, Nested3, Nested2, Nested2](f => x => x.copy(n = f(x.n)))
val eoS4 = EoSetter[Nested4, Nested4, Nested3, Nested3](f => x => x.copy(n = f(x.n)))
val eoS5 = EoSetter[Nested5, Nested5, Nested4, Nested4](f => x => x.copy(n = f(x.n)))
val eoS6 = EoSetter[Nested6, Nested6, Nested5, Nested5](f => x => x.copy(n = f(x.n)))
EoModify[Nested0, Nested0, Int, Int](f => n0 => n0.copy(value = f(n0.value)))

val eoS1 = EoModify[Nested1, Nested1, Nested0, Nested0](f => x => x.copy(n = f(x.n)))
val eoS2 = EoModify[Nested2, Nested2, Nested1, Nested1](f => x => x.copy(n = f(x.n)))
val eoS3 = EoModify[Nested3, Nested3, Nested2, Nested2](f => x => x.copy(n = f(x.n)))
val eoS4 = EoModify[Nested4, Nested4, Nested3, Nested3](f => x => x.copy(n = f(x.n)))
val eoS5 = EoModify[Nested5, Nested5, Nested4, Nested4](f => x => x.copy(n = f(x.n)))
val eoS6 = EoModify[Nested6, Nested6, Nested5, Nested5](f => x => x.copy(n = f(x.n)))

// ---- Per-level Monocle Setters + composed set-3/set-6 -----------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ package object bench:
Getter => EoGetter,
Lens => EoLens,
Prism => EoPrism,
Setter => EoSetter,
Modify => EoModify,
Traversal => EoTraversal,
}
32 changes: 21 additions & 11 deletions core/src/main/scala/dev/constructive/eo/compose/Composer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import optics.Optic

/** Bridge between carriers — reshape an `F`-carrier optic into a `G`-carrier optic. Used by
* `Optic.morph`; the mechanism by which optic families cross boundaries (Lens → Optional, Lens →
* Setter, Iso → Lens, …).
* Modify, Iso → Lens, …).
*
* @tparam F
* source carrier
Expand Down Expand Up @@ -60,23 +60,33 @@ object Composer extends LowPriorityComposerInstances:
// 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
* [[dev.constructive.eo.laws.GetterLaws]] anticipated; it became sound once `Fold` was made
* honestly one-way (`B = Unit`), because the `from` is now genuinely unreachable: `Forget[F]`
* admits no `ReverseAccessor`, so the resulting fold's build side is never invoked (mirrors
* [[direct2either]]'s unreachable `Left`). Powers `Getter.andThen(Fold)` and `Optic.cross`
* against a `Fold`. Requires `Applicative[F]` for `pure`.
/** Express a Direct optic (a Getter, Review, or Iso) as a `Forget[F]`-carrier optic — lift the
* single focus into `F` via `pure` on the read side, and pick the single `B` back out of the
* `F[B]` on the build side. This is the `Composer[Direct, Forget[F]]` bridge
* [[dev.constructive.eo.laws.GetterLaws]] anticipated. Powers `Getter.andThen(Fold)`,
* `Optic.cross` against a `Fold`, and — since [[optics.Unfold]] gave `Forget[F]` a genuine
* build-only citizen — the build side of `review.andThen(unfold)` chains.
*
* The `from` was a documented-unreachable `???` while `Forget[F]` had no build-capable
* inhabitant; `Unfold` made it reachable (via `assocForgetMonad.composeFrom` on a `Monad[F]`
* chain). The singleton pick is sound on every reachable path: the only `F[B]` ever fed to a
* lifted Direct optic's `from` is `ForgetPull.monadicPull`'s `pure(b)`. A hand-routed call with
* cardinality ≠ 1 throws, mirroring the other `pickSingletonOrThrow` bridges. Requires
* `Applicative[F]` for `pure` and `Foldable[F]` for the pick.
*
* @group Instances
*/
given direct2forget[F[_]](using F: cats.Applicative[F]): Composer[Direct, data.Forget[F]] with
given direct2forget[F[_]](using
F: cats.Applicative[F],
FF: cats.Foldable[F],
): Composer[Direct, data.Forget[F]] with

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.Forget(F.pure(o.to(s).value))
def from(u: data.Forget[F][X, B]): T = ???
def from(fb: data.Forget[F][X, B]): T =
o.from(Direct(data.MultiFocusK.pickSingletonOrThrow[F, B](fb.value, "Direct")))

/** Low-priority `Composer` instances —
* [[LowPriorityComposerInstances.chainViaTuple2 chainViaTuple2]], a transitive derivation pinned
Expand All @@ -88,7 +98,7 @@ trait LowPriorityComposerInstances:

/** Transitive derivation via `Tuple2` as the intermediate carrier: given `F → Tuple2` and
* `Tuple2 → G`, derive `F → G`. Fires cleanly for Direct-origin chains to any target with a
* `Composer[Tuple2, _]` direct (Affine / SetterF / MultiFocus[F] / MultiFocus[PSVec]).
* `Composer[Tuple2, _]` direct (Affine / ModifyF / MultiFocus[F] / MultiFocus[PSVec]).
*
* @group Instances
*/
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/scala/dev/constructive/eo/data/Forget.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import optics.Optic
* `X`. Equivalent to the classic Haskell `newtype Forget r a b = Forget (a -> r)` construction but
* applied to a type constructor `F`: `Forget[F][X, A] = F[A]`, ignoring `X` completely.
*
* Used by [[dev.constructive.eo.optics.Fold]] and the multi-focus family
* Used by [[dev.constructive.eo.optics.Fold]] (read-only), its build-only dual
* [[dev.constructive.eo.optics.Unfold]], and the multi-focus family
* ([[dev.constructive.eo.data.MultiFocus]]) as a uniform "F-shape carrier" whose optic-level
* capabilities scale with the typeclasses `F` itself admits.
*/
Expand Down
Loading