dev.constructive.eo.jsoniter
Byte-level JSON optics over jsoniter-scala: JsoniterPrism and JsoniterTraversal focus a JSONPath '''directly in the encoded byte buffer''' — scanning for the span, decoding only the focused slice, and splicing writes back — so reads and writes never materialise the full document AST.
The typed entry point is JsoniterPrism[A] — the root Prism[Array[Byte], A]:
val streetP: JsoniterPrism[String] =
JsoniterPrism[Person].field(_.address).field(_.street)
streetP.modify(_.toUpperCase)(personBytes)
// → Array[Byte] with .address.street upper-cased —
// no Person ever materialised, only the String leaf decoded.
A document that sits inside a larger buffer is read in place, without copying it out first: streetP.inPlace.getOption(JsonSlice(buffer, offset, length)) — or drill in place from the start, JsoniterPrism.inPlace[Person].address.street. Nothing is copied, so the array must not change while the slice is in use, and a slice keeps the whole array reachable (see JsonSlice).
Attributes
Members list
Grouped members
Scanner
Hand-rolled JSON byte scanner. Resolves a PathStep list against a JSON document in an Array[Byte] — the whole array, or a range [from, until) of it — and returns the byte span [start, end) of the resolved value, or -1 for the start if the path doesn't match. Every helper threads the range's until as its bound, so a ranged scan never reads past it.
Hand-rolled JSON byte scanner. Resolves a PathStep list against a JSON document in an Array[Byte] — the whole array, or a range [from, until) of it — and returns the byte span [start, end) of the resolved value, or -1 for the start if the path doesn't match. Every helper threads the range's until as its bound, so a ranged scan never reads past it.
Why hand-rolled rather than reusing jsoniter-scala-core's JsonReader: the scanner only needs to skip-with-backtracking, not decode. Embedding JsonReader would force us to thread its mark / rollback state through every call site, and its API doesn't expose byte offsets directly. 100-ish LoC of dispatch is cheaper than the impedance mismatch.
The scanner is permissive about whitespace (skips it everywhere) and strict about structure (unbalanced quotes / brackets short-circuit to "miss"). It does NOT validate the document; an invalid JSON whose path step happens to resolve will return a span — decoding is the layer that surfaces validity failures.
Attributes
- Source
- JsonPathScanner.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsonPathScanner.type
Parser
Parser for the JSONPath subset we accept in JsoniterPrism / JsoniterTraversal. Grammar:
Parser for the JSONPath subset we accept in JsoniterPrism / JsoniterTraversal. Grammar:
path := '$' (step)*
step := '.' ident | '[' int ']' | '[*]'
ident := [A-Za-z_][A-Za-z_0-9]*
int := [0-9]+
[*] is the wildcard step — only meaningful inside a multi-focus JsoniterTraversal; JsoniterPrism rejects paths containing it at construction. Filters / recursive descent are still out of scope.
Attributes
- Source
- PathParser.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
PathParser.type
Optics
A JSON document held as a '''slice''' of a byte array: length bytes of array from offset on. The source (and, through .raw, the focus) of the in-place optics JsoniterInPlacePrism and JsoniterInPlaceTraversal — JsoniterPrism.inPlace[A], prism.inPlace, traversal.inPlace — which read a JSON value where it lies, without first copying it into an array of its own: a message cut out of a network buffer, say.
A JSON document held as a '''slice''' of a byte array: length bytes of array from offset on. The source (and, through .raw, the focus) of the in-place optics JsoniterInPlacePrism and JsoniterInPlaceTraversal — JsoniterPrism.inPlace[A], prism.inPlace, traversal.inPlace — which read a JSON value where it lies, without first copying it into an array of its own: a message cut out of a network buffer, say.
A slice is a '''view''', not a copy:
- Building one copies nothing, and an optic reads the array as it is when it runs. '''Don't''' '''mutate the array''' while the slice, or anything read from it, is in use.
- A slice keeps the '''whole''' array reachable, however few bytes it covers: a 300-byte slice of a 256 KiB read pins the 256 KiB. Call toArray for anything kept long-term.
Equality is by '''content''': two slices are equal when they hold the same bytes, wherever they sit in whatever arrays. So a slice in the middle of a larger buffer equals the same bytes copied out, and the optic laws can compare slices with ==.
Value parameters
- array
-
the backing array, shared, not copied
- length
-
how many bytes the slice holds
- offset
-
where the slice starts in
array
Attributes
- Companion
- object
- Source
- JsonSlice.scala
- Supertypes
-
class Objecttrait Matchableclass Any
A JsoniterPrism that reads its document '''in place''': the source is a JsonSlice, a range of a larger byte array, scanned and decoded where it lies. Made by prism.inPlace, or at the root by JsoniterPrism.inPlace to drill from there with the same .field / .at / .each / Dynamic surface (JsoniterPrismPath). Same path, same codec, and the same '''Laws & preconditions''' as the whole-array prism, with slices compared by content.
A JsoniterPrism that reads its document '''in place''': the source is a JsonSlice, a range of a larger byte array, scanned and decoded where it lies. Made by prism.inPlace, or at the root by JsoniterPrism.inPlace to drill from there with the same .field / .at / .each / Dynamic surface (JsoniterPrismPath). Same path, same codec, and the same '''Laws & preconditions''' as the whole-array prism, with slices compared by content.
'''No copy is made.''' The optic reads the caller's array where it lies, and .raw focuses a view of it. So:
- '''Don't mutate the array''' while the slice, or anything read from it (a
.rawfocus), is in use: the optic reads the bytes as they are when it runs. - A slice, and any slice focus, keeps the '''whole''' underlying array reachable, however few bytes it covers. Call JsonSlice.toArray for anything kept long-term.
- '''Writes never mutate the source.''' A write that splices (a
Hit) returns a slice over a '''fresh''' array holding only the slice's bytes —array[offset, start) ++ encoded ++ array[end, until)— at offset 0; the bytes around the slice are neither copied nor touched. A write that changes nothing (aMiss) hands back the input slice itself.
Carrier: dev.constructive.eo.data.Affine. Shape Optic[JsonSlice, JsonSlice, A, A, Affine]:
type X = (JsonSlice, (JsonSlice, Int, Int))— Miss carries the source slice for pass-through; Hit carries it with the focused span's ABSOLUTE[start, end)in its array.toscansslice.arrayfromslice.offsettoslice.untiland decodes the span viareadFromSubArray. Nothing outside the slice is read, and nothing is copied.fromon a Hit encodes the new focus and splices it in, as above.
The whole-array JsoniterPrism runs the same scan and splice over (0, length), so a slice reads and writes exactly like the same bytes copied out.
Attributes
- Source
- JsoniterPrism.scala
- Supertypes
-
trait Dynamictrait Serializableclass Objecttrait Matchableclass AnyShow all
A JsoniterTraversal that reads its document '''in place''': the source is a JsonSlice, a range of a larger byte array, scanned and decoded where it lies. Made by JsoniterTraversal.inPlace, or by .each on an in-place prism; drills further with the same .field / .at / Dynamic surface (JsoniterPath). Same path, same codec, same laws and write-side rules as the whole-array traversal, with slices compared by content.
A JsoniterTraversal that reads its document '''in place''': the source is a JsonSlice, a range of a larger byte array, scanned and decoded where it lies. Made by JsoniterTraversal.inPlace, or by .each on an in-place prism; drills further with the same .field / .at / Dynamic surface (JsoniterPath). Same path, same codec, same laws and write-side rules as the whole-array traversal, with slices compared by content.
'''No copy is made.''' The optic reads the caller's array where it lies. So:
- '''Don't mutate the array''' while the slice, or anything read from it, is in use: the optic reads the bytes as they are when it runs.
- A slice keeps the '''whole''' underlying array reachable, however few bytes it covers. Call JsonSlice.toArray for anything kept long-term.
- '''Writes never mutate the source''': see
frombelow.
Carrier: MultiFocus[PSVec], as the whole-array traversal's.
type X = (JsonSlice, List[JsonPathScanner.Span])— the source slice + each kept focus's ABSOLUTE span in its array.toscansslice.arrayfromslice.offsettoslice.untiland decodes every span in place. Nothing outside the slice is read, and nothing is copied.fromsplices every focus into a FRESH array holding only the slice's bytes, returned as a slice at offset 0; the source array is never written. No foci (or a span/foci mismatch) returns the input slice itself.
Attributes
- Source
- JsoniterTraversal.scala
- Supertypes
-
trait Dynamictrait Serializableclass Objecttrait Matchableclass AnyShow all
The path builder every eo-jsoniter optic drills with: .field(_.x), .at(i) and the Dynamic sugar .x, each compile-time checked against A's schema and appending one PathStep. Shared by the whole-array optics (JsoniterPrism, JsoniterTraversal) and their in-place faces (JsoniterInPlacePrism, JsoniterInPlaceTraversal), so one set of macros builds all four and .inPlace can be taken anywhere in a chain: JsoniterPrism.inPlace[Person].address and JsoniterPrism[Person].address.inPlace are the same optic.
The path builder every eo-jsoniter optic drills with: .field(_.x), .at(i) and the Dynamic sugar .x, each compile-time checked against A's schema and appending one PathStep. Shared by the whole-array optics (JsoniterPrism, JsoniterTraversal) and their in-place faces (JsoniterInPlacePrism, JsoniterInPlaceTraversal), so one set of macros builds all four and .inPlace can be taken anywhere in a chain: JsoniterPrism.inPlace[Person].address and JsoniterPrism[Person].address.inPlace are the same optic.
Type parameters
- A
-
the focus type the next step is checked against
- P
-
the optic one step further on: the same kind of optic over a new focus
Attributes
- Companion
- object
- Source
- JsoniterPath.scala
- Supertypes
-
trait Dynamicclass Objecttrait Matchableclass Any
- Known subtypes
-
class JsoniterInPlaceTraversal[A]class JsoniterInPlacePrism[A]class JsoniterPrism[A]class JsoniterTraversal[A]
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.
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 ofJsonCodecMaker.make[Whole]+ decoding the whole document, hold the bytes and read/write individual fields through JsoniterPrism.apply /.fieldwith leaf codecs only. Consuming code then follows the standard doctrine — consume via capability, construct via optic — leaving the CARRIER generic: signatures demandCanGetOption[T, WhatYouWant](orCanModify/CanFold), never naming the optic or the wire format,
def validateId[T](idCarrier: T)(using CanGetOption[T, Id]): Boolean
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.
Carrier: dev.constructive.eo.data.Affine. Shape Optic[Array[Byte], Array[Byte], A, A, Affine]:
type X = (Array[Byte], (Array[Byte], Int, Int))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 viareadFromSubArrayand packsHit(snd = (bytes, start, end), b = decoded). On miss (path doesn't resolve, decode throws), packsMiss(fst = bytes)for pass-through.from: Affine[X, A] => Array[Byte](phase 2) — Hit encodesh.bviawriteToArrayand splices into the source bytes at the recorded[start, end)span. Threearraycopys 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
'''In place.''' A document inside a larger buffer (one network read holding several messages, say) is read where it lies by the in-place face: prism.inPlace — or JsoniterPrism.inPlace[A], to drill from there — is the same prism over a JsonSlice (JsonSlice(bytes, offset, length), a view, nothing copied), with the same laws. This prism's whole-array scan and splice are the (0, length) case of the in-place face's. See JsoniterInPlacePrism for what not copying asks of the caller, and what writes return.
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] needs only the leaf codec, and returns the prism in an Either (a path is data).
'''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
todecodes eagerly, so.replaceonto a span whose current value doesn't decode asAis a Miss pass-through — template placeholders must be VALID encodings of the focus type. - '''A write that fails to ENCODE the new value is a silent pass-through''' (
fromis total by type — there is nowhere inOptic.fromto put the failure). On a drilled prism the original slice survives and the call reports success. Pinned byJsoniterPrismWriteSpec; seedocs/research/2026-09-22-exception-audit.md(class C) for the carrier-level fix, tracked as issue #117. - Only the ROOT prism (JsoniterPrism.apply, empty path) is additionally a lawful full-cover Prism: there reverseGet is a genuine build and
tomisses only on undecodable input.
Attributes
- Companion
- object
- Source
- JsoniterPrism.scala
- Supertypes
-
trait Dynamictrait Serializableclass Objecttrait Matchableclass AnyShow all
A prism's path builder: JsoniterPath's steps, plus .each, which splits the prism into a traversal T over the iterated array.
A prism's path builder: JsoniterPath's steps, plus .each, which splits the prism into a traversal T over the iterated array.
Type parameters
- T
-
the traversal
.eachhands off to
Attributes
- Companion
- object
- Source
- JsoniterPath.scala
- Supertypes
- Known subtypes
-
class JsoniterInPlacePrism[A]class JsoniterPrism[A]
Read-WRITE Traversal over JSON byte buffers. Resolves a wildcard-bearing JSONPath against Array[Byte], decodes each matched span via JsonValueCodec[A] on read, and encodes-and- splices every focus back in one pass on write. No runtime AST.
Read-WRITE Traversal over JSON byte buffers. Resolves a wildcard-bearing JSONPath against Array[Byte], decodes each matched span via JsonValueCodec[A] on read, and encodes-and- splices every focus back in one pass on write. No runtime AST.
Carrier: MultiFocus[PSVec] — the same carrier Traversal.each uses, peer to all the classical Traversal idioms. The choice over MultiFocus[List] matters for downstream composition: JsoniterTraversal[A].andThen(Traversal.each[List, A]) and similar same-carrier chains work via the existing mfAssocPSVec AssociativeFunctor instance, with zero-copy per-element reassembly. A List-carrier traversal would force a Composer hop or a manual .modify rebuild.
type X = (Array[Byte], List[JsonPathScanner.Span])— original bytes + each KEPT focus's span, 1:1 aligned with the foci (spans whose decode throws are dropped TOGETHER with their focus, so the write side can trust the pairing).to: Array[Byte] => (X, PSVec[A])— runs JsonPathScanner.findAll, decodes every span via the codec into a singleArray[Any]allocation, wraps it as a PSVec. Spans whose decode throws are silently dropped (matches the read semantic "fold the focuses that exist") — and stay byte-untouched on write.from: ((X, PSVec[A])) => Array[Byte]— encodes each focus viawriteToArrayand splices all spans back in a single pass (segments between spans arearraycopyd verbatim). A span↔foci length mismatch (an aggregatefromafter a shape-changing carrier op) passes the original bytes through unchanged, as does an empty focus set.
Composability: standard cats-eo extensions on Optic[..., MultiFocus[PSVec]] light up automatically via mfFold[PSVec], mfFunctor[PSVec], mfAssocPSVec, etc. — .foldMap, .modify, .headOption, .length, .exists, .modifyA, same-carrier .andThen all work without anything new shipping in this module. Mirrors dev.constructive.eo.avro.AvroTraversal's byte-carried write semantics. JsoniterPrism's '''Laws & preconditions''' apply verbatim: laws hold up to canonical re-encoding of the focused slices, and writes need decodable current focuses.
'''Write-side failure is per-element and silent''': a focus whose ENCODE throws keeps its ORIGINAL bytes while its siblings are written, so a partially applied write is reported as success (from is total by type). Pinned by JsoniterTraversalWriteSpec; the carrier-level fix is docs/research/2026-09-22-exception-audit.md (class C), tracked as issue #117.
A document inside a larger buffer is read where it lies by the in-place face, JsoniterTraversal.inPlace: the same traversal over a JsonSlice, nothing copied (see JsoniterInPlaceTraversal for what that asks of the caller). This traversal's whole-array splice is the (0, length) case of the in-place face's.
Typed drilling continues past the wildcard, mirroring eo-circe's JsonTraversal: JsoniterPrism[Basket].field(_.items).each.field(_.price) focuses price of every element. Each step is compile-time checked against the case-class schema and appends to the path.
Attributes
- Companion
- object
- Source
- JsoniterTraversal.scala
- Supertypes
-
trait Dynamictrait Serializableclass Objecttrait Matchableclass AnyShow all
Constructors
JsonSlice(bytes, offset, length) as a method on the array: the length bytes of bytes from offset on — (offset, length), as JsonSlice.apply takes them, not (from, until). Nothing is copied, and the same fail-fast bounds check applies. There is deliberately no implicit conversion from Array[Byte]: a slice shares its array, so taking one is spelled out.
JsonSlice(bytes, offset, length) as a method on the array: the length bytes of bytes from offset on — (offset, length), as JsonSlice.apply takes them, not (from, until). Nothing is copied, and the same fail-fast bounds check applies. There is deliberately no implicit conversion from Array[Byte]: a slice shares its array, so taking one is spelled out.
Attributes
- Source
- JsonSlice.scala
AST
One step of a JSON path expression:
One step of a JSON path expression:
.field— descend into an object property by exact name (powers JsoniterPrism).[i]— descend into an array element by zero-based index (powers JsoniterPrism).[*]— fan out across every element of the current array (powers JsoniterTraversal).
Filters / recursive descent are out of scope.
Attributes
- Source
- PathStep.scala
- Supertypes
-
trait Enumtrait Serializabletrait Producttrait Equalsclass Objecttrait Matchableclass AnyShow all
Type members
Classlikes
Attributes
- Companion
- class
- Source
- JsonSlice.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsonSlice.type
Attributes
- Companion
- trait
- Source
- JsoniterPath.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPath.type
Attributes
- Companion
- class
- Source
- JsoniterPrism.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPrism.type
Macros backing the JsoniterPath builder — .field(_.fieldName), selectDynamic, at(i) and each — for every eo-jsoniter optic: the whole-array JsoniterPrism / JsoniterTraversal and their in-place faces. Extracts the field name from the selector AST (same pattern as eo-circe's JsonPrismMacro) and emits a widenField / widenIndex / toTraversal call on the builder, which builds the next optic of the builder's own kind (P, or T for .each). The single divergence from the circe macro: one JsonValueCodec[B] summon instead of the Encoder[B] / Decoder[B] pair.
Macros backing the JsoniterPath builder — .field(_.fieldName), selectDynamic, at(i) and each — for every eo-jsoniter optic: the whole-array JsoniterPrism / JsoniterTraversal and their in-place faces. Extracts the field name from the selector AST (same pattern as eo-circe's JsonPrismMacro) and emits a widenField / widenIndex / toTraversal call on the builder, which builds the next optic of the builder's own kind (P, or T for .each). The single divergence from the circe macro: one JsonValueCodec[B] summon instead of the Encoder[B] / Decoder[B] pair.
val streetP: JsoniterPrism[String] =
JsoniterPrism[Person].field(_.address).field(_.street)
val streetIn: JsoniterInPlacePrism[String] =
JsoniterPrism.inPlace[Person].field(_.address).field(_.street)
Attributes
- Source
- JsoniterPrismMacro.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPrismMacro.type
Attributes
- Companion
- trait
- Source
- JsoniterPath.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPrismPath.type
Attributes
- Companion
- class
- Source
- JsoniterTraversal.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterTraversal.type