lib/json.bx
JSON, parsed and rendered, in ordinary Burxt.
use "lib/json.bx";
Nothing here needs a compiler feature. It is the enum + class mutual recursion from docs/guide/03-types.md and the Option/Result from lib/, which is the same test lib/map.bx passed: if a JSON document had needed a keyword, the type system would not be real.
—- The one position this library takes ————————————————
A JSON number is its DIGITS, not a float.
JSON’s own grammar says a number is arbitrary-precision decimal text. Every language then parses it into a double on the way in, which is where an exact 19.99 stops being exact — and it is the same abandonment as scaled exists to prevent at the C boundary. See spec/1.0/N1-BOUNDARY-EXACTNESS.md: real financial defects live at boundaries, not in arithmetic.
So Json.Number holds the digits exactly as they were written, and turning them into a typed value is a separate, checked step that can fail — json_as_int, json_as_money. Nothing is rounded on the way in, because nothing on the way in knows what rounding you wanted.
And going out, money crosses as a quoted string: json_money($19.99) renders "19.99", not 19.99. A JSON number reaches a JavaScript consumer as a double and loses the cent. A string reaches every consumer with all its digits. That costs one JSON.parse field being text on the far side, and it is the difference between exact and nearly.
What is in it
| Name | Kind | What it answers |
|---|---|---|
Json |
enum | A JSON value. |
Field |
class | One member of an object. A class rather than a two-payload variant, because a name and a value travelling together is a |
Reader |
class | Where the parser is. A class with mutable self methods, because Burxt has no writable parameters — the same constraint |
json_null |
function | — |
json_truth |
function | — |
json_int |
function | — |
json_money |
function | Money, as a quoted string. See the header: this is the position, and it is deliberate. |
json_text |
function | — |
json_list |
function | — |
json_object |
function | — |
json_field |
function | — |
json_escape |
function | A String with the six escapes JSON requires, and nothing else. |
json_render |
function | One JSON value as text, with no whitespace. Recursive, because the shape is. |
json_at |
function | A member by name, or None. Linear, like every other lookup in this repository at this scale — an MCP request has single- |
json_as_text |
function | — |
json_digits |
function | The digits of a number, whether it arrived as a JSON number or as a quoted string. |
json_as_int |
function | — |
json_as_truth |
function | — |
json_as_money |
function | Money, at two places, or None when the digits are not exactly that. |
is_json_digit |
function | — |
json_parse |
function | One JSON document. Trailing whitespace is allowed; trailing anything else is not, because a second document where one wa |
skip_space |
method on Reader |
— |
peek |
method on Reader |
— |
parse_value |
method on Reader |
Parse one value at the cursor. Recursive through parse_list and parse_object. |
word |
method on Reader |
Consume word if it is at the cursor. Answers whether it was. |
parse_number |
method on Reader |
A number, kept as the digits it was written with. Only the SHAPE is checked here — that it is a number at all — because |
parse_unicode_escape |
method on Reader |
One \uXXXX, with self.at sitting on the u. Answers the character and leaves self.at just past the escape. B9. |
read_four_hex |
method on Reader |
The four hex digits of a \uXXXX, with self.at on the u. Leaves self.at past the digits. |
parse_text |
method on Reader |
A quoted string, with the escapes undone. |
parse_list |
method on Reader |
— |
parse_object |
method on Reader |
— |
Types
Json
enum Json
A JSON value.
List holds a slice of itself and Object a slice of Field, which holds a Json — the two halves of one recursive shape. A slice is a pointer and a length, so neither is infinitely wide, which is why this works where an enum directly inside an enum does not.
Field
class Field { name: String, value: Json }
One member of an object. A class rather than a two-payload variant, because a name and a value travelling together is a thing worth naming — and because a variant may not carry an enum, while a class field may.
Reader
class Reader { text: String, at: Int }
Where the parser is. A class with mutable self methods, because Burxt has no writable parameters — the same constraint that made lib/map.bx method-based, and the same outcome: the cursor being a value you can name reads better than threading an index through twelve returns.
Functions
json_null
function json_null() -> Json
json_truth
function json_truth(value: Bool) -> Json
json_int
function json_int(value: Int) -> Json
json_money
function json_money(amount: Decimal<2>) -> Json
Money, as a quoted string. See the header: this is the position, and it is deliberate.
json_text
function json_text(value: String) -> Json
json_list
function json_list(values: [Json]) -> Json
json_object
function json_object(fields: [Field]) -> Json
json_field
function json_field(name: String, value: Json) -> Field
json_escape
function json_escape(text: String) -> String
A String with the six escapes JSON requires, and nothing else.
Built in RUNS rather than a byte at a time: out = out + one_byte copies the whole String on every byte, which this project has paid for three times — the lexer was quadratic for eleven versions on exactly this shape. Everything from copied to here is appended in one slice.
json_render
function json_render(value: Json) -> String
One JSON value as text, with no whitespace. Recursive, because the shape is.
json_at
function json_at(value: Json, name: String) -> Option<Json>
A member by name, or None. Linear, like every other lookup in this repository at this scale — an MCP request has single-digit field counts, and a map would allocate to save nothing.
json_as_text
function json_as_text(value: Json) -> Option<String>
json_digits
function json_digits(value: Json) -> Option<String>
The digits of a number, whether it arrived as a JSON number or as a quoted string.
Both, on purpose: an exact producer sends money as a string (see the header) and a careless one sends it as a number, and a server that reads only one of the two rejects half its callers for a difference that carries no information.
json_as_int
function json_as_int(value: Json) -> Option<Int>
json_as_truth
function json_as_truth(value: Json) -> Option<Bool>
json_as_money
function json_as_money(value: Json) -> Option<Decimal<2>>
Money, at two places, or None when the digits are not exactly that.
It never rounds. "19.999" answers None rather than 20.00, because a value arriving from outside with more precision than you asked for is a question and not a rounding: the caller sent a third decimal place for a reason, and no default here can know what it was. 1.5 is fine — 1.50 loses nothing — and 1.567 is refused.
The reconstruction is a count of pennies times a penny, which is exact by construction and needs no rounding contract, because that is literally what a scaled decimal already is.
is_json_digit
function is_json_digit(b: Int) -> Bool
json_parse
function json_parse(text: String) -> Result<Json, String>
One JSON document. Trailing whitespace is allowed; trailing anything else is not, because a second document where one was expected means the caller framed its input wrong and saying so beats parsing half of it.
Methods
skip_space
function (mutable self: Reader) skip_space() -> Int
Takes mutable self, so it changes the value it is called on.
peek
function (self: Reader) peek() -> Int
parse_value
function (mutable self: Reader) parse_value() -> Result<Json, String>
Parse one value at the cursor. Recursive through parse_list and parse_object.
Takes mutable self, so it changes the value it is called on.
word
function (mutable self: Reader) word(word: String) -> Bool
Consume word if it is at the cursor. Answers whether it was.
Takes mutable self, so it changes the value it is called on.
parse_number
function (mutable self: Reader) parse_number() -> Result<Json, String>
A number, kept as the digits it was written with. Only the SHAPE is checked here — that it is a number at all — because deciding what type it should become is the caller’s, and doing it here would be the silent conversion this file exists to avoid.
Takes mutable self, so it changes the value it is called on.
parse_unicode_escape
function (mutable self: Reader) parse_unicode_escape() -> Result<String, String>
One \uXXXX, with self.at sitting on the u. Answers the character and leaves self.at just past the escape. B9.
Surrogate pairs are the reason this is a function rather than four lines inline. JSON is specified in terms of UTF-16, so every codepoint above U+FFFF is written as TWO escapes — an emoji is 😀, never one escape. A decoder that treats each \uXXXX independently produces two half-characters, and from_codepoint refuses those outright (they are surrogates, and encoding one is CESU-8, which this library’s own is_valid_utf8 rejects). So the choice was never “handle pairs or ignore them” — it was “handle pairs or refuse every emoji in real-world JSON”.
A lone surrogate, high or low, is an error rather than U+FFFD. Substituting a replacement character is the same silent repair as the "?" that os_byte_as_string used to make: the caller asked for text and would get text, subtly not the text that was sent.
Takes mutable self, so it changes the value it is called on.
read_four_hex
function (mutable self: Reader) read_four_hex() -> Result<Int, String>
The four hex digits of a \uXXXX, with self.at on the u. Leaves self.at past the digits.
Takes mutable self, so it changes the value it is called on.
parse_text
function (mutable self: Reader) parse_text() -> Result<String, String>
A quoted string, with the escapes undone.
Takes mutable self, so it changes the value it is called on.
parse_list
function (mutable self: Reader) parse_list() -> Result<Json, String>
Takes mutable self, so it changes the value it is called on.
parse_object
function (mutable self: Reader) parse_object() -> Result<Json, String>
Takes mutable self, so it changes the value it is called on.