use std::collections::HashMap;
use std::sync::Arc;
use serde_json::Value as JsonValue;
use tatara_lisp_eval::{Arity, EvalError, Interpreter, MapKey, Value};
use crate::script_ctx::ScriptCtx;
use crate::stdlib::env::str_arg;
pub fn install(interp: &mut Interpreter<ScriptCtx>) {
interp.register_fn(
"json-parse",
Arity::Exact(1),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let s = str_arg(&args[0], "json-parse", sp)?;
let parsed: JsonValue = serde_json::from_str(&s)
.map_err(|e| EvalError::native_fn("json-parse", e.to_string(), sp))?;
Ok(json_to_value(&parsed))
},
);
interp.register_fn(
"json-stringify",
Arity::Exact(1),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let s = serde_json::to_string(&value_to_json(&args[0]))
.map_err(|e| EvalError::native_fn("json-stringify", e.to_string(), sp))?;
Ok(Value::Str(Arc::from(s)))
},
);
interp.register_fn(
"alist-get",
Arity::Range(2, 3),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let key = match &args[1] {
Value::Str(s) => s.clone(),
Value::Symbol(s) | Value::Keyword(s) => s.clone(),
other => {
return Err(EvalError::native_fn(
"alist-get",
format!(
"key must be string/symbol/keyword, got {}",
other.type_name()
),
sp,
))
}
};
let default = args.get(2).cloned().unwrap_or(Value::Nil);
Ok(alist_lookup(&args[0], &key).unwrap_or(default))
},
);
}
pub fn json_to_value(j: &JsonValue) -> Value {
match j {
JsonValue::Null => Value::Nil,
JsonValue::Bool(b) => Value::Bool(*b),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else {
Value::Float(n.as_f64().unwrap_or(0.0))
}
}
JsonValue::String(s) => Value::Str(Arc::from(s.as_str())),
JsonValue::Array(xs) => Value::list(xs.iter().map(json_to_value).collect::<Vec<_>>()),
JsonValue::Object(m) if m.is_empty() => Value::Map(Arc::new(HashMap::new())),
JsonValue::Object(m) => Value::list(
m.iter()
.map(|(k, v)| {
Value::list(vec![Value::Str(Arc::from(k.as_str())), json_to_value(v)])
})
.collect::<Vec<_>>(),
),
}
}
pub fn value_to_json(v: &Value) -> JsonValue {
match v {
Value::Nil => JsonValue::Null,
Value::Bool(b) => JsonValue::Bool(*b),
Value::Int(n) => JsonValue::Number((*n).into()),
Value::Float(n) => serde_json::Number::from_f64(*n)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null),
Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
JsonValue::String(s.as_ref().to_owned())
}
Value::List(xs) => {
let looks_like_object = !xs.is_empty()
&& xs.iter().all(|entry| {
if let Value::List(pair) = entry {
pair.len() == 2
&& matches!(
pair[0],
Value::Str(_) | Value::Symbol(_) | Value::Keyword(_)
)
} else {
false
}
});
if looks_like_object {
let mut m = serde_json::Map::with_capacity(xs.len());
for entry in xs.iter() {
if let Value::List(pair) = entry {
let k = match &pair[0] {
Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
s.as_ref().to_owned()
}
_ => unreachable!(),
};
m.insert(k, value_to_json(&pair[1]));
}
}
JsonValue::Object(m)
} else {
JsonValue::Array(xs.iter().map(value_to_json).collect())
}
}
Value::Map(m) => JsonValue::Object(
m.iter()
.map(|(k, v)| (map_key_to_json_key(k), value_to_json(v)))
.collect(),
),
_ => JsonValue::Null,
}
}
fn map_key_to_json_key(k: &MapKey) -> String {
match k {
MapKey::Str(s) | MapKey::Symbol(s) | MapKey::Keyword(s) => s.as_ref().to_owned(),
MapKey::Nil => "null".to_owned(),
MapKey::Bool(b) => b.to_string(),
MapKey::Int(n) => n.to_string(),
MapKey::Float(bits) => f64::from_bits(*bits).to_string(),
}
}
fn alist_lookup(alist: &Value, key: &str) -> Option<Value> {
let Value::List(entries) = alist else {
return None;
};
for entry in entries.iter() {
let Value::List(pair) = entry else { continue };
if pair.len() != 2 {
continue;
}
let matches = match &pair[0] {
Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.as_ref() == key,
_ => false,
};
if matches {
return Some(pair[1].clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_round_trips(src: &str) {
let parsed: JsonValue = serde_json::from_str(src).expect("fixture is valid JSON");
let out = value_to_json(&json_to_value(&parsed));
assert_eq!(out, parsed, "round-trip changed the document\nin: {src}");
}
#[test]
fn empty_object_round_trips() {
assert_round_trips("{}");
assert_round_trips(r#"{"a":{}}"#);
assert_round_trips(r#"{"a":{"b":{}}}"#);
assert_round_trips(r#"[{},{}]"#);
}
#[test]
fn empty_array_is_not_confused_for_an_object() {
assert_round_trips("[]");
assert_round_trips(r#"{"a":[]}"#);
assert_round_trips(r#"{"obj":{},"arr":[]}"#);
}
#[test]
fn docker_config_with_credstore_entries_survives() {
let src = r#"{
"auths": {
"ghcr.io": {},
"localhost:5000": {},
"registry.example.com": {"auth":"dXNlcjpwYXNz"}
},
"credsStore": "desktop",
"currentContext": "desktop-linux",
"features": {"hooks":"true"}
}"#;
assert_round_trips(src);
let parsed: JsonValue = serde_json::from_str(src).unwrap();
let out = value_to_json(&json_to_value(&parsed));
assert!(
out["auths"]["ghcr.io"].is_object(),
"credsStore-backed entry must stay an object, got {}",
out["auths"]["ghcr.io"]
);
}
#[test]
fn non_empty_objects_stay_alists() {
let v = json_to_value(&serde_json::json!({"a": 1}));
assert!(
matches!(v, Value::List(_)),
"non-empty object must be an alist"
);
assert!(matches!(alist_lookup(&v, "a"), Some(Value::Int(1))));
}
#[test]
fn map_serializes_as_object_not_null() {
let mut m = HashMap::new();
m.insert(MapKey::Str(Arc::from("k")), Value::Int(7));
let out = value_to_json(&Value::Map(Arc::new(m)));
assert_eq!(out, serde_json::json!({"k": 7}));
}
#[test]
fn map_with_non_string_keys_keeps_every_entry() {
let mut m = HashMap::new();
m.insert(MapKey::Int(1), Value::Str(Arc::from("one")));
m.insert(MapKey::Bool(true), Value::Str(Arc::from("yes")));
let out = value_to_json(&Value::Map(Arc::new(m)));
assert_eq!(out, serde_json::json!({"1": "one", "true": "yes"}));
}
}