Skip to main content

tatara_lisp_script/stdlib/
toml.rs

1//! TOML parse + stringify. Same `Value` shape as JSON/YAML (objects
2//! become alists of 2-lists) so `alist-get` works uniformly.
3//!
4//!   (toml-parse STR)       → nested Value
5//!   (toml-read PATH)       → parse a file
6//!   (toml-stringify VALUE) → TOML text
7
8use 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            // `Table`, not `Value`. Under toml 0.9 (spec 1.1) `FromStr for
23            // Value` parses a single TOML *value*, so a DOCUMENT — the only
24            // thing anyone passes here — failed on its first `key = …`, and
25            // even `""` failed. Documents parse as `Table`.
26            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            // See the note in `toml-parse`: a file is a document, so `Table`.
41            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
84/// `value_to_toml`, plus the one decision only the root can make.
85///
86/// An empty alist and an empty array are the SAME `Value`, so the shared
87/// mapper cannot tell them apart and guesses Array. At the root that guess is
88/// always wrong — TOML's grammar admits only a table there — so it is resolved
89/// here rather than by making the mapper guess differently everywhere else.
90fn 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    /// A DOCUMENT must parse. Under toml 0.9 (spec 1.1) `FromStr for Value`
150    /// parses a single TOML *value*, so parsing a document through `Value`
151    /// rejected every real input — including the empty document — with
152    /// "unexpected content, expected nothing". There were no TOML tests in
153    /// this crate at all, which is why it shipped that way.
154    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    /// `Value` has no `PartialEq`, so the assertion goes back through TOML:
162    /// an empty document must survive the trip as an empty document.
163    #[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    /// The shape that first exposed the bug: attic's `config.toml` opens with
171    /// `default-server = "…"`. Dashes are legal in TOML bare keys.
172    #[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    /// A genuinely malformed document must still be an error, so the fix did
197    /// not simply make every input succeed.
198    #[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}