Skip to main content

sim_cookbook/
toml_lite.rs

1//! A strict parser for the tiny TOML subset the cookbook manifests use.
2//!
3//! Supported, and nothing else:
4//! - full-line comments (`# ...`) and trailing comments outside strings,
5//! - top-level `key = value` where value is a quoted string, an integer, a
6//!   bool, or a single-line array of quoted strings,
7//! - `[[expect]]` array-of-tables, each holding `key = value` lines.
8//!
9//! Anything the parser does not understand is a hard error with a line number,
10//! so a malformed manifest fails loudly instead of being silently misread.
11
12/// A scalar or string-array value from a manifest line.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum TomlValue {
15    /// A quoted string.
16    Str(String),
17    /// A signed integer.
18    Int(i64),
19    /// A boolean.
20    Bool(bool),
21    /// A single-line array of strings.
22    Array(Vec<String>),
23}
24
25impl TomlValue {
26    /// The string value, or an error naming the actual type.
27    pub fn as_str(&self) -> Result<&str, String> {
28        match self {
29            Self::Str(s) => Ok(s),
30            other => Err(format!("expected string, found {}", other.type_name())),
31        }
32    }
33
34    /// The integer value, or an error naming the actual type.
35    pub fn as_int(&self) -> Result<i64, String> {
36        match self {
37            Self::Int(n) => Ok(*n),
38            other => Err(format!("expected integer, found {}", other.type_name())),
39        }
40    }
41
42    /// The string-array value, or an error naming the actual type.
43    pub fn as_array(&self) -> Result<&[String], String> {
44        match self {
45            Self::Array(items) => Ok(items),
46            other => Err(format!("expected array, found {}", other.type_name())),
47        }
48    }
49
50    fn type_name(&self) -> &'static str {
51        match self {
52            Self::Str(_) => "string",
53            Self::Int(_) => "integer",
54            Self::Bool(_) => "bool",
55            Self::Array(_) => "array",
56        }
57    }
58}
59
60/// A parsed manifest: top-level keys plus any `[[name]]` array-of-tables.
61#[derive(Clone, Debug, Default, PartialEq, Eq)]
62pub struct TomlDoc {
63    /// Top-level `key = value` entries, in source order.
64    pub top: Vec<(String, TomlValue)>,
65    /// One `(name, entries)` per `[[name]]` table, in source order.
66    pub tables: Vec<(String, Vec<(String, TomlValue)>)>,
67}
68
69impl TomlDoc {
70    /// Look up a top-level key.
71    pub fn get(&self, key: &str) -> Option<&TomlValue> {
72        self.top.iter().find(|(k, _)| k == key).map(|(_, v)| v)
73    }
74
75    /// Every `[[name]]` table with the given name, in source order.
76    pub fn tables_named(&self, name: &str) -> Vec<&[(String, TomlValue)]> {
77        self.tables
78            .iter()
79            .filter(|(n, _)| n == name)
80            .map(|(_, t)| t.as_slice())
81            .collect()
82    }
83
84    /// Reject any top-level key not in `allowed` (strict schema check).
85    pub fn reject_unknown_top(&self, allowed: &[&str]) -> Result<(), String> {
86        for (k, _) in &self.top {
87            if !allowed.contains(&k.as_str()) {
88                return Err(format!("unknown key `{k}`"));
89            }
90        }
91        Ok(())
92    }
93
94    /// Reject any `[[name]]` table whose name is not in `allowed`.
95    pub fn reject_unknown_tables(&self, allowed: &[&str]) -> Result<(), String> {
96        for (n, _) in &self.tables {
97            if !allowed.contains(&n.as_str()) {
98                return Err(format!("unknown table `[[{n}]]`"));
99            }
100        }
101        Ok(())
102    }
103}
104
105/// If `line` is a `[[name]]` array-of-table header, return `name`.
106fn array_table_header(line: &str) -> Option<String> {
107    let inner = line.strip_prefix("[[")?.strip_suffix("]]")?;
108    let name = inner.trim();
109    if !name.is_empty()
110        && name
111            .chars()
112            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
113    {
114        Some(name.to_string())
115    } else {
116        None
117    }
118}
119
120/// Parse the full manifest text. Returns an error string with a line number on
121/// the first construct it cannot accept.
122pub fn parse(text: &str) -> Result<TomlDoc, String> {
123    let mut doc = TomlDoc::default();
124    // None = top level; Some(idx) = inside `tables[idx]`.
125    let mut table: Option<usize> = None;
126
127    for (i, raw) in text.lines().enumerate() {
128        let line_no = i + 1;
129        let line = strip_trailing_comment(raw).trim();
130        if line.is_empty() {
131            continue;
132        }
133        if let Some(name) = array_table_header(line) {
134            doc.tables.push((name, Vec::new()));
135            table = Some(doc.tables.len() - 1);
136            continue;
137        }
138        if line.starts_with('[') {
139            return Err(format!("line {line_no}: unsupported table header `{line}`"));
140        }
141        let (key, value) = parse_assignment(line).map_err(|e| format!("line {line_no}: {e}"))?;
142        match table {
143            None => doc.top.push((key, value)),
144            Some(idx) => doc.tables[idx].1.push((key, value)),
145        }
146    }
147    Ok(doc)
148}
149
150fn parse_assignment(line: &str) -> Result<(String, TomlValue), String> {
151    let eq = line.find('=').ok_or("expected `key = value`")?;
152    let key = line[..eq].trim();
153    if key.is_empty()
154        || !key
155            .chars()
156            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
157    {
158        return Err(format!("invalid key `{key}`"));
159    }
160    let value = parse_value(line[eq + 1..].trim())?;
161    Ok((key.to_string(), value))
162}
163
164fn parse_value(s: &str) -> Result<TomlValue, String> {
165    if s.starts_with('"') {
166        return Ok(TomlValue::Str(parse_string(s)?));
167    }
168    if s.starts_with('[') {
169        return Ok(TomlValue::Array(parse_string_array(s)?));
170    }
171    if s == "true" {
172        return Ok(TomlValue::Bool(true));
173    }
174    if s == "false" {
175        return Ok(TomlValue::Bool(false));
176    }
177    if let Ok(n) = s.parse::<i64>() {
178        return Ok(TomlValue::Int(n));
179    }
180    Err(format!("unrecognized value `{s}`"))
181}
182
183/// Parse one double-quoted string that occupies the whole of `s`.
184fn parse_string(s: &str) -> Result<String, String> {
185    let (value, rest) = take_string(s)?;
186    if !rest.trim().is_empty() {
187        return Err(format!("trailing text after string: `{}`", rest.trim()));
188    }
189    Ok(value)
190}
191
192/// Parse a leading double-quoted string, returning it and the remainder.
193fn take_string(s: &str) -> Result<(String, &str), String> {
194    let bytes = s.as_bytes();
195    if bytes.first() != Some(&b'"') {
196        return Err("expected `\"`".to_string());
197    }
198    let mut out = String::new();
199    let mut chars = s.char_indices().skip(1);
200    while let Some((idx, c)) = chars.next() {
201        match c {
202            '"' => return Ok((out, &s[idx + 1..])),
203            '\\' => match chars.next() {
204                Some((_, 'n')) => out.push('\n'),
205                Some((_, 't')) => out.push('\t'),
206                Some((_, '"')) => out.push('"'),
207                Some((_, '\\')) => out.push('\\'),
208                Some((_, other)) => out.push(other),
209                None => return Err("unterminated escape".to_string()),
210            },
211            other => out.push(other),
212        }
213    }
214    Err("unterminated string".to_string())
215}
216
217fn parse_string_array(s: &str) -> Result<Vec<String>, String> {
218    let s = s.strip_prefix('[').ok_or("expected `[`")?;
219    let inner = s
220        .strip_suffix(']')
221        .ok_or("unterminated array (missing `]`)")?;
222    let mut items = Vec::new();
223    let mut rest = inner.trim();
224    while !rest.is_empty() {
225        if !rest.starts_with('"') {
226            return Err(format!(
227                "array elements must be quoted strings, found `{rest}`"
228            ));
229        }
230        let (value, after) = take_string(rest)?;
231        items.push(value);
232        rest = after.trim_start();
233        if let Some(stripped) = rest.strip_prefix(',') {
234            rest = stripped.trim_start();
235        } else if !rest.is_empty() {
236            return Err(format!(
237                "expected `,` between array elements, found `{rest}`"
238            ));
239        }
240    }
241    Ok(items)
242}
243
244/// Cut a line at the first `#` that is not inside a double-quoted string.
245fn strip_trailing_comment(line: &str) -> &str {
246    let mut in_string = false;
247    let mut escaped = false;
248    for (idx, c) in line.char_indices() {
249        if in_string {
250            if escaped {
251                escaped = false;
252            } else if c == '\\' {
253                escaped = true;
254            } else if c == '"' {
255                in_string = false;
256            }
257        } else if c == '"' {
258            in_string = true;
259        } else if c == '#' {
260            return &line[..idx];
261        }
262    }
263    line
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn parses_scalars_and_arrays() {
272        let doc = parse(
273            r#"
274            # a comment
275            id = "add"        # trailing comment
276            order = 100
277            flag = true
278            tags = ["a", "b"]
279            empty = []
280            "#,
281        )
282        .unwrap();
283        assert_eq!(doc.get("id").unwrap().as_str().unwrap(), "add");
284        assert_eq!(doc.get("order").unwrap().as_int().unwrap(), 100);
285        assert_eq!(doc.get("flag").unwrap(), &TomlValue::Bool(true));
286        assert_eq!(doc.get("tags").unwrap().as_array().unwrap(), &["a", "b"]);
287        assert!(doc.get("empty").unwrap().as_array().unwrap().is_empty());
288    }
289
290    #[test]
291    fn parses_named_tables() {
292        let doc = parse("title = \"x\"\n[[expect]]\nform = 0\nresult = \"3\"\n").unwrap();
293        let expect = doc.tables_named("expect");
294        assert_eq!(expect.len(), 1);
295        assert_eq!(expect[0][0].0, "form");
296        assert_eq!(expect[0][1].1.as_str().unwrap(), "3");
297        assert!(doc.reject_unknown_tables(&["expect"]).is_ok());
298        assert!(doc.reject_unknown_tables(&[]).is_err());
299    }
300
301    #[test]
302    fn parses_multiple_named_tables() {
303        let doc =
304            parse("[[hide]]\nrecipe = \"a\"\n[[reorder]]\nrecipe = \"b\"\norder = 1\n").unwrap();
305        assert_eq!(doc.tables_named("hide").len(), 1);
306        assert_eq!(doc.tables_named("reorder").len(), 1);
307    }
308
309    #[test]
310    fn hash_inside_string_is_kept() {
311        let doc = parse("title = \"a # b\"\n").unwrap();
312        assert_eq!(doc.get("title").unwrap().as_str().unwrap(), "a # b");
313    }
314
315    #[test]
316    fn rejects_unterminated_string() {
317        assert!(parse("id = \"oops\n").is_err());
318    }
319
320    #[test]
321    fn rejects_unknown_table() {
322        let err = parse("[server]\n").unwrap_err();
323        assert!(err.contains("unsupported table header"));
324    }
325
326    #[test]
327    fn reject_unknown_top_flags_extra_keys() {
328        let doc = parse("id = \"x\"\nbogus = 1\n").unwrap();
329        assert!(doc.reject_unknown_top(&["id"]).is_err());
330        assert!(doc.reject_unknown_top(&["id", "bogus"]).is_ok());
331    }
332}