bop-lang 0.3.0

A small, embeddable, dynamically-typed programming language with zero dependencies
Documentation
// 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())
}