Skip to main content

blue_lang_runtime/
json.rs

1//! JSON — parse, stringify, and read back — as blue's own surface.
2//!
3//! Pure computation with no host imports, so this is part of the runtime
4//! unconditionally (the `wasm32-unknown-unknown` consumer keeps it). The
5//! semantics are the substrate's — `tatara-lisp-script`'s `json.rs` is the
6//! reference, and these are deliberately the same shapes — adapted to what
7//! blue can actually reach:
8//!
9//! ```text
10//! json_parse(s)          → nil / bool / int / float / string / list / alist
11//! json_stringify(v)      → string
12//! json_get(doc, key)     → value at key, or nil   (doc = alist or Map)
13//! json_get_or(doc, key, default) → value at key, or default
14//! ```
15//!
16//! # Why objects are alists, not Maps
17//!
18//! `Value::Map` is the representation that round-trips `{}` exactly — the
19//! substrate proved that fix, and it is kept for the empty case — but a Map
20//! has **no reachable reader**: every map primitive blue's own surface could
21//! spell is kebab-case (`hash-map-get`), and `-` is an operator in blue, so a
22//! `json_parse` that produced Maps would hand blue a document it could not
23//! open. Non-empty objects therefore parse to an association list of
24//! `[key value]` 2-lists, which blue reads with `car`/`cdr`/`nth`. `json_get`
25//! is the Rust-side reader, total over both shapes.
26//!
27//! The cost is the same ambiguity the substrate records: a JSON array whose
28//! every element is a 2-list with a string first still stringifies as an
29//! object. Round-trip is exact for the shapes the fleet emits; the Map/List
30//! split is where a future migration lands if the map reader becomes
31//! reachable.
32
33use std::sync::Arc;
34
35use serde_json::Value as JsonValue;
36use tatara_lisp_eval::ffi::Arity;
37use tatara_lisp_eval::{EvalError, Interpreter, MapKey, Value};
38
39/// Install blue's JSON surface.
40pub fn install_json_stdlib<H: 'static>(interp: &mut Interpreter<H>) {
41    interp.register_fn(
42        "json_parse",
43        Arity::Exact(1),
44        |args: &[Value], _h: &mut H, span| {
45            let s = as_str(&args[0], "json_parse", span)?;
46            let parsed: JsonValue = serde_json::from_str(&s)
47                .map_err(|e| EvalError::native_fn("json_parse", e.to_string(), span))?;
48            Ok(json_to_value(&parsed))
49        },
50    );
51
52    interp.register_fn(
53        "json_read",
54        Arity::Exact(1),
55        |args: &[Value], _h: &mut H, span| {
56            let s = as_str(&args[0], "json_read", span)?;
57            let parsed: JsonValue = serde_json::from_str(&s)
58                .map_err(|e| EvalError::native_fn("json_read", e.to_string(), span))?;
59            Ok(json_to_maps(&parsed))
60        },
61    );
62
63    interp.register_fn(
64        "json_stringify",
65        Arity::Exact(1),
66        |args: &[Value], _h: &mut H, span| {
67            let s = serde_json::to_string(&value_to_json(&args[0]))
68                .map_err(|e| EvalError::native_fn("json_stringify", e.to_string(), span))?;
69            Ok(Value::Str(Arc::from(s)))
70        },
71    );
72
73    interp.register_fn(
74        "json_get",
75        Arity::Exact(2),
76        |args: &[Value], _h: &mut H, span| {
77            let doc = &args[0];
78            let key = as_str(&args[1], "json_get", span)?;
79            match json_lookup(doc, &key) {
80                Some(v) => Ok(v),
81                None if is_doc(doc) => Ok(Value::Nil),
82                None => Err(EvalError::native_fn(
83                    "json_get",
84                    format!("cannot read a field from a {}", doc.type_name()),
85                    span,
86                )),
87            }
88        },
89    );
90
91    interp.register_fn(
92        "json_get_or",
93        Arity::Exact(3),
94        |args: &[Value], _h: &mut H, span| {
95            let doc = &args[0];
96            let key = as_str(&args[1], "json_get_or", span)?;
97            match json_lookup(doc, &key) {
98                Some(v) => Ok(v),
99                None if is_doc(doc) => Ok(args[2].clone()),
100                None => Err(EvalError::native_fn(
101                    "json_get_or",
102                    format!("cannot read a field from a {}", doc.type_name()),
103                    span,
104                )),
105            }
106        },
107    );
108}
109
110/// A document is an alist (non-empty object) or a Map (empty object). Anything
111/// else — a string, a number — has no fields, and reading from it is an error
112/// rather than a silent nil, which is the drift this crate exists to catch.
113fn is_doc(v: &Value) -> bool {
114    matches!(v, Value::List(_) | Value::Map(_))
115}
116
117fn as_str(v: &Value, fname: &'static str, span: tatara_lisp::Span) -> Result<String, EvalError> {
118    match v {
119        Value::Str(s) => Ok(s.to_string()),
120        // A symbol/keyword is text the author wrote; `json_get` names a field
121        // with `"outcome"` today, but `:outcome` should not surprise.
122        Value::Symbol(s) | Value::Keyword(s) => Ok(s.to_string()),
123        other => {
124            Err(EvalError::type_mismatch("a string", other.type_name(), span).into_native(fname))
125        }
126    }
127}
128
129/// Attach the primitive's name to a type error so the raised message names the
130/// offender, matching the style of the named parse/serialise errors.
131trait NameError {
132    fn into_native(self, fname: &'static str) -> EvalError;
133}
134
135impl NameError for EvalError {
136    fn into_native(self, fname: &'static str) -> EvalError {
137        match self {
138            EvalError::TypeMismatch { expected, got, at } => {
139                EvalError::native_fn(fname, format!("expected {expected}, got {got}"), at)
140            }
141            other => other,
142        }
143    }
144}
145
146/// Convert a `serde_json::Value` into a `Value`. Objects become association
147/// lists of `[key value]` 2-lists; the EMPTY object is `Value::Map`, the one
148/// representation that can say "object with no entries" and round-trip.
149pub fn json_to_value(j: &JsonValue) -> Value {
150    match j {
151        JsonValue::Null => Value::Nil,
152        JsonValue::Bool(b) => Value::Bool(*b),
153        JsonValue::Number(n) => {
154            if let Some(i) = n.as_i64() {
155                Value::Int(i)
156            } else {
157                Value::Float(n.as_f64().unwrap_or(0.0))
158            }
159        }
160        JsonValue::String(s) => Value::Str(Arc::from(s.as_str())),
161        JsonValue::Array(xs) => Value::list(xs.iter().map(json_to_value)),
162        JsonValue::Object(m) if m.is_empty() => Value::map([]),
163        JsonValue::Object(m) => Value::list(
164            m.iter()
165                .map(|(k, v)| Value::list([Value::Str(Arc::from(k.as_str())), json_to_value(v)])),
166        ),
167    }
168}
169
170pub fn json_to_maps(j: &JsonValue) -> Value {
171    match j {
172        JsonValue::Array(xs) => Value::list(xs.iter().map(json_to_maps)),
173        JsonValue::Object(m) => Value::map(
174            m.iter()
175                .map(|(k, v)| (MapKey::Str(Arc::from(k.as_str())), json_to_maps(v))),
176        ),
177        other => json_to_value(other),
178    }
179}
180
181/// Convert a `Value` back into a `serde_json::Value`. Closures and native
182/// functions collapse to `null`.
183pub fn value_to_json(v: &Value) -> JsonValue {
184    match v {
185        Value::Nil => JsonValue::Null,
186        Value::Bool(b) => JsonValue::Bool(*b),
187        Value::Int(n) => JsonValue::Number((*n).into()),
188        Value::Float(n) => serde_json::Number::from_f64(*n)
189            .map(JsonValue::Number)
190            .unwrap_or(JsonValue::Null),
191        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
192            JsonValue::String(s.as_ref().to_owned())
193        }
194        // A list that is entirely `[string, anything]` pairs IS an object —
195        // that is what `json_to_value` produced, so the round-trip is exact
196        // for documents blue parsed. A genuine array of such pairs is the
197        // ambiguity recorded at the top of this module.
198        Value::List(xs) => {
199            let looks_like_object = !xs.is_empty()
200                && xs.iter().all(|entry| {
201                    if let Value::List(pair) = entry {
202                        pair.len() == 2
203                            && matches!(
204                                pair[0],
205                                Value::Str(_) | Value::Symbol(_) | Value::Keyword(_)
206                            )
207                    } else {
208                        false
209                    }
210                });
211            if looks_like_object {
212                let mut m = serde_json::Map::with_capacity(xs.len());
213                for entry in xs.iter() {
214                    if let Value::List(pair) = entry {
215                        let k = match &pair[0] {
216                            Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
217                                s.as_ref().to_owned()
218                            }
219                            _ => unreachable!(),
220                        };
221                        m.insert(k, value_to_json(&pair[1]));
222                    }
223                }
224                JsonValue::Object(m)
225            } else {
226                JsonValue::Array(xs.iter().map(value_to_json).collect())
227            }
228        }
229        // A Map is unambiguously an object — the only Value that is. Keys
230        // render through their scalar spelling rather than being dropped: a
231        // silently vanished entry is worse than one findable under "1" or
232        // "true".
233        Value::Map(m) => JsonValue::Object(
234            m.iter()
235                .map(|(k, v)| (map_key_to_json_key(k), value_to_json(v)))
236                .collect(),
237        ),
238        _ => JsonValue::Null,
239    }
240}
241
242fn map_key_to_json_key(k: &MapKey) -> String {
243    match k {
244        MapKey::Str(s) | MapKey::Symbol(s) | MapKey::Keyword(s) => s.as_ref().to_owned(),
245        MapKey::Nil => "null".to_owned(),
246        MapKey::Bool(b) => b.to_string(),
247        MapKey::Int(n) => n.to_string(),
248        MapKey::Float(bits) => f64::from_bits(*bits).to_string(),
249    }
250}
251
252/// Read `key` from a parsed JSON document, in either representation —
253/// an association list of `[key value]` pairs or a `Value::Map`.
254fn json_lookup(doc: &Value, key: &str) -> Option<Value> {
255    match doc {
256        Value::List(entries) => entries.iter().find_map(|entry| {
257            let Value::List(pair) = entry else {
258                return None;
259            };
260            if pair.len() != 2 {
261                return None;
262            }
263            let matches = match &pair[0] {
264                Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.as_ref() == key,
265                _ => false,
266            };
267            matches.then(|| pair[1].clone())
268        }),
269        Value::Map(m) => {
270            let k = MapKey::Str(Arc::from(key));
271            m.get(&k).cloned()
272        }
273        _ => None,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn eval(src: &str) -> Value {
282        crate::run(src)
283            .unwrap_or_else(|e| panic!("{src:?}: {e}"))
284            .value
285    }
286
287    fn s(src: &str) -> String {
288        match eval(src) {
289            Value::Str(v) => v.to_string(),
290            other => panic!("{src:?} produced {other:?}"),
291        }
292    }
293
294    /// Round-trip is identity on the parsed value — the property the
295    /// read-a-doc / touch-one-leaf / write-it-back idiom rests on. Compared as
296    /// parsed JSON so key order is not asserted.
297    fn assert_round_trips(src: &str) {
298        let parsed: JsonValue = serde_json::from_str(src).expect("fixture is valid JSON");
299        let out = value_to_json(&json_to_value(&parsed));
300        assert_eq!(out, parsed, "round-trip changed the document\nin:  {src}");
301    }
302
303    #[test]
304    fn parse_and_get_an_object_field() {
305        let v = eval(r#"json_get(json_parse("{\"outcome\":\"ok\"}"), "outcome")"#);
306        assert!(matches!(v, Value::Str(ref x) if &**x == "ok"), "got {v:?}");
307    }
308
309    #[test]
310    fn a_missing_field_is_nil_not_an_error() {
311        assert!(matches!(
312            eval(r#"json_get(json_parse("{\"a\":1}"), "nope")"#),
313            Value::Nil
314        ));
315    }
316
317    #[test]
318    fn json_get_or_returns_the_default() {
319        assert!(matches!(
320            eval(r#"json_get_or(json_parse("{}"), "nope", 42)"#),
321            Value::Int(42)
322        ));
323    }
324
325    #[test]
326    fn nested_objects_read_by_walking_alists() {
327        let src = r#"json_get(
328          json_get(json_parse("{\"outer\":{\"inner\":7}}"), "outer"),
329          "inner")"#;
330        assert!(matches!(eval(src), Value::Int(7)));
331    }
332
333    #[test]
334    fn numbers_and_bools_keep_their_kinds() {
335        assert!(matches!(
336            eval(r#"json_get(json_parse("{\"n\":1,\"b\":true}"), "n")"#),
337            Value::Int(1)
338        ));
339        assert!(matches!(
340            eval(r#"json_get(json_parse("{\"b\":true}"), "b")"#),
341            Value::Bool(true)
342        ));
343    }
344
345    #[test]
346    fn null_is_nil() {
347        assert!(matches!(
348            eval(r#"json_get(json_parse("{\"x\":null}"), "x")"#),
349            Value::Nil
350        ));
351    }
352
353    #[test]
354    fn stringify_reaches_the_parser_shape_back() {
355        assert_eq!(
356            s(r#"json_stringify(json_parse("{\"a\":1,\"b\":[1,2]}"))"#),
357            r#"{"a":1,"b":[1,2]}"#
358        );
359    }
360
361    #[test]
362    fn empty_object_round_trips() {
363        assert_round_trips("{}");
364        assert_round_trips(r#"{"a":{}}"#);
365        assert_round_trips(r#"{"a":{"b":{}}}"#);
366        assert_round_trips(r#"[{},{}]"#);
367    }
368
369    #[test]
370    fn empty_array_is_not_confused_for_an_object() {
371        assert_round_trips("[]");
372        assert_round_trips(r#"{"a":[]}"#);
373        assert_round_trips(r#"{"obj":{},"arr":[]}"#);
374    }
375
376    #[test]
377    fn non_empty_objects_parse_to_alists() {
378        let v = eval(r#"json_parse("{\"a\":1}")"#);
379        assert!(
380            matches!(v, Value::List(_)),
381            "non-empty object must be an alist"
382        );
383    }
384
385    #[test]
386    fn a_parsed_empty_object_is_a_map_and_reads_as_nil() {
387        let v = eval(r#"json_parse("{}")"#);
388        assert!(
389            matches!(v, Value::Map(_)),
390            "an empty object must be a Map so it round-trips"
391        );
392        assert!(
393            matches!(
394                eval(r#"json_get(json_parse("{}"), "anything")"#),
395                Value::Nil
396            ),
397            "an empty object has no fields to read"
398        );
399    }
400
401    #[test]
402    fn unparseable_json_is_a_named_error() {
403        let err = crate::run(r#"json_parse("{not json")"#).expect_err("must raise");
404        assert!(
405            err.to_string().contains("json_parse"),
406            "the error must name the primitive: {err}"
407        );
408    }
409
410    #[test]
411    fn a_wrong_typed_argument_is_a_type_error() {
412        assert!(crate::run(r#"json_parse(42)"#).is_err());
413        assert!(crate::run(r#"json_get("not a doc", "k")"#).is_err());
414    }
415}