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