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 Object
trait Matchable
class Any
Self type

Parser

object PathParser

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 Object
trait Matchable
class Any
Self type
PathParser.type

Optics

final class JsonSlice

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 Object
trait Matchable
class 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 .raw focus), 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 (a Miss) 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.
  • to scans slice.array from slice.offset to slice.until and decodes the span via readFromSubArray. Nothing outside the slice is read, and nothing is copied.
  • from on 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 Dynamic
trait Optic[JsonSlice, JsonSlice, A, A, Affine]
trait Serializable
class Object
trait Matchable
class Any
Show 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 from below.

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.
  • to scans slice.array from slice.offset to slice.until and decodes every span in place. Nothing outside the slice is read, and nothing is copied.
  • from splices 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 Dynamic
class Traversal[JsonSlice, JsonSlice, A, A]
trait Serializable
class Object
trait Matchable
class Any
Show all
trait JsoniterPath[A, P[_]] extends Dynamic

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 Dynamic
class Object
trait Matchable
class Any
Known subtypes
final class JsoniterPrism[A] extends Optic[Array[Byte], Array[Byte], A, A, Affine], JsoniterPrismPath[A, JsoniterPrism, JsoniterTraversal]

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 of JsonCodecMaker.make[Whole] + decoding the whole document, hold the bytes and read/write individual fields through JsoniterPrism.apply / .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,
   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 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

'''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 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.
  • '''A write that fails to ENCODE the new value is a silent pass-through''' (from is total by type — there is nowhere in Optic.from to put the failure). On a drilled prism the original slice survives and the call reports success. Pinned by JsoniterPrismWriteSpec; see docs/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 to misses only on undecodable input.

Attributes

Companion
object
Source
JsoniterPrism.scala
Supertypes
trait Dynamic
trait Optic[Array[Byte], Array[Byte], A, A, Affine]
trait Serializable
class Object
trait Matchable
class Any
Show all
trait JsoniterPrismPath[A, P[_], T[_]] extends JsoniterPath[A, P]

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 .each hands off to

Attributes

Companion
object
Source
JsoniterPath.scala
Supertypes
trait JsoniterPath[A, P]
trait Dynamic
class Object
trait Matchable
class Any
Known subtypes
final class JsoniterTraversal[A] extends Traversal[Array[Byte], Array[Byte], A, A], JsoniterPath[A, JsoniterTraversal]

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 single Array[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 via writeToArray and splices all spans back in a single pass (segments between spans are arraycopyd verbatim). A span↔foci length mismatch (an aggregate from after 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 Dynamic
class Traversal[Array[Byte], Array[Byte], A, A]
trait Optic[Array[Byte], Array[Byte], A, A, MultiFocus[PSVec]]
trait Serializable
class Object
trait Matchable
class Any
Show all

Constructors

def jsonSlice(offset: Int, length: Int): JsonSlice

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

enum PathStep

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 Enum
trait Serializable
trait Product
trait Equals
class Object
trait Matchable
class Any
Show all

Type members

Classlikes

object JsonSlice

Attributes

Companion
class
Source
JsonSlice.scala
Supertypes
class Object
trait Matchable
class Any
Self type
JsonSlice.type
object JsoniterPath

Attributes

Companion
trait
Source
JsoniterPath.scala
Supertypes
class Object
trait Matchable
class Any
Self type
object JsoniterPrism

Attributes

Companion
class
Source
JsoniterPrism.scala
Supertypes
class Object
trait Matchable
class Any
Self 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 Object
trait Matchable
class Any
Self type

Attributes

Companion
trait
Source
JsoniterPath.scala
Supertypes
class Object
trait Matchable
class Any
Self type

Attributes

Companion
class
Source
JsoniterTraversal.scala
Supertypes
class Object
trait Matchable
class Any
Self type