// std.json — JSON parse and stringify.
//
// Implemented in pure Bop. Performance is reasonable for
// scripting workloads (config files, API payloads up to a few
// MB); a C-backed parser would be faster but would live
// outside `bop-std`'s zero-Rust-dep contract.
//
// `stringify(value)` walks a Bop value and emits RFC-8259 JSON.
// `parse(text)` is a recursive-descent parser that returns the
// decoded value. Parse errors raise a runtime error with a
// position marker; callers who want `Result`-shaped handling
// should wrap the call in `try_call`:
//
// let r = try_call(fn() { return parse(text) })
// match r {
// Result::Ok(v) => ...,
// Result::Err(e) => ("parse failed: " + e.message).log(),
// }
// ─── Stringify ────────────────────────────────────────────────
// Emit `value` as JSON text. Bop values that have no JSON
// analogue (fns, structs, enum variants) raise a runtime
// error — caller is expected to strip them out first.
fn stringify(value) {
let t = value.type()
if t == "none" { return "null" }
if t == "bool" { return bool_to_json(value) }
if t == "int" { return value.to_str() }
if t == "number" { return value.to_str() }
if t == "string" { return escape_string(value) }
if t == "array" { return array_to_json(value) }
if t == "dict" { return dict_to_json(value) }
// fn / struct / enum have no JSON representation.
panic_json("cannot stringify " + t, 0)
}
fn bool_to_json(b) {
if b { return "true" }
return "false"
}
// Escape a Bop string for JSON output: wrap in quotes, escape
// the five characters JSON requires (`"`, `\`, newline, CR,
// tab), pass everything else through verbatim. Control chars
// under 0x20 are not currently escaped — the common cases
// (`\n`, `\t`, `\r`) are covered and the rest are rare in
// practice.
fn escape_string(s) {
let out = "\""
for c in s {
out = out + match c {
"\"" => "\\\"",
"\\" => "\\\\",
"\n" => "\\n",
"\r" => "\\r",
"\t" => "\\t",
_ => c,
}
}
return out + "\""
}
fn array_to_json(arr) {
let out = "["
let i = 0
for v in arr {
if i > 0 { out = out + "," }
out = out + stringify(v)
i = i + 1
}
return out + "]"
}
fn dict_to_json(d) {
let out = "{"
let keys = d.keys()
let i = 0
for k in keys {
if i > 0 { out = out + "," }
out = out + escape_string(k) + ":" + stringify(d[k])
i = i + 1
}
return out + "}"
}
// ─── Parse ────────────────────────────────────────────────────
//
// Parser state is a plain `(text, pos)` pair threaded through
// each helper. Helpers return `[new_pos, value]` on success
// and raise on failure. The top-level `parse` wraps the whole
// thing and checks for trailing garbage.
fn parse(text) {
let pos = skip_ws(text, 0)
if pos >= text.len() {
panic_json("empty input", pos)
}
let result = parse_value(text, pos)
let end_pos = skip_ws(text, result[0])
if end_pos < text.len() {
panic_json("unexpected trailing data", end_pos)
}
return result[1]
}
fn skip_ws(text, pos) {
while pos < text.len() {
let c = text[pos]
if c == " " || c == "\t" || c == "\n" || c == "\r" {
pos = pos + 1
} else {
return pos
}
}
return pos
}
fn parse_value(text, pos) {
pos = skip_ws(text, pos)
if pos >= text.len() {
panic_json("unexpected end of input", pos)
}
let c = text[pos]
if c == "{" { return parse_object(text, pos) }
if c == "[" { return parse_array(text, pos) }
if c == "\"" { return parse_string(text, pos) }
if c == "t" { return parse_literal(text, pos, "true", true) }
if c == "f" { return parse_literal(text, pos, "false", false) }
if c == "n" { return parse_literal(text, pos, "null", none) }
// Numbers start with `-` or a digit. Anything else is a
// syntax error — the user probably forgot quotes on a
// bareword.
if c == "-" || is_digit(c) {
return parse_number(text, pos)
}
panic_json("unexpected character `" + c + "`", pos)
}
fn parse_object(text, pos) {
pos = pos + 1 // skip {
let obj = {}
pos = skip_ws(text, pos)
if pos < text.len() && text[pos] == "}" {
return [pos + 1, obj]
}
while pos < text.len() {
pos = skip_ws(text, pos)
if pos >= text.len() || text[pos] != "\"" {
panic_json("expected string key in object", pos)
}
let key_result = parse_string(text, pos)
pos = key_result[0]
let key = key_result[1]
pos = skip_ws(text, pos)
if pos >= text.len() || text[pos] != ":" {
panic_json("expected `:` after object key", pos)
}
pos = pos + 1 // skip :
let val_result = parse_value(text, pos)
pos = val_result[0]
obj[key] = val_result[1]
pos = skip_ws(text, pos)
if pos >= text.len() {
panic_json("unterminated object", pos)
}
if text[pos] == "}" {
return [pos + 1, obj]
}
if text[pos] != "," {
panic_json("expected `,` or `}` in object", pos)
}
pos = pos + 1 // skip ,
}
panic_json("unterminated object", pos)
}
fn parse_array(text, pos) {
pos = pos + 1 // skip [
let arr = []
pos = skip_ws(text, pos)
if pos < text.len() && text[pos] == "]" {
return [pos + 1, arr]
}
while pos < text.len() {
let val_result = parse_value(text, pos)
pos = val_result[0]
arr = arr + [val_result[1]]
pos = skip_ws(text, pos)
if pos >= text.len() {
panic_json("unterminated array", pos)
}
if text[pos] == "]" {
return [pos + 1, arr]
}
if text[pos] != "," {
panic_json("expected `,` or `]` in array", pos)
}
pos = pos + 1 // skip ,
}
panic_json("unterminated array", pos)
}
fn parse_string(text, pos) {
if text[pos] != "\"" {
panic_json("expected `\"`", pos)
}
pos = pos + 1
let out = ""
while pos < text.len() {
let c = text[pos]
if c == "\"" {
return [pos + 1, out]
}
if c == "\\" {
pos = pos + 1
if pos >= text.len() {
panic_json("unterminated escape", pos)
}
let esc = text[pos]
// `\b`, `\f`, and `\uXXXX` aren't supported in this
// pass. The first two are rare in real payloads;
// `\u` escapes would need 4-hex parsing and
// code-point → UTF-8 conversion, which is a
// nontrivial Bop exercise. Document as a known
// gap and surface a clean error.
let decoded = match esc {
"\"" => "\"",
"\\" => "\\",
"/" => "/",
"n" => "\n",
"r" => "\r",
"t" => "\t",
_ => "<bad>",
}
if decoded == "<bad>" {
panic_json("unsupported escape `\\" + esc + "`", pos)
}
out = out + decoded
pos = pos + 1
} else {
out = out + c
pos = pos + 1
}
}
panic_json("unterminated string", pos)
}
fn parse_number(text, pos) {
let start = pos
if text[pos] == "-" { pos = pos + 1 }
let has_dot = false
let has_exp = false
while pos < text.len() {
let c = text[pos]
if is_digit(c) {
pos = pos + 1
} else if c == "." && !has_dot && !has_exp {
has_dot = true
pos = pos + 1
} else if (c == "e" || c == "E") && !has_exp {
has_exp = true
pos = pos + 1
// Optional sign after exponent.
if pos < text.len() && (text[pos] == "+" || text[pos] == "-") {
pos = pos + 1
}
} else {
break
}
}
let slice = text.slice(start, pos)
// Int or float? If we saw a dot or exponent, it's a float;
// otherwise int. Matches JSON's numeric subtyping.
if has_dot || has_exp {
return [pos, slice.to_float()]
}
return [pos, slice.to_int()]
}
fn parse_literal(text, pos, lit, value) {
let end = pos + lit.len()
if text.slice(pos, end) == lit {
return [end, value]
}
panic_json("expected `" + lit + "`", pos)
}
fn is_digit(c) {
// Kept on one line — ASI would treat each `"4"` / `"9"` as
// a statement terminator on a line-wrapped `||` chain.
return c == "0" || c == "1" || c == "2" || c == "3" || c == "4" || c == "5" || c == "6" || c == "7" || c == "8" || c == "9"
}
// Raise a runtime error tagged with the JSON parse position.
// Delegates to the `panic` builtin so the message surfaces
// verbatim in `try_call` / engine diagnostics.
fn panic_json(message, pos) {
panic("json: " + message + " at position " + pos.to_str())
}