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.
Attributes
Members list
Grouped members
Scanner
Hand-rolled JSON byte scanner. Resolves a PathStep list against an Array[Byte] JSON document and returns the byte span [start, end) of the resolved value, or -1 for the start if the path doesn't match.
Hand-rolled JSON byte scanner. Resolves a PathStep list against an Array[Byte] JSON document and returns the byte span [start, end) of the resolved value, or -1 for the start if the path doesn't match.
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
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 / JsoniterPrism.field with 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
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
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. - 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
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.
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 Dynamicclass Objecttrait Matchableclass AnyShow all
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
- JsoniterPrism.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPrism.type
Macros backing JsoniterPrism.field(_.fieldName), selectDynamic, at(i), each and their JsoniterTraversal counterparts. Extracts the field name from the selector AST (same pattern as eo-circe's JsonPrismMacro) and emits a widenField / widenIndex / toTraversal call. The single divergence from the circe macro: one JsonValueCodec[B] summon instead of the Encoder[B] / Decoder[B] pair.
Macros backing JsoniterPrism.field(_.fieldName), selectDynamic, at(i), each and their JsoniterTraversal counterparts. Extracts the field name from the selector AST (same pattern as eo-circe's JsonPrismMacro) and emits a widenField / widenIndex / toTraversal call. 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)
Attributes
- Source
- JsoniterPrismMacro.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterPrismMacro.type
Attributes
- Companion
- class
- Source
- JsoniterTraversal.scala
- Supertypes
-
class Objecttrait Matchableclass Any
- Self type
-
JsoniterTraversal.type