Skip to content

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.

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}") -> 9007199254740992

Off 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.

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.

impl Json
pub 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.

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.

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.

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.

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.

pub fn of_int(value: Int) -> Json

A 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.

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.

pub fn is_null(self) -> Bool

Whether 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.

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.

impl Show for Json

encode, and as deep: a frame per level of nesting.

fn show(self) -> String
pub fn member(name: String, value: Json) -> Field

One object member. Chiefly useful to generated encoders, and preferable to making an otherwise context-free record literal guess that it is a Field.

pub fn object(fields: List<Field>) -> Json

Builds 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.

pub fn encode(value: Json) -> String

The 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) -> String

A 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.

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