Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class CrossCarrierCompositionSpec extends Specification:
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]] = MultiFocus((), List(xs))
def from(pair: MultiFocus[List][X, List[Int]]): List[Int] = pair.foci.head
def from(mf: MultiFocus[List][X, List[Int]]): List[Int] = mf.foci.head

// The cross-carrier .andThen resolves through `either2multifocus[List]`.
val chain: Optic[Json, Json, List[Int], List[Int], MultiFocus[List]] =
Expand Down
646 changes: 429 additions & 217 deletions core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala

Large diffs are not rendered by default.

43 changes: 41 additions & 2 deletions core/src/main/scala/dev/constructive/eo/optics/Optic.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package dev.constructive.eo
package optics

import scala.annotation.{nowarn, targetName}
import scala.compiletime.summonInline
import scala.compiletime.{summonFrom, summonInline}

import cats.arrow.Profunctor
import cats.syntax.functor.*
Expand Down Expand Up @@ -68,6 +68,11 @@ trait Optic[S, T, A, B, F[_, _]]:
* Cross-carrier composition (Lens → Optional, Lens → Traversal, …) goes through the
* `Morph`-summoning overload of this same method.
*
* The composite also carries [[SameFocus]] (mixed in below, with whatever `C =:= D` evidence
* this call site has), so a kernel whose write cannot be inverted from the bundle alone can look
* the fact up on the inner optic it is handed. Stability is a property of the optic's own type
* pair, so it rides on the optic — the composition algebra stays unaware of it.
*
* @example
* {{{
* case class Address(street: String)
Expand All @@ -79,8 +84,13 @@ trait Optic[S, T, A, B, F[_, _]]:
@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:
val same: Option[C =:= D] = summonFrom {
case ev: (C =:= D) => Some(ev)
case _ => None
}
new Optic with Optic.SameFocus[C, D, F]:
type X = af.Z
def sameFocus: Option[C =:= D] = same
def to(s: S): F[X, C] = af.composeTo(s, self, o)
def from(xd: F[X, D]): T = af.composeFrom(xd, o, self)

Expand Down Expand Up @@ -146,6 +156,35 @@ trait Optic[S, T, A, B, F[_, _]]:
*/
object Optic:

/** Focus-stability witness carried by an optic whose focus types coincide (`C =:= D`).
*
* It exists for kernels whose composite read cannot be inverted from the write bundle alone: the
* `Function1`-shaped one reads ONE position per index, so composing two tabulating optics
* (`MultiFocus.tuple` ∘ `MultiFocus.tuple`) leaves each inner structure's off-diagonal out of
* the composite's focus vector, and putting those values back needs a `C => D` — which exists
* exactly when `C =:= D`. Handing that fact to the kernel through the composition algebra would
* put one carrier's problem in every carrier's contract; it rides on the optic instead, like
* [[data.Function1BroadcastOptic]] (which says *this optic writes one value per position*) —
* this one says *this optic's write types coincide with its read types*.
*
* [[Optic.andThen]] mixes it into every composite it builds, with whatever `C =:= D` evidence
* that call site has, and the monomorphic factories (`MultiFocus.representable`,
* `MultiFocus.tuple`, `MultiFocus.apply`) mix it in with `Some(…)` directly, so a kernel that is
* handed an inner optic can look the fact up on it.
*
* `None` is a real answer, not a failure: a polymorphic optic (`MultiFocus.pApply`, a Lens whose
* `S ≠ T`) cannot put a read value back into a write bundle, and kernels keep their lossy
* default for it.
*
* @group Operations
*/
trait SameFocus[C, D, F[_, _]]:

/** `Some` carries the evidence that this optic's read and write types coincide; `None` says
* they may not.
*/
def sameFocus: Option[C =:= D]

/** 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ object Traversal:
new Traversal[S, S, S, S]:
type X = 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)
def from(mf: MultiFocus[PSVec][X, S]): S = rebuild(mf.context, mf.foci)

// Streaming read path: fold the children vector directly, skipping the carrier wrapper.
override def foldMap[M](f: S => M)(s: S)(using Monoid[M]): M =
Expand Down Expand Up @@ -344,8 +344,8 @@ final class TraverseTraversal[T[_]: Traverse, A, B] extends Traversal[T[A], T[B]
def to(ta: T[A]): MultiFocus[PSVec][X, A] =
MultiFocus(ta, PSVec.from(ta))

def from(pair: MultiFocus[PSVec][X, B]): T[B] =
val (xo, vec) = (pair.context, pair.foci)
def from(mf: MultiFocus[PSVec][X, B]): T[B] =
val (xo, vec) = (mf.context, mf.foci)
xo match
case _: ArraySeq[?] =>
// `unsafeShareableArray` returns the Slice's backing array when it densely covers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ private[optics] object TraversalArityMacro:
}
MultiFocus((), PSVec.unsafeWrap[A](arr))

def from(pair: MultiFocus[PSVec][X, B]): T =
${ applyReverse(List.tabulate(getters.length)(i => '{ pair.foci(${ Expr(i) }) })) }
def from(mf: MultiFocus[PSVec][X, B]): T =
${ applyReverse(List.tabulate(getters.length)(i => '{ mf.foci(${ Expr(i) }) })) }
}

def twoImpl[S: Type, T: Type, A: Type, B: Type](
Expand Down
Loading
Loading