tatara_lisp_script/stdlib/
toml.rs1use std::sync::Arc;
9
10use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
11use toml::Value as TomlValue;
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 "toml-parse",
19 Arity::Exact(1),
20 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
21 let s = str_arg(&args[0], "toml-parse", sp)?;
22 let parsed: toml::Table = s.parse().map_err(|e: toml::de::Error| {
27 EvalError::native_fn("toml-parse", e.to_string(), sp)
28 })?;
29 Ok(toml_to_value(&TomlValue::Table(parsed)))
30 },
31 );
32
33 interp.register_fn(
34 "toml-read",
35 Arity::Exact(1),
36 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
37 let path = str_arg(&args[0], "toml-read", sp)?;
38 let body = std::fs::read_to_string(&*path)
39 .map_err(|e| EvalError::native_fn("toml-read", format!("{path}: {e}"), sp))?;
40 let parsed: toml::Table = body.parse().map_err(|e: toml::de::Error| {
42 EvalError::native_fn("toml-read", e.to_string(), sp)
43 })?;
44 Ok(toml_to_value(&TomlValue::Table(parsed)))
45 },
46 );
47
48 interp.register_fn(
49 "toml-stringify",
50 Arity::Exact(1),
51 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
52 let tv = root_toml(&args[0]).ok_or_else(|| {
53 EvalError::native_fn(
54 "toml-stringify",
55 "TOML requires a table at the root".to_string(),
56 sp,
57 )
58 })?;
59 let s = toml::to_string(&tv)
60 .map_err(|e| EvalError::native_fn("toml-stringify", e.to_string(), sp))?;
61 Ok(Value::Str(Arc::from(s)))
62 },
63 );
64}
65
66fn toml_to_value(t: &TomlValue) -> Value {
67 match t {
68 TomlValue::String(s) => Value::Str(Arc::from(s.as_str())),
69 TomlValue::Integer(n) => Value::Int(*n),
70 TomlValue::Float(f) => Value::Float(*f),
71 TomlValue::Boolean(b) => Value::Bool(*b),
72 TomlValue::Datetime(d) => Value::Str(Arc::from(d.to_string())),
73 TomlValue::Array(xs) => Value::list(xs.iter().map(toml_to_value).collect::<Vec<_>>()),
74 TomlValue::Table(m) => Value::list(
75 m.iter()
76 .map(|(k, v)| {
77 Value::list(vec![Value::Str(Arc::from(k.as_str())), toml_to_value(v)])
78 })
79 .collect::<Vec<_>>(),
80 ),
81 }
82}
83
84fn root_toml(v: &Value) -> Option<TomlValue> {
91 match value_to_toml(v)? {
92 TomlValue::Array(a) if a.is_empty() => Some(TomlValue::Table(toml::map::Map::new())),
93 other => Some(other),
94 }
95}
96
97fn value_to_toml(v: &Value) -> Option<TomlValue> {
98 match v {
99 Value::Nil => None,
100 Value::Bool(b) => Some(TomlValue::Boolean(*b)),
101 Value::Int(n) => Some(TomlValue::Integer(*n)),
102 Value::Float(f) => Some(TomlValue::Float(*f)),
103 Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
104 Some(TomlValue::String(s.as_ref().to_owned()))
105 }
106 Value::List(xs) => {
107 let looks_like_table = !xs.is_empty()
108 && xs.iter().all(|entry| {
109 if let Value::List(pair) = entry {
110 pair.len() == 2
111 && matches!(
112 pair[0],
113 Value::Str(_) | Value::Symbol(_) | Value::Keyword(_)
114 )
115 } else {
116 false
117 }
118 });
119 if looks_like_table {
120 let mut m = toml::map::Map::new();
121 for entry in xs.iter() {
122 if let Value::List(pair) = entry {
123 let k = match &pair[0] {
124 Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => {
125 s.as_ref().to_owned()
126 }
127 _ => unreachable!(),
128 };
129 if let Some(v) = value_to_toml(&pair[1]) {
130 m.insert(k, v);
131 }
132 }
133 }
134 Some(TomlValue::Table(m))
135 } else {
136 Some(TomlValue::Array(
137 xs.iter().filter_map(value_to_toml).collect(),
138 ))
139 }
140 }
141 _ => None,
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn parse_document(src: &str) -> Value {
155 let table: toml::Table = src
156 .parse()
157 .unwrap_or_else(|e| panic!("document must parse: {src:?}: {e}"));
158 toml_to_value(&TomlValue::Table(table))
159 }
160
161 #[test]
164 fn an_empty_document_parses_to_an_empty_table() {
165 let v = parse_document("");
166 let back = root_toml(&v).expect("root is a table");
167 assert_eq!(toml::to_string(&back).expect("serialises"), "");
168 }
169
170 #[test]
173 fn a_hyphenated_bare_key_parses() {
174 let v = parse_document(r#"default-server = "nexus""#);
175 let rendered = root_toml(&v).expect("root is a table");
176 assert_eq!(
177 toml::to_string(&rendered).expect("serialises"),
178 "default-server = \"nexus\"\n"
179 );
180 }
181
182 #[test]
183 fn nested_tables_survive_a_round_trip() {
184 let src = "default-server = \"nexus\"\n\n[servers.nexus]\nendpoint = \"http://rio:8080/nexus\"\ntoken = \"t\"\n";
185 let v = parse_document(src);
186 let back = root_toml(&v).expect("root is a table");
187 let out = toml::to_string(&back).expect("serialises");
188 let reparsed: toml::Table = out.parse().expect("output re-parses");
189 let original: toml::Table = src.parse().expect("input parses");
190 assert_eq!(
191 reparsed, original,
192 "round-trip changed the document (tokens live in these tables)"
193 );
194 }
195
196 #[test]
199 fn malformed_toml_is_still_rejected() {
200 let bad: Result<toml::Table, _> = "a = = 1".parse();
201 assert!(bad.is_err(), "malformed TOML must not parse");
202 }
203}