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