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