Skip to content
You are reading v0.1, which is not the current release. Go to v0.4.

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 · parse

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 = {
at: Int,
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 = {
name: String,
value: Json,
};

One field of an object, in the order entries happened to find it.

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 rest 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 — which is how they were read before, so nothing that decoded yesterday stops decoding today.

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 the bug this replaced. These two are the way to make one.

pub fn of_float(value: Float) -> Json

A number from a Float, in its shortest round-tripping form.

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, and now does, 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 could print its fields differently. That makes a golden test over an encoded document impossible to write and a diff between two API responses unreadable, which is what this was reported for.

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
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 from fields in declaration order.

The map intentionally forgets that order, as every other object built by this module does. Raw::to_json in std::schema is built on it, and so is a handwritten encoder.

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.

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.

Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource