These libraries are experimental. APIs may change without notice. Generated from source with koruc 0.1.7 on 8/19/2026.
yyjson
@korulang/yyjson@0.1.0yyjson (fast conformant JSON) for Koru with phantom obligation types — parse, read, build and render
yyjson/index.kz · 26 tors
@korulang/yyjson — the definitive Koru yyjson edition (read + write) · 59 more lines
@korulang/yyjson — the definitive Koru yyjson edition (read + write)
Lifts yyjson (the fastest conformant JSON library — single-file C, used by
PHP internals, ScyllaDB, and others) into a phantom-obligation-typed Koru
edition. yyjson's read API hands back an arena-owned `yyjson_doc`: every
`yyjson_val*` you navigate to (object members, array elements, scalars) is
a raw pointer INTO that arena. Free the doc and every value pointer you
were holding is instantly dangling — a use-after-free that in C is a
silent footgun and in production is a CVE waiting to happen.
This edition compiles that footgun away:
• The parsed document is a phantom obligation (`open`): the build FAILS
if you forget to free it (`close`), same shape as sqlite3's
Connection / pcre2's Regex.
• Every value-reading event (`root`, `object.get`, `array.get`,
`array.each`, `object.each`, `as.*`) takes `doc: *Doc<open>` as a
BORROW, not just documentation — the compiler requires the caller to
still hold the live `open!` obligation on that exact binding. Call
`close` first and then try to read a value and the phantom checker
rejects it with "Use-after-discharge" — the value cannot be read once
its doc is gone, enforced at compile time, not discovered at 3am from
a crash dump.
• Wrong-type reads (`as.string` on a number, `as.int` on an object) are
a loud `wrong-type` branch, never a silently-defaulted zero or empty
string — yyjson's own C accessors return 0/NULL on type mismatch,
which we refuse to expose directly because it hides bugs.
USAGE:
~import koru/yyjson
~koru/yyjson:parse(text: "{\"name\": \"ada\", \"age\": 36}")
| ok doc |> koru/yyjson:root(doc): root_v |> koru/yyjson:object.get(doc, v: root_v, key: "name")
| found name_v |> koru/yyjson:as.string(doc, v: name_v)
| ok name |> std/io:print.ln("name: {{ name:s }}") |> koru/yyjson:close(doc)
| wrong-type |> std/io:print.ln("name was not a string") |> koru/yyjson:close(doc)
| not-found |> std/io:print.ln("no name field") |> koru/yyjson:close(doc)
| err e |> std/io:print.ln("parse failed at byte {{ e.pos:d }}: {{ e.msg:s }}")
(Note the explicit `v: name_v` label on the second call — bare positional
args only punning-match when the local binding's name equals the callee's
own parameter name, as `doc` does above; `root_v`/`name_v` do not match `v`
and need the label. See tests/basic.kz for this exact flow, verified
end-to-end.)
Scope: BOTH directions. The read path (`parse` … `close`, above) navigates
objects/arrays, iterates both, and reads scalar leaves. The write path
(`build` … `build.close`, at the bottom of this file) mints nodes,
assembles objects and arrays, and renders to text — with the rendered
buffer carrying its own second obligation, because yyjson allocates it
from libc rather than the document arena.
Not bound, deliberately: reading or writing files by path
(`yyjson_read_file` / `yyjson_mut_write_file` — koru already has
`std/io:read-file`, and a second file API in a JSON package earns
nothing), the immutable/mutable bridges (`yyjson_doc_mut_copy`,
`yyjson_mut_doc_imut_copy`), JSON Pointer / Patch / Merge-Patch, unsigned
64-bit integers (no `as.uint` on the read side to mirror, so the hole is
symmetric), and all write flags but `PRETTY`. See README for the
reasoning on each.
Phantom lifecycles
Derived from the phantom labels in the declarations below — state! issues an
obligation the compiler will chase, !state discharges it, a bare state holds it without moving it. Nothing here is hand-drawn.
Doc 1 state open!Builder 1 state open!string 1 state rendered!// Parse — mint the `open` obligation
//
// Never a silent null: a malformed document is a loud `err` carrying
// yyjson's own error code, the byte offset it choked on, and its message.
~pub tor parse { text: string }
| ok *Doc<open!>
| err { code: i32, pos: usize, msg: string }// Close — discharge the `open` obligation
~pub tor close { doc: *Doc<!open> }// Root — the entry point into the tree. BORROWS `doc` (bare <open>, not
// consumed): the caller keeps the live `open!` obligation and still owes
// exactly one `close`. A successfully parsed document always has a root, so
// this is a direct return, not a branch.
~pub tor root { doc: *Doc<open> } -> *Value// Object / array navigation — every reader BORROWS `doc`, just like `root`.
// yyjson's own accessors are null-safe on a type mismatch (an object.get on
// a non-object, or an array.get on a non-array, is a documented `NULL`, never
// a crash), so a wrong-shape read is folded straight into the miss branch
// rather than needing a separate type probe.
~pub tor object.get { doc: *Doc<open>, v: *Value, key: string }
| found *Value
| not-found~pub tor array.get { doc: *Doc<open>, v: *Value, index: i64 }
| found *Value
| out-of-range// array.each — iterate every element of an array value. `! item` fires once
// per element, its body inlined at the call site (same effect-branch cursor
// shape as sqlite3's `! row` and pcre2's `! match`). Each `*Value` handed to
// the body is a borrow into the doc's arena — safe for the duration of the
// loop, and (like every reader here) only reachable while `doc` still carries
// `open`.
~pub tor array.each { doc: *Doc<open>, v: *Value }
! item *Value
| done
| not-an-array// object.each — iterate every key/value pair of an object value. `! entry`
// fires once per member with a reused scratch `Entry{ key, value }` — same
// stack-scratch convention as `__match`/`__row` above: mutated in place per
// iteration, never heap-allocated.
~pub tor object.each { doc: *Doc<open>, v: *Value }
! entry *Entry
| done
| not-an-object// Scalar leaves — read a value as a concrete type. A type mismatch is a
// loud `wrong-type` branch (yyjson's raw accessors return 0/NULL on
// mismatch; we refuse to expose that footgun directly).
~pub tor as.string { doc: *Doc<open>, v: *Value }
| ok string
| wrong-type~pub tor as.int { doc: *Doc<open>, v: *Value }
| ok i64
| wrong-type~pub tor as.double { doc: *Doc<open>, v: *Value }
| ok f64
| wrong-type~pub tor as.bool { doc: *Doc<open>, v: *Value }
| ok bool
| wrong-type// Build / close — mint and discharge the write-side `open` obligation
//
// A direct return rather than `| ok | err`: `yyjson_mut_doc_new` fails only
// on allocation failure, and this module has no OOM branch anywhere (see
// `parse`'s `catch @panic` and `root`'s `orelse @panic`). Inventing one here
// would put an unreachable arm on every caller's happy path for nothing.
~pub tor build {} -> *Builder<open!>~pub tor build.close { doc: *Builder<!open> }// new.* — mint a node. The write-side mirror of `as.*`: where `as.string`
// reads a `*Value` out as a koru string, `new.string` mints a `*Node` from
// one. Every one BORROWS `doc`, like every reader above, and every one is a
// direct return for the reason given at `build`.
//
// A minted node is NOT attached to anything yet — it is loose in the arena
// until `object.set`, `array.push` or `root.set` places it. A node you mint
// and never place is not a leak (the arena frees it at `build.close`), it is
// simply absent from the output.
~pub tor new.object { doc: *Builder<open> } -> *Node~pub tor new.array { doc: *Builder<open> } -> *Node~pub tor new.string { doc: *Builder<open>, text: string } -> *Node~pub tor new.int { doc: *Builder<open>, value: i64 } -> *Node~pub tor new.double { doc: *Builder<open>, value: f64 } -> *Node~pub tor new.bool { doc: *Builder<open>, value: bool } -> *Node~pub tor new.null { doc: *Builder<open> } -> *Node// Assembly — place a node. `object.set` / `array.push` are the mirrors of
// `object.get` / `array.get`, but deliberately NOT branch-shaped like them,
// and the difference is worth naming because it looks like an inconsistency
// and is not.
//
// This file's actual rule, read off the read side: a DATA condition gets a
// loud branch (`not-found`, `out-of-range`, `wrong-type` — all of them
// depend on what was in the JSON), and an IMPOSSIBLE-or-programmer condition
// aborts (`root`'s `orelse @panic`, `parse`'s OOM `catch @panic`). A
// wrong-shape target here is the second kind, not the first: a `*Node` can
// only ever come from a `new.*` call in the caller's own flow — there is no
// tor that hands you a Node out of parsed data — so whether `v` is an object
// is fixed at the line that minted it, three lines up, and never depends on
// input.
//
// Making it a branch would be worse than a panic, not safer. The arm is
// unreachable by construction, so every caller writes it by rote, fills it
// with the same cleanup as the happy path, and thereby converts a node-mixup
// typo into a SILENTLY MISSING FIELD in the output — precisely the class of
// quiet wrong answer this whole edition exists to refuse. It also buries the
// one branch on this side that genuinely can fire (`render`'s `err`) at the
// bottom of a ten-deep pyramid of arms that cannot. An abort naming the tor
// and the shape it actually got is the loudest available answer, and it
// leaves building a document as one flat chain.
~pub tor object.set { doc: *Builder<open>, v: *Node, key: string, value: *Node }~pub tor array.push { doc: *Builder<open>, v: *Node, value: *Node }// root.set — the mirror of `root`. Where `root` reads the tree's entry point
// out of a parsed doc, `root.set` installs it on one being built. A builder
// with no root renders as an `err` (yyjson refuses to write a rootless
// document), so this is not optional decoration — it is the step that turns
// a pile of nodes into a document.
~pub tor root.set { doc: *Builder<open>, v: *Node }// render — serialize to text, minting the second obligation
//
// The returned string is UTF-8, minimally escaped, and byte-identical to
// what a correct hand-rolled escaper produces: `"` -> `\"`, `\` -> `\\`,
// control bytes -> `\n` / `\t` / `\uXXXX`, and non-ASCII left as raw UTF-8.
// We pass flag 0 (YYJSON_WRITE_NOFLAG) deliberately — ESCAPE_UNICODE would
// turn `å` into `\u00e5` and ESCAPE_SLASHES would turn a model id like
// `anthropic/claude-haiku-4.5` into `anthropic\/claude-haiku-4.5`. Both are
// legal JSON, neither is what a hand-rolled body produces, and silently
// differing from the obvious output is how a "drop-in" replacement stops
// being one.
//
// `ok` carries `rendered!`. Hand it to `render.free`.
~pub tor render { doc: *Builder<open> }
| ok string<rendered!>
| err { code: i32, msg: string }// Same as `render`, indented for humans. yyjson's own PRETTY flag (4-space
// indent). Carries the same `rendered!` obligation — same buffer, same
// allocator, same `render.free`.
~pub tor render.pretty { doc: *Builder<open> }
| ok string<rendered!>
| err { code: i32, msg: string }// Discharge the `rendered!` obligation on a buffer `render` / `render.pretty`
// handed back. The one disposer for the one label, same shape as
// `std/io:free`.
~pub tor render.free { text: string<!rendered> }