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() diff --git a/.gitignore b/.gitignore index 975f6f2c..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/ @@ -22,3 +24,4 @@ site/target/ # wrangler's local Pages state .wrangler/ +.#* 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/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/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/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/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/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..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,8 +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.data.MultiFocus.given // mfFunctor + either2multifocus - 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 @@ -195,8 +194,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]): 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]`. val chain: Optic[Json, Json, List[Int], List[Int], MultiFocus[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/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) } diff --git a/core/src/main/scala/dev/constructive/eo/Accessors.scala b/core/src/main/scala/dev/constructive/eo/accessor/Accessor.scala similarity index 51% rename from core/src/main/scala/dev/constructive/eo/Accessors.scala rename to core/src/main/scala/dev/constructive/eo/accessor/Accessor.scala index d41a8fd7..a28a60dd 100644 --- a/core/src/main/scala/dev/constructive/eo/Accessors.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. @@ -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/accessor/PartialAccessor.scala b/core/src/main/scala/dev/constructive/eo/accessor/PartialAccessor.scala new file mode 100644 index 00000000..0219c3af --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/accessor/PartialAccessor.scala @@ -0,0 +1,16 @@ +package dev.constructive.eo +package accessor + +import data.Affine + +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/accessor/ReverseAccessor.scala b/core/src/main/scala/dev/constructive/eo/accessor/ReverseAccessor.scala new file mode 100644 index 00000000..d00ffbd7 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/accessor/ReverseAccessor.scala @@ -0,0 +1,17 @@ +package dev.constructive.eo +package accessor + +/** 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/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 86% rename from core/src/main/scala/dev/constructive/eo/Composer.scala rename to core/src/main/scala/dev/constructive/eo/compose/Composer.scala index 6dd9b016..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 @@ -38,8 +39,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,11 +52,13 @@ 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(_) => ??? + // `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 @@ -72,8 +75,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] = data.Forget(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/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/compose/ReadCompose.scala b/core/src/main/scala/dev/constructive/eo/compose/ReadCompose.scala new file mode 100644 index 00000000..460f2f12 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/compose/ReadCompose.scala @@ -0,0 +1,127 @@ +package dev.constructive.eo +package compose + +import cats.Monoid + +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 + * 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 F + * outer optic's carrier + * @tparam G + * inner optic's carrier + */ +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, 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 + * 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[F[_, _], G[_, _]](using + AO: Accessor[F], + AI: Accessor[G], + ): ReadCompose[F, G] with + type Out[S, C] = Getter[S, C] + + 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[F[_, _], G[_, _]](using + AO: Accessor[F], + PI: PartialAccessor[G], + ): ReadCompose[F, G] with + type Out[S, C] = PickFold[S, C] + + 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[F[_, _], G[_, _]](using + PO: PartialAccessor[F], + AI: Accessor[G], + ): ReadCompose[F, G] with + type Out[S, C] = PickFold[S, C] + + 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[F[_, _], G[_, _]](using + PO: PartialAccessor[F], + PI: PartialAccessor[G], + ): ReadCompose[F, G] with + type Out[S, C] = PickFold[S, C] + + 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)))) + +/** 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[F[_, _], G[_, _]](using + FF: ForgetfulFold[F], + FG: ForgetfulFold[G], + ): ReadCompose[F, G] with + type Out[S, C] = ForgetFold[S, List, C] + + 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 => + FF.foldMap( + (a: A) => FG.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/data/Affine.scala b/core/src/main/scala/dev/constructive/eo/data/Affine.scala index 977b5789..e9ee2420 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -1,8 +1,10 @@ package dev.constructive.eo package data -import cats.Applicative +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 @@ -146,6 +148,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. @@ -212,11 +225,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 +247,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/Direct.scala b/core/src/main/scala/dev/constructive/eo/data/Direct.scala index 6ea59ccc..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 @@ -44,6 +47,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..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 @@ -16,10 +18,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 +48,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 +118,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 +126,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 +136,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 +146,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 +166,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 +180,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 278eb3f1..aa125be4 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -1,18 +1,10 @@ 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 forgetful.* +import compose.* import optics.Optic /** Unified pair carrier for the `AlgLens`, `Kaleidoscope`, and `Grate` optic families — @@ -39,7 +31,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 +132,40 @@ 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 - - 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)) +object MultiFocusK: - def foldLeft[A, B](fa: PSVec[A], b: B)(f: (B, A) => B): B = - pSVecFoldable.foldLeft(fa, b)(f) + // 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 - 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 +178,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 +207,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 +260,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 +268,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 +289,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 +350,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 = @@ -514,8 +471,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 +482,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 +492,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).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 @@ -563,8 +519,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): 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. @@ -578,13 +534,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 +546,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 +561,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 +592,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 +616,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 +652,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 +686,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 +699,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 +729,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 +742,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 +763,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 +790,35 @@ 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)) + +/** 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, *} + + 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/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/data/SetterF.scala b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala index 7615ca69..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]] — @@ -59,9 +62,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/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 71% rename from core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala index 1b30f58b..2d1075c4 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulFold.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala @@ -1,9 +1,8 @@ package dev.constructive.eo +package forgetful import cats.Monoid -import data.Affine - /** `foldMap` over the focus of `F[_, _]`. Miss / absent contributes `Monoid.empty`; hit runs `f` * and folds. * @@ -28,10 +27,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/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 79% rename from core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala rename to core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala index 9a35e93b..b8e58309 100644 --- a/core/src/main/scala/dev/constructive/eo/ForgetfulTraverse.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala @@ -1,12 +1,11 @@ package dev.constructive.eo +package forgetful import cats.syntax.applicative.* 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 +46,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/optics/AffineFold.scala b/core/src/main/scala/dev/constructive/eo/optics/AffineFold.scala index 275c5359..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 @@ -15,6 +16,56 @@ 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 [[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) + + 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 [[Getter.andThen]]). + */ + inline def andThen[C](inner: PickFold[A, C]): PickFold[S, C] = + new PickFold(s => pick(s).flatMap(inner.pick)) + + /** 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: @@ -28,18 +79,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) - val to: S => Affine[X, A] = s => - 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 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/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 307fb97f..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. @@ -45,11 +47,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): 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 * intermediate `S => M` closure and no `ForgetfulFold` summon. Wins over the [[Optic.foldMap]] @@ -57,4 +60,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 e369a772..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,16 +1,67 @@ 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 + * 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 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)) + 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: Getter[A, B]): Getter[S, B] = + new Getter(s => inner.get(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: @@ -24,28 +75,5 @@ object Getter: * val nameLength = Getter[Person, Int](_.name.length) * }}} */ - 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))) + 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 2ebe2715..ed299874 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))) @@ -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( 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..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 @@ -42,12 +44,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 @@ -61,15 +63,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 - 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, 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,21 +108,29 @@ 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`, - * `.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: + /** 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. * @@ -114,8 +143,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,19 +160,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)) - - /** The identity optic — `S` is its own focus and modification has no effect. Carrier is - * [[data.Direct]] (no leftover). - * - * @group Constructors - */ - 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(s: S): F[X, C] = F.map(o.to(s), g) + def from(xd: F[X, D]): T = o.from(F.map(xd, f)) /** Cross-carrier `.andThen` — picks the direction via a summoned [[Morph]] when the two optics' * carriers differ. With one exception (`forget2multifocus` / `multifocus2forget`), cats-eo ships @@ -164,32 +182,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]] - ) - - /** 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.*`. @@ -197,17 +190,17 @@ 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 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)) /** 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`). @@ -215,8 +208,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). @@ -224,16 +224,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 - 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))) + 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. @@ -241,14 +245,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 @@ -365,31 +372,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 ab05c30a..f18c4218 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 @@ -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( 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..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 @@ -38,7 +37,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 +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((p.to(parent)._1, 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/Prism.scala b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala index 4180538d..85645edd 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Prism.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Prism.scala @@ -67,20 +67,20 @@ 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 - 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) @@ -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( @@ -188,23 +188,23 @@ 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 - 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) @@ -215,8 +215,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] 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..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,10 +1,11 @@ package dev.constructive.eo package optics +import accessor.ReverseAccessor import data.Direct /** 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`. * @@ -14,24 +15,33 @@ 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 [[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 - val to: Unit => Direct[X, Unit] = _ => Direct(()) - val from: Direct[X, A] => S = d => 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` + def to(u: Unit): Direct[X, Unit] = Direct(u) + def from(d: Direct[X, B]): T = reverseGet(d.value) + + /** 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 34fbba11..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,22 +27,21 @@ 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) - 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) @@ -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 b5a9b2f7..a2d91c67 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Traversal.scala @@ -35,33 +35,35 @@ 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]): 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]])) + 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]); () }) - (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 - } - } + MultiFocus(ta, buf.freezeAsPSVec[A]) + + 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 + // 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 +87,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): 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 * `MultiFocus[Function1[Int, *]]` — the homogeneous-arity Tuple_N-shaped traversal encoded as an @@ -103,16 +105,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): MultiFocus[Function1[Int, *]][X, A] = val read: Int => A = (i: Int) => i match case 0 => a(s) case 1 => b(s) - ((), read) + MultiFocus((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(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]( @@ -124,17 +124,16 @@ 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): 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) + MultiFocus((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(1), k(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]( @@ -147,15 +146,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): 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) + MultiFocus((), read) - val from: ((Unit, Int => B)) => T = { - case (_, k) => reverse(k(0), k(1), k(2), k(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/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala index 14f01f25..e87950f5 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala @@ -4,12 +4,12 @@ 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 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..378a1df5 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala @@ -6,12 +6,12 @@ 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 import data.{Direct, MultiFocus, SetterF} -import data.MultiFocus.given import data.MultiFocus.{collectList, collectMap} import optics.* import optics.Optic.* 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. 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..7ea3b211 100644 --- a/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala +++ b/jsoniter/src/main/scala/dev/constructive/eo/jsoniter/JsoniterTraversal.scala @@ -67,10 +67,10 @@ 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]): 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 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) - ((bytes, spans), psv) + MultiFocus((bytes, spans), psv) - val from: ((X, PSVec[A])) => Array[Byte] = pair => pair._1._1 + 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/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/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/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 5d1a5515..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 @@ -2,9 +2,8 @@ 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 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/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/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 08f34e91..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 @@ -2,8 +2,9 @@ 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 dev.constructive.eo.accessor.* +import dev.constructive.eo.compose.* import optics.Optic import optics.Optic.* @@ -32,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/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/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 be45abae..41bcb7fd 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -188,6 +188,26 @@ summons `Composer[Tuple2, Affine]` under the hood and morphs the Lens into the Affine carrier. No explicit `.morph` required on your end. +**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) The read-only projection of an Affine — a 0-or-1 focus with no @@ -346,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/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/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/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/EoSpecificLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala index 70275d41..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 @@ -8,10 +10,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, @@ -234,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 a7f23eab..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 @@ -20,10 +21,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 +31,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 756f4625..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 @@ -22,15 +24,11 @@ import optics.{ Optional, Prism, Review, + Setter, Traversal, } import optics.Optic.* import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, SetterF} -import data.Direct.given -import data.Forget.given -import data.Affine.given -import data.MultiFocus.given -import data.SetterF.given /** Non-law behavioural coverage for EO's optics: exercises the extension methods (`andThen`, * `reverse`, `foldMap`, `modifyA`, `morph`), the Lens/Prism/Traversal alternative constructors, @@ -66,8 +64,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): Forget[F][X, Int] = Forget(toF(s)) + def from(b: Forget[F][X, Int]): Int = fromF(b.value) // ----- Basic-factory smoke tests ------------------------------------- // @@ -165,7 +163,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]( @@ -314,6 +313,67 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: .and(chainOk) } + // 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: 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: Getter[Int, String] = 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 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: Setter[(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 ---------------------------------------------- // covers: Review.apply wraps an A => S build function; Review is a proper Optic @@ -398,11 +458,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(Forget(Some(7))) === 1016) + .and(sameCarrier.from(Forget(None)) === 998) val pureForget = forgetOpt[Option]( n => Option.when(n > 0)(n * 10), @@ -414,11 +474,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): MultiFocus[Option][X, Int] = + 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) val composedOk = (composed.modify(identity)(5) === 5113).and(composed.modify(identity)(-2) === -1) @@ -651,14 +710,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] = 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 - 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] = Forget(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) diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index 6cf9e04d..f2cbae81 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -1,16 +1,15 @@ 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 import optics.{AffineFold, Fold, Getter, Iso, Lens, Optic, Optional, Prism, Setter, Traversal} import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} -import data.Affine.given -import data.Forget.given -import data.MultiFocus.given -import data.SetterF.given import laws.{AffineFoldLaws, GetterLaws, IsoLaws, LensLaws, OptionalLaws, PrismLaws, SetterLaws} import laws.discipline.{ AffineFoldTests, @@ -205,7 +204,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 +255,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 +267,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)) + MultiFocus((x, x), PSVec.unsafeWrap[Int](arr)) ) // ----- MultiFocus[Function1[Int, *]] carrier laws ---------------- @@ -360,6 +359,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(Forget(_))) + checkAllForgetfulFunctorFor[Forget[List], Unit, Int]( "ForgetfulFunctor[Forget[List]] on Forget[List][Unit, Int]" ) @@ -384,7 +386,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: @@ -403,7 +404,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(MultiFocus(x, _))) + ) private given arbMultiFocusOptionIntInt: Arbitrary[MultiFocus[Option][Int, Int]] = Arbitrary( @@ -413,17 +416,21 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: Gen.const(Option.empty[Int]), Arbitrary.arbitrary[Int].map(Option(_)), ) - yield (x, o) + yield MultiFocus(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 => MultiFocus(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 => MultiFocus(x, Chain.fromSeq(xs)))) ) // covers: ForgetfulFunctor + ForgetfulTraverse over MultiFocus[List] @@ -469,14 +476,14 @@ 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]]] 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](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 01c2352d..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. @@ -78,7 +77,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) } 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. *