std::json
JSON, written in Khora.
Nothing here is an intrinsic and nothing here is foreign: the encoder walks
bytes with String::byte and the parser is recursive descent over the same.
That is the point as much as the module is — if reading a config file needed
the compiler’s help, so would every format above it.
Runs of ordinary text are copied with String::slice, never byte by
byte. A lone continuation byte is not valid UTF-8, so String::from_bytes
on one stops the program; slicing at an escape is safe because every byte
JSON escapes is below 128 and so never appears inside a multi-byte
character. Getting that wrong is a crash on the first non-English string,
which is exactly the input nobody tests with.
API at a glance
Section titled “API at a glance”Types: Json · JsonError · Field
Methods: Json
Trait implementations: Show for Json
Functions: member · object · encode · quote · parse · parse_with_depth
pub type Json = | Null | Bool(value: Bool) | Number(literal: String) | Text(value: String) | Array(items: List<Json>) | Object(fields: Map<String, Json>);A JSON value.
Number carries the token’s text, not a Float. RFC 8259 §6 admits a
number of arbitrary precision; what it warns is that implementations
commonly use IEEE 754 doubles and that anything outside the range of an
exact integer double may not survive a round trip between two of them. A
parser that decodes through a double is one of those implementations:
parse("{\"big\": 9007199254740993}") -> 9007199254740992Off by one, quietly, on an ordinary sixty-four-bit identifier — and worse in
kind on a decimal, where 10.10 became the nearest double and was never
exactly recoverable again. In the language that gives Decimal a 128-bit
significand for money, and whose configuration reader refuses exponent
notation on the grounds that a number arriving as 1e-3 has been through a
float somewhere, that was the wrong default.
So the parser stores what it read and each accessor decides what to make of
it: Json::integer goes through Int::of_string and is exact,
Json::number goes through a Float and inherits the format’s usual limit,
and Json::literal hands over the text for anything else — Decimal most
of all, which std::json cannot import without a cycle.
Encoding writes the literal back, so a document that round-trips through this keeps every digit it arrived with.
JsonError
Section titled “JsonError”pub type JsonError = { pub at: Int, pub expected: String,};Where the parser stopped and what it wanted there.
A record rather than a variant, because every failure has both halves and a
variant per expectation would be a list of the parser’s internals. The
offset is in bytes, which is what String::byte counts and what a
caller can slice with.
pub type Field = { pub name: String, pub value: Json,};One field of an object. entries reports them sorted by name, which is
the same order every time and is not the order the document wrote them in.
Methods
Section titled “Methods”impl Jsonpub fn field(self, name: String) -> Option<Json>The field, if this is an object and it has one.
The one accessor that earns its place twice over: reaching into an object
is what almost every consumer of a parsed document does, and writing the
match by hand at each step turns three levels of nesting into fifteen
lines.
pub fn text(self) -> Option<String>The string here, or None if this is something else. No coercion: the
number 3 is not text.
This and the accessors below it are one per shape, so that a caller can
ask “is it what I expected” without a match whose other arms it does
not care about. Option rather than a failure, because a document being
the wrong shape is the caller’s business to name — this module does not
know what the field was for.
number
Section titled “number”pub fn number(self) -> Option<Float>The number here as a Float, or None if this is something else.
No coercion, as with text: the string "3" is not a number, and a
library that read it as one would hide the day a client started quoting
its integers.
A Float holds about fifteen significant digits, so this is the lossy
reading and it is lossy by choice rather than by accident — the document’s
own text is still there. integer for an identifier or a count, literal
for money.
integer
Section titled “integer”pub fn integer(self) -> Option<Int>The number here as an Int, exactly, or None.
This is the accessor an identifier wants. 9007199254740993 through
number is 9007199254740992; through here it is itself, because the
digits never went near a Float.
None for a number no Int can hold, and for one that is not whole.
A whole number written as 1.0 or 1e3 is still whole, and those go
through a Float to be read.
literal
Section titled “literal”pub fn literal(self) -> Option<String>The number here as the document wrote it, or None.
For everything the two above cannot answer, and Decimal is the reason
there has to be one: money must not go through a Float, and std::json
cannot import std::decimal without a cycle. So the text comes out and
Decimal::of_string takes it from there.
boolean
Section titled “boolean”pub fn boolean(self) -> Option<Bool>The boolean here, or None.
pub fn items(self) -> Option<List<Json>>The elements, if this is an array. None for anything else, including an
object — a JSON array and a JSON object are different things.
of_int
Section titled “of_int”pub fn of_int(value: Int) -> JsonA number from an Int, with every digit kept.
Json::Number takes text, and text is easy to get wrong — a caller
writing Json::Number(Int::to_string(n)) is one Float::to_string away
from a silently lossy number. These two are the way to make one.
of_float
Section titled “of_float”pub fn of_float(value: Float) -> Option<Json>A JSON number holding value, or None where JSON has no way to say it.
JSON has no infinity and no NaN, so this answers None for them.
Float::to_string writes inf, which is not a JSON document and which
std::json::parse refuses – a module that emitted it would be producing
something it could not read back, and any other parser would refuse it
too.
The test is a round trip through Float::of_string, whose documented
grammar is JSON’s and already excludes both names, rather than a
finiteness predicate Float does not have. That keeps one definition of
“a number this module will emit” instead of two that can drift.
Option rather than a silent null, because a caller reaching for this
has a number in hand and should decide what an unrepresentable one means:
dropping the field, Json::Null, or refusing the whole document are all
reasonable and this is not the place to pick. derive(Encode) has no such
choice – see impl Encode for Float in std::schema, which writes
null and says why.
is_null
Section titled “is_null”pub fn is_null(self) -> BoolWhether this is null, which is a question rather than a value.
A JSON null and a missing field are different, and both are worth
telling apart from a present value: is_null answers the first.
entries
Section titled “entries”pub fn entries(self) -> List<Field>Every field of an object, or the empty list.
By name, not by the order the document wrote them. A Map is a
hash table and forgets the order its keys arrived in, so a parsed
document cannot round-trip field-for-field whatever this does. That is
the same trade every language whose JSON object is a hash makes — Go’s
map[string]any, Python before 3.7 — and it buys field in constant
time, which is what consumers actually do with a document.
What it can promise is that the order is the same every time. Bucket order is not: it depends on how many keys there are and changes when the map grows, so two runs over the same document would print its fields differently. That would make a golden test over an encoded document impossible to write and a diff between two API responses unreadable.
Sorted rather than insertion order, because insertion order is the one
thing a hash map genuinely cannot recover — keeping it would mean
Object carrying a List<Field> beside the map, and paying for the
order on every document whether or not anybody looks at it.
Trait implementations
Section titled “Trait implementations”Show for Json
Section titled “Show for Json”impl Show for Jsonencode, and as deep: a frame per level of nesting.
fn show(self) -> StringFunctions
Section titled “Functions”member
Section titled “member”pub fn member(name: String, value: Json) -> FieldOne object member. Chiefly useful to generated encoders, and preferable to
making an otherwise context-free record literal guess that it is a Field.
object
Section titled “object”pub fn object(fields: List<Field>) -> JsonBuilds an object out of the fields it is given.
The order is not kept, and the printed object is sorted by key. A
Json::Object holds a Map, which is a hash table and forgets the order
its keys arrived in; the sorting is sorted_fields, which encode and
Json::entries both go through, and not anything the Map does. So the
list here says which fields, not which order. Raw::to_json in
std::schema builds its objects here, so a value encoded through it
prints its keys sorted too.
std::schema::json_text is the route that keeps a derived record’s
declaration order, because it writes the record as text and never builds
a Json; it is what Response::json sends.
encode
Section titled “encode”pub fn encode(value: Json) -> StringThe value, as JSON text.
Compact: no spaces and no newlines. Pretty-printing is a second function when something wants one, rather than a flag that doubles every branch below.
A stack frame per level of nesting, and no limit. A value parse
produced is at most 512 levels deep, which encodes in under 200 KB of
stack. A deeper one came from parse_with_depth or was built by the
program, and encoding it costs about 350 bytes of stack a level in a
debug build and 200 in a release one: an 8 MB stack runs out at about
23,000 levels in debug, and the process ends.
pub fn quote(text: String) -> StringA string as JSON text: quoted, with every byte JSON requires escaped.
The one escaper, shared with std::schema’s writer, so a string in
a document built as Json and one written straight from a record come
out byte for byte the same.
pub fn parse(text: String) -> Result<Json, JsonError>A whole document.
Trailing text is an error rather than ignored: {"a":1} oops is a mistake
somebody wants told about, and a parser that stops at the first complete
value hides it.
At most 512 levels of nesting. The parser takes a stack frame per
level, so with no limit a document of nothing but opening brackets would
decide how much stack it used: 31 KB of them runs an 8 MB stack out and
ends the process, and a request body is a document any client chooses.
Deeper is an error: its at is the offset of the bracket that would open
level 513, and its expected is at most 512 levels of nesting. 512 is
far past what real documents nest, and costs about 320 KB of stack at its
deepest in a debug build and 100 KB in a release one. For another limit,
see parse_with_depth.
parse_with_depth
Section titled “parse_with_depth”pub fn parse_with_depth(text: String, limit: Int) -> Result<Json, JsonError>parse, refusing nesting deeper than limit rather than 512.
For a caller who knows its documents nest deeper and has the stack for
them. Each level costs a stack frame: about 620 bytes in a debug build
and about 200 in a release one on x86-64 Linux, so an 8 MB stack – what
main gets by default and what every fiber gets – holds about 13,000
levels in debug and 40,000 in release, with nothing else on it. A limit
past what the stack holds is no limit: the process ends instead of
answering an error. A limit of 0 or less admits no array or object at
all.
Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource