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 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 JsoniterPrism[A] extends Optic[Array[Byte], Array[Byte], A, A, Affine], Dynamic

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 / 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,
   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

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 (1e01.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.

Attributes

Companion
object
Source
JsoniterPrism.scala
Supertypes
trait Dynamic
trait Optic[Array[Byte], Array[Byte], A, A, Affine]
class Object
trait Matchable
class Any
final class JsoniterTraversal[A] extends Traversal[Array[Byte], Array[Byte], A, A], Dynamic

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.

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]]
class Object
trait Matchable
class Any
Show all

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 JsoniterPrism

Attributes

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

Attributes

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