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!(matches!(v, Value::List(_)), "non-empty object must be an alist");
282 // `Value` has no PartialEq, so match the shape rather than compare.
283 assert!(matches!(alist_lookup(&v, "a"), Some(Value::Int(1))));
284 }
285
286 #[test]
287 fn map_serializes_as_object_not_null() {
288 let mut m = HashMap::new();
289 m.insert(MapKey::Str(Arc::from("k")), Value::Int(7));
290 let out = value_to_json(&Value::Map(Arc::new(m)));
291 assert_eq!(out, serde_json::json!({"k": 7}));
292 }
293
294 #[test]
295 fn map_with_non_string_keys_keeps_every_entry() {
296 let mut m = HashMap::new();
297 m.insert(MapKey::Int(1), Value::Str(Arc::from("one")));
298 m.insert(MapKey::Bool(true), Value::Str(Arc::from("yes")));
299 let out = value_to_json(&Value::Map(Arc::new(m)));
300 assert_eq!(out, serde_json::json!({"1": "one", "true": "yes"}));
301 }
302}