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}