Read-write optic over a JSON byte buffer. Resolves a JSONPath subset against Array[Byte], decodes the focused slice via JsonValueCodec[A] on read, encodes-and-splices on write. No runtime AST.
'''Two usage modes''' — pick deliberately:
'''Layer on an existing codec''' (hot-path reads): you keep materialising your case class elsewhere and use a prism for the one or two fields on the hot path.
'''Replace the materialised model''' (optics-as-evidence): the wire Array[Byte] IS the data structure. Instead of JsonCodecMaker.make[Whole] + decoding the whole document, hold the bytes and read/write individual fields through JsoniterPrism.apply / JsoniterPrism.field with leaf codecs only. Consuming code then follows the standard doctrine — consume via capability, construct via optic — leaving the CARRIER generic: signatures demand CanGetOption[T, WhatYouWant] (or CanModify / CanFold), never naming the optic or the wire format,
and a prism given at the use site is the evidence that instantiates T = Array[Byte]. No JsonValueCodec[Whole] is ever derived — do NOT go looking for a codec-derivation API in this module; not needing one is the point. See the migration recipe in the jsoniter integration docs.
Fst[X] = Array[Byte] — original source bytes (Miss carries this for pass-through)
Snd[X] = (Array[Byte], Int, Int) — bytes + the focused span (Hit carries this; phase-2 splice writes use the span to memcpy the new encoding back in)
to: Array[Byte] => Affine[X, A] runs the path scanner; on hit, decodes the slice via readFromSubArray and packs Hit(snd = (bytes, start, end), b = decoded). On miss (path doesn't resolve, decode throws), packs Miss(fst = bytes) for pass-through.
from: Affine[X, A] => Array[Byte] (phase 2) — Hit encodes h.b via writeToArray and splices into the source bytes at the recorded [start, end) span. Three arraycopys into a fresh buffer; cost is O(src.length). Miss returns the original bytes unchanged.
Composability: the standard cats-eo extension methods on Optic[..., Affine] light up automatically — .foldMap, .modify, .replace, .andThen, etc. No new Composer / AssociativeFunctor needed; reuses the existing Affine machinery.
Typed drilling (compile-time checked against the case-class schema, mirroring eo-circe's JsonPrism surface):
val streetP = JsoniterPrism[Person].field(_.address).field(_.street)
val street2 = JsoniterPrism[Person].address.street // Dynamic sugar, same optic
val firstT = JsoniterPrism[Basket].field(_.items).at(0) // array index
val allT = JsoniterPrism[Basket].field(_.items).each // JsoniterTraversal
Each drilled step needs a JsonValueCodec for its focus type in scope (derive leaf codecs via JsonCodecMaker.make from jsoniter-scala-macros, which callers add themselves). To skip intermediate codecs entirely, use the string-path factory: JsoniterPrism.fromPath[String]("$.a.b") needs only the leaf codec.
'''Laws & preconditions''' (normative):
The Optional laws hold '''up to canonical re-encoding of the focused slice''': modify(identity) re-encodes the focus through the codec, normalising number forms (1e0 → 1.0), escapes, and key order INSIDE the span. Bytes outside the span (whitespace, sibling formatting) are never touched. Byte-for-byte identity holds only for focus slices already in the codec's canonical form.
'''Writes require a decodable current focus''': the Affine to decodes eagerly, so .replace onto a span whose current value doesn't decode as A is a Miss pass-through — template placeholders must be VALID encodings of the focus type.
Only the ROOT prism (JsoniterPrism.apply, empty path) is additionally a lawful full-cover Prism: there reverseGet is a genuine build and to misses only on undecodable input.
Build-then-observe across the build-output ⇄ read-input seam, preserving structure, on a shared carrierF. Flip self (it must be reversible — Accessor[F]andReverseAccessor[F], i.e. an Iso or Review over Direct) so it reads T from B, then andThenthat under the same carrier. The result is the full Optic[B, A, C, D, F], not a collapsed getter: its read capability follows the carrier (.get for Direct), and self's read focus A survives as the composite's write-back focus.
Build-then-observe across the build-output ⇄ read-input seam, preserving structure, on a shared carrierF. Flip self (it must be reversible — Accessor[F]andReverseAccessor[F], i.e. an Iso or Review over Direct) so it reads T from B, then andThenthat under the same carrier. The result is the full Optic[B, A, C, D, F], not a collapsed getter: its read capability follows the carrier (.get for Direct), and self's read focus A survives as the composite's write-back focus.
This is exactly self.reverse.andThen(that). The motivating case is ana.cross(cata): a Review (the unfold) crossed with a getter on the built S (the fold) — a (materializing) hylomorphism whose .get reads the folded value. When that sits on a different carrier (a Prism, a Fold, …), the cross-carrier cross overload below is selected instead.
Seam: that's source is self's T and its back-type is self's S.
Existential leftover carried alongside the focus — the type-level witness the carrier uses to rebuild T. Concrete at construction (Lens.apply sets X = S, Prism.apply sets X = S, …) and abstract when the optic is bound to Optic[…, F] without refinement.
Existential leftover carried alongside the focus — the type-level witness the carrier uses to rebuild T. Concrete at construction (Lens.apply sets X = S, Prism.apply sets X = S, …) and abstract when the optic is bound to Optic[…, F] without refinement.
Re-focus this prism on the '''raw encoded slice''': same path, same span scan, but the focus is the focused value's own JSON bytes — captured verbatim on read (readRawValAsBytes), spliced verbatim on write (writeRawVal). No typed decode ever runs. The seam for format-level consumers that transform the slice as a document of its own — e.g. the avro bridge's structural .avro face (dev.constructive.eo.avro.jsoniter).
Re-focus this prism on the '''raw encoded slice''': same path, same span scan, but the focus is the focused value's own JSON bytes — captured verbatim on read (readRawValAsBytes), spliced verbatim on write (writeRawVal). No typed decode ever runs. The seam for format-level consumers that transform the slice as a document of its own — e.g. the avro bridge's structural .avro face (dev.constructive.eo.avro.jsoniter).
Encode a standalone. Lawful only for the ROOT prism (a real Prism.reverseGet — Array[Byte] A whole-document); on a drilled prism it cannot restore siblings, which is why the Optic seam carries the source bytes instead of calling this.
Encode a standalone. Lawful only for the ROOT prism (a real Prism.reverseGet — Array[Byte] A whole-document); on a drilled prism it cannot restore siblings, which is why the Optic seam carries the source bytes instead of calling this.
Dynamic field sugar — JsoniterPrism[Person].name lowers to JsoniterPrism[Person].field(_.name). Compile-time checked against A's case fields; the field's JsonValueCodec is summoned at the call site.
Dynamic field sugar — JsoniterPrism[Person].name lowers to JsoniterPrism[Person].field(_.name). Compile-time checked against A's case fields; the field's JsonValueCodec is summoned at the call site.
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 compose.ReadCompose (Getter / PickFold / ForgetFold).
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 compose.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.
Compose with another optic under the shared carrier F. Requires AssociativeFunctor[F]. Cross-carrier composition (Lens → Optional, Lens → Traversal, …) goes through the Morph-summoning overload of this same method.
Compose with another optic under the shared carrier F. Requires AssociativeFunctor[F]. Cross-carrier composition (Lens → Optional, Lens → Traversal, …) goes through the Morph-summoning overload of this same method.
Attributes
Example
case class Address(street: String)
case class Person(address: Address)
val streetLens = lens[Person](_.address).andThen(lens[Address](_.street))