March Docs

JsonStream

JsonStream: resumable, total, constant-memory streaming JSON tokenizer.

Design: specs/2026-07-30-json-streaming-design.md. Feed chunks with feed, close with finish. Every failure is an Err with an absolute byte offset; no input can panic, crash, or grow memory beyond the configured limits. The per-byte loop is strictly tail-recursive — keep it that way (see the design's "why a state machine" section).

All constructor names are prefixed (Ev/E/M/P/S/Js/St*): the ctor namespace is flat across modules, and Json.JsonValue already owns the natural names. Do not rename until FQN type identity lands.

Types

typeEventEvent =#
typeJsonStreamErrorJsonStreamError =#
typeJsonLimitsJsonLimits = JsonLimits(Int, Int)#

max_depth, max_token_bytes

ptypeJsModeJsMode =#

What the grammar expects next. MTop: the (next) top-level value. MDone: single-doc top-level value consumed. MArrFirst: just after '[' (value or ']'). MArrValue: after ',' in an array (value required). MArrNext: after an array element (',' or ']'). MObjFirst: just after '{' (key or '}'). MObjKey: after ',' in an object (key required). MObjColon: after a key (':' required). MObjValue: after ':'. MObjNext: after an object value (',' or '}').

ptypeJsEscJsEsc = SPlain | SEsc | SHex(Int, Int)#

String-escape scanner state: plain content, just after '\', or inside \uXXXX with (digits seen, value so far).

ptypeJsPartialJsPartial =#

In-flight token. String/number content accumulates as a REVERSED list of pieces joined once at completion (linear, unlike repeated ++). Every variant carries the token's absolute start offset for errors.

ptypeJsCfgJsCfg = JsCfg(Int, Int, Bool, Bool)#

max_depth, max_token_bytes, ndjson mode, raw-number mode

ptypeJsStateJsState = StOk(JsCfg, JsMode, List(JsMode), Int, JsPartial, Int)#

cfg, mode, container stack (parent modes), depth, in-flight token, absolute offset of the next unread byte.

ptypeJsFrameJsFrame =#

Functions

fnbuildbuild(chunks)#

Fold a whole single-document chunk stream into one Json.JsonValue, producing the same tree as Json.parse on the concatenated input but with this tokenizer's limits and error offsets.

Examples

march> JsonStream.build(Seq.from_list(["{\"a\": [tr", "ue]}"])) == Ok(Json.encode_object([("a", Json.encode_array([Json.encode_bool(true)]))]))
true
march> JsonStream.build(Seq.from_list(["1 2"]))
Err(EMalformed("unexpected data after top-level value", 2))
fndefault_limitsdefault_limits() do JsonLimits(512, 8000000) end#

The default limits: JsonLimits(512, 8000000), i.e. a maximum nesting depth of 512 and a maximum single-token (string or number) length of 8,000,000 bytes.

Examples

march> JsonStream.default_limits()
JsonLimits(512, 8000000)
fneach_typedeach_typed(path, cb)#

Read the NDJSON file at path line by line, decode each top-level value with the caller's derive Json-generated from_json, and call cb(record) per success. Returns Ok(count) of records decoded, or the first failure as a Json.DecodeError: a decode failure carries the failing record's line start offset ($.id (byte 4096): expected Int), a tokenizer error its own offset, and a file error offset -1.

JsonStream.each_typed("users.ndjson", fn u -> println(u.name))
fneach_valueeach_value(path, cb)#

Read the NDJSON file at path in 64 KiB chunks and call cb(value) for each completed top-level JsonValue, in order, with memory bounded by the largest single value. Returns Ok(()), or the first tokenizer error; a file error is reported as EMalformed("file error: ...", 0). Values seen before an error have already been passed to cb.

JsonStream.each_value("events.ndjson", fn v -> println(Json.to_string(v)))
fnerr_to_stringerr_to_string(e)#

Render a JsonStreamError as a one-line message ending in the absolute byte offset it carries.

Examples

march> JsonStream.err_to_string(EMalformed("expected a JSON value", 3))
"expected a JSON value at byte 3"
march> JsonStream.err_to_string(EDepthLimit(7))
"nesting deeper than max_depth at byte 7"
march> JsonStream.err_to_string(ETruncated(5))
"input truncated at byte 5"
fnfeedfeed(st, chunk)#

Feed one chunk of input to the tokenizer. Returns Ok((events, next_state)) with the events completed by this chunk, in order, or Err(JsonStreamError) on the first error. Chunk boundaries may fall anywhere, including inside a token: a partial token is carried in the state and its event is emitted by the chunk that completes it. A number at the very end of a chunk is not emitted until a delimiter arrives (or finish). Error offsets are absolute across all chunks fed; a malformed number reports its start offset.

Examples

march> let r1 = JsonStream.feed(JsonStream.start(), "[1, tr")
march> Result.map(r1, Tuple.first) == Ok([EvArrStart, EvNum(1.0)])
true
march> let r1 = JsonStream.feed(JsonStream.start(), "[1, tr")
march> let r2 = JsonStream.feed(Tuple.second(Result.unwrap(r1)), "ue]")
march> Result.map(r2, Tuple.first) == Ok([EvBool(true), EvArrEnd])
true
march> Result.map_err(JsonStream.feed(JsonStream.start(), "[1 2]"), JsonStream.err_to_string)
Err("expected ',' or ']' in array at byte 3")
fnfinishfinish(st)#

Signal end of input. Returns Ok(events) with any final events (a number still in flight at end of input), or ETruncated(offset) if the input ended inside a string or literal (offset of its start), inside an open container, or (single-document mode) before any value. A malformed trailing number fails with EMalformed.

Examples

march> JsonStream.finish(Tuple.second(Result.unwrap(JsonStream.feed(JsonStream.start(), "12")))) == Ok([EvNum(12.0)])
true
march> JsonStream.finish(Tuple.second(Result.unwrap(JsonStream.feed(JsonStream.start(), "\"ab"))))
Err(ETruncated(0))
march> JsonStream.finish(Tuple.second(Result.unwrap(JsonStream.feed(JsonStream.start(), "01"))))
Err(EMalformed("invalid number", 0))
fnfoldfold(chunks, z, f)#

Tokenize a whole Seq of chunks as ONE JSON document (start() limits), folding f(acc, event) over every event in order. Returns Ok(acc), or the first tokenizer error. The fold is eager: after an error, the remaining chunks are still consumed (and ignored).

Examples

march> JsonStream.fold(Seq.from_list(["[1,", "2]"]), 0, fn (n, _) -> n + 1)
Ok(4)
march> JsonStream.fold(Seq.from_list(["[1,", "2"]), 0, fn (n, _) -> n + 1)
Err(ETruncated(4))
fnstartstart() do StOk(cfg_of(default_limits(), false), MTop, Nil, 0, PNone, 0) end#

A fresh tokenizer state for a single JSON document, with default_limits(). Data after the first complete top-level value is rejected. Feed it with feed, then close it with finish.

Examples

march> JsonStream.finish(JsonStream.start())
Err(ETruncated(0))
fnstart_ndjsonstart_ndjson() do StOk(cfg_of(default_limits(), true), MTop, Nil, 0, PNone, 0) end#

A fresh tokenizer state for NDJSON (any number of top-level values, whitespace-separated), with default_limits(). Unlike start, an input with no values at all finishes successfully.

Examples

march> Result.map(JsonStream.feed(JsonStream.start_ndjson(), "1\n{}\n"), Tuple.first) == Ok([EvNum(1.0), EvObjStart, EvObjEnd])
true
march> JsonStream.finish(JsonStream.start_ndjson())
Ok([])
fnstart_ndjson_withstart_ndjson_with(l) do StOk(cfg_of(l, true), MTop, Nil, 0, PNone, 0) end#

Like start_ndjson, with caller-supplied JsonLimits(max_depth, max_token_bytes).

Examples

march> JsonStream.feed(JsonStream.start_ndjson_with(JsonLimits(8, 2)), "123")
Err(ETokenLimit(0))
fnstart_withstart_with(l) do StOk(cfg_of(l, false), MTop, Nil, 0, PNone, 0) end#

Like start, with caller-supplied JsonLimits(max_depth, max_token_bytes). Opening a container deeper than max_depth fails with EDepthLimit; a string or number token longer than max_token_bytes fails with ETokenLimit (offset of the token start).

Examples

march> JsonStream.feed(JsonStream.start_with(JsonLimits(1, 100)), "[[1]]")
Err(EDepthLimit(1))
fnwith_raw_numberswith_raw_numbers(st)#

Emit EvNumRaw(lexeme) instead of EvNum(float), preserving integers above 2^53 that a Float cannot represent.

Composes with every constructor:

let st = JsonStream.with_raw_numbers(JsonStream.start_ndjson())

Validation is unchanged -- a malformed number is still rejected with the same error at the same offset. Intended for a fresh state; applied mid-stream it affects only numbers completed after the call.

Examples

march> let raw = JsonStream.feed(JsonStream.with_raw_numbers(JsonStream.start()), "[12345678901234567890]")
march> Result.map(raw, Tuple.first) == Ok([EvArrStart, EvNumRaw("12345678901234567890"), EvArrEnd])
true