Skip to main content

tatara_lisp_script/stdlib/
json.rs

1//! JSON parse + stringify, mapping to the tatara-lisp `Value` tree.
2//!
3//!   (json-parse STR)      → nested Value (null → nil, objects → alist)
4//!   (json-stringify V)    → string
5//!   (alist-get ALIST KEY) → value at KEY, or nil
6//!   (alist-get ALIST KEY DEFAULT) → value at KEY, or DEFAULT
7
8use std::sync::Arc;
9
10use serde_json::Value as JsonValue;
11use tatara_lisp_eval::{Arity, EvalError, Interpreter, MapKey, Value};
12
13use crate::script_ctx::ScriptCtx;
14use crate::stdlib::env::str_arg;
15
16pub fn install(interp: &mut Interpreter<ScriptCtx>) {
17    interp.register_fn(
18        "json-parse",
19        Arity::Exact(1),
20        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
21            let s = str_arg(&args[0], "json-parse", sp)?;
22            let parsed: JsonValue = serde_json::from_str(&s)
23                .map_err(|e| EvalError::native_fn("json-parse", e.to_string(), sp))?;
24            Ok(json_to_value(&parsed))
25        },
26    );
27
28    interp.register_fn(
29        "json-stringify",
30        Arity::Exact(1),
31        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
32            let s = serde_json::to_string(&value_to_json(&args[0]))
33                .map_err(|e| EvalError::native_fn("json-stringify", e.to_string(), sp))?;
34            Ok(Value::Str(Arc::from(s)))
35        },
36    );
37
38    interp.register_fn(
39        "alist-get",
40        Arity::Range(2, 3),
41        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
42            let key = match &args[1] {
43                Value::Str(s) => s.clone(),
44                Value::Symbol(s) | Value::Keyword(s) => s.clone(),
45                other => {
46                    return Err(EvalError::native_fn(
47                        "alist-get",
48                        format!(
49                            "key must be string/symbol/keyword, got {}",
50                            other.type_name()
51                        ),
52                        sp,
53                    ))
54                }
55            };
56            let default = args.get(2).cloned().unwrap_or(Value::Nil);
57            Ok(alist_lookup(&args[0], &key).unwrap_or(default))
58        },
59    );
60}
61
62/// Convert a `serde_json::Value` into a tatara-lisp `Value`.
63/// Objects become association lists: `((key . v) (key . v) ...)` where
64/// each pair is a 2-element list for easy alist-get lookup.
65pub fn json_to_value(j: &JsonValue) -> Value {
66    match j {
67        JsonValue::Null => Value::Nil,
68        JsonValue::Bool(b) => Value::Bool(*b),
69        JsonValue::Number(n) => {
70            if let Some(i) = n.as_i64() {
71                Value::Int(i)
72            } else {
73                Value::Float(n.as_f64().unwrap_or(0.0))
74            }
75        }
76        JsonValue::String(s) => Value::Str(Arc::from(s.as_str())),
77        JsonValue::Array(xs) => Value::list(xs.iter().map(json_to_value).collect::<Vec<_>>()),
78        // The EMPTY object is the one case the alist representation cannot
79        // carry: `{}` and `[]` both become the empty list, and `value_to_json`
80        // then has nothing left to decide on, so it picks `[]` and the object
81        // is gone. That is not hypothetical — it silently rewrote every
82        // credsStore-backed `"<registry>": {}` entry in ~/.docker/config.json
83        // to `"<registry>": []` on each home-manager activation, which the
84        // docker CLI rejects outright:
85        //   json: cannot unmarshal array into Go struct field
86        //   ConfigFile.auths of type types.AuthConfig
87        // i.e. one activation of an unrelated script bricked the docker CLI
88        // for every registry on the machine.
89        //
90        // `Value::Map` is the representation that CAN say "object" with no
91        // entries, so the empty case uses it and round-trips exactly. Every
92        // non-empty object stays an alist: that is the shape the whole
93        // authoring surface (`alist-get`, `alist-upsert`, the `as-alist`
94        // idiom) is written against, and changing it is a separate, much
95        // larger move — see the KNOWN REMAINING AMBIGUITY note on
96        // `value_to_json`.
97        JsonValue::Object(m) if m.is_empty() => Value::map([]),
98        JsonValue::Object(m) => Value::list(
99            m.iter()
100                .map(|(k, v)| {
101                    Value::list(vec![Value::Str(Arc::from(k.as_str())), json_to_value(v)])
102                })
103                .collect::<Vec<_>>(),
104        ),
105    }
106}
107
108/// Convert a tatara-lisp `Value` into a `serde_json::Value` for serialization.
109/// Closures / native fns / foreign / quoted-sexp collapse to `null`.
110///
111/// KNOWN REMAINING AMBIGUITY (deliberate, not overlooked). The list arm below
112/// decides object-vs-array by *shape*, so a genuine JSON array whose every
113/// element is a 2-element array with a string first — `[["a",1],["b",2]]` —
114/// still stringifies as `{"a":1,"b":2}`. That is the same lossy heuristic the
115/// empty-object case above escaped, and the destination is the same for both:
116/// objects are `Value::Map`, arrays are `Value::List`, and round-trip is
117/// identity by construction rather than by heuristic. Getting there means
118/// migrating every `alist-get`/`alist-upsert` caller in the fleet, so it is a
119/// separate change; the empty case was split out first because it was actively
120/// corrupting a file on every activation and the array-of-pairs case has never
121/// been observed in fleet data.
122pub fn value_to_json(v: &Value) -> JsonValue {
123    match v {
124        Value::Nil => JsonValue::Null,
125        Value::Bool(b) => JsonValue::Bool(*b),
126        Value::Int(n) => JsonValue::Number((*n).into()),
127        Value::Float(n) => serde_json::Number::from_f64(*n)
128            .map(JsonValue::Number)
129            .unwrap_or(JsonValue::Null),
130        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
131            JsonValue::String(s.as_ref().to_owned())
132        }
133        Value::List(xs) => {
134            // Heuristic: if every element is a 2-list with a string first,
135            // treat it as an object; else array.
136            let looks_like_object = !xs.is_empty()
137                && xs.iter().all(|entry| {
138                    if let Value::List(pair) = entry {
139                        pair.len() == 2
140                            && matches!(
141                                pair[0],
142                                Value::Str(_) | Value::Symbol(_) | Value::Keyword(_)
143                            )
144                    } else {
145                        false
146                    }
147                });
148            if looks_like_object {
149                let mut m = serde_json::Map::with_capacity(xs.len());
150                for entry in xs.iter() {
151                    if let Value::List(pair) = entry {
152                        let k = match &pair[0] {
153                            Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
154                                s.as_ref().to_owned()
155                            }
156                            _ => unreachable!(),
157                        };
158                        m.insert(k, value_to_json(&pair[1]));
159                    }
160                }
161                JsonValue::Object(m)
162            } else {
163                JsonValue::Array(xs.iter().map(value_to_json).collect())
164            }
165        }
166        // A Map is unambiguously an object — the only Value that is. Before
167        // this arm existed it fell through to `_ => Null`, so every Map that
168        // reached json-stringify serialized as `null`.
169        //
170        // JSON object keys are strings, so non-string keys render through
171        // their scalar spelling rather than being dropped: an entry that
172        // silently vanished would be worse than one that is findable under
173        // "1" or "true".
174        Value::Map(m) => JsonValue::Object(
175            m.iter()
176                .map(|(k, v)| (map_key_to_json_key(k), value_to_json(v)))
177                .collect(),
178        ),
179        _ => JsonValue::Null,
180    }
181}
182
183/// Render a `MapKey` as a JSON object key. Total by construction — every
184/// variant has a spelling, so no entry can be dropped on the way out.
185fn map_key_to_json_key(k: &MapKey) -> String {
186    match k {
187        MapKey::Str(s) | MapKey::Symbol(s) | MapKey::Keyword(s) => s.as_ref().to_owned(),
188        MapKey::Nil => "null".to_owned(),
189        MapKey::Bool(b) => b.to_string(),
190        MapKey::Int(n) => n.to_string(),
191        MapKey::Float(bits) => f64::from_bits(*bits).to_string(),
192    }
193}
194
195/// Look up `key` in an alist represented as a list of 2-element lists.
196///
197/// `pub(crate)` so the capture-record readers in `stdlib::process` share this
198/// one lookup rather than growing a second — `alist-get` and `status-of` must
199/// agree on what a key match is, and one function is how that stays true.
200pub(crate) fn alist_lookup(alist: &Value, key: &str) -> Option<Value> {
201    let Value::List(entries) = alist else {
202        return None;
203    };
204    for entry in entries.iter() {
205        let Value::List(pair) = entry else { continue };
206        if pair.len() != 2 {
207            continue;
208        }
209        let matches = match &pair[0] {
210            Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.as_ref() == key,
211            _ => false,
212        };
213        if matches {
214            return Some(pair[1].clone());
215        }
216    }
217    None
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// `json-parse` then `json-stringify` must be identity on the JSON value,
225    /// which is the property the whole "read a config, upsert one leaf, write
226    /// it back" idiom rests on. Compared as parsed values, so key order is not
227    /// asserted.
228    fn assert_round_trips(src: &str) {
229        let parsed: JsonValue = serde_json::from_str(src).expect("fixture is valid JSON");
230        let out = value_to_json(&json_to_value(&parsed));
231        assert_eq!(out, parsed, "round-trip changed the document\nin:  {src}");
232    }
233
234    #[test]
235    fn empty_object_round_trips() {
236        assert_round_trips("{}");
237        assert_round_trips(r#"{"a":{}}"#);
238        assert_round_trips(r#"{"a":{"b":{}}}"#);
239        assert_round_trips(r#"[{},{}]"#);
240    }
241
242    #[test]
243    fn empty_array_is_not_confused_for_an_object() {
244        assert_round_trips("[]");
245        assert_round_trips(r#"{"a":[]}"#);
246        // The two empties must stay distinguishable side by side.
247        assert_round_trips(r#"{"obj":{},"arr":[]}"#);
248    }
249
250    /// The exact document class this fix was written for. Docker Desktop
251    /// writes a bare `{}` for every registry whose credentials live in the
252    /// credential store; round-tripping that through the old code produced
253    /// `"ghcr.io":[]`, which the docker CLI refuses to unmarshal, taking down
254    /// every registry on the machine.
255    #[test]
256    fn docker_config_with_credstore_entries_survives() {
257        let src = r#"{
258            "auths": {
259                "ghcr.io": {},
260                "localhost:5000": {},
261                "registry.example.com": {"auth":"dXNlcjpwYXNz"}
262            },
263            "credsStore": "desktop",
264            "currentContext": "desktop-linux",
265            "features": {"hooks":"true"}
266        }"#;
267        assert_round_trips(src);
268
269        // And it must still be an object after the round trip, not merely
270        // equal to something — assert the shape the CLI actually requires.
271        let parsed: JsonValue = serde_json::from_str(src).unwrap();
272        let out = value_to_json(&json_to_value(&parsed));
273        assert!(
274            out["auths"]["ghcr.io"].is_object(),
275            "credsStore-backed entry must stay an object, got {}",
276            out["auths"]["ghcr.io"]
277        );
278    }
279
280    #[test]
281    fn non_empty_objects_stay_alists() {
282        // The authoring surface (alist-get / alist-upsert) depends on this.
283        let v = json_to_value(&serde_json::json!({"a": 1}));
284        assert!(
285            matches!(v, Value::List(_)),
286            "non-empty object must be an alist"
287        );
288        // `Value` has no PartialEq, so match the shape rather than compare.
289        assert!(matches!(alist_lookup(&v, "a"), Some(Value::Int(1))));
290    }
291
292    #[test]
293    fn map_serializes_as_object_not_null() {
294        let mut m = std::collections::HashMap::new();
295        m.insert(MapKey::Str(Arc::from("k")), Value::Int(7));
296        let out = value_to_json(&Value::Map(Arc::new(m.into())));
297        assert_eq!(out, serde_json::json!({"k": 7}));
298    }
299
300    #[test]
301    fn map_with_non_string_keys_keeps_every_entry() {
302        let mut m = std::collections::HashMap::new();
303        m.insert(MapKey::Int(1), Value::Str(Arc::from("one")));
304        m.insert(MapKey::Bool(true), Value::Str(Arc::from("yes")));
305        let out = value_to_json(&Value::Map(Arc::new(m.into())));
306        assert_eq!(out, serde_json::json!({"1": "one", "true": "yes"}));
307    }
308}