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 an array of quoted strings (on one or several lines),
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    /// An 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    let mut lines = text.lines().enumerate();
127
128    while let Some((i, raw)) = lines.next() {
129        let line_no = i + 1;
130        let line = strip_trailing_comment(raw).trim();
131        if line.is_empty() {
132            continue;
133        }
134        if let Some(name) = array_table_header(line) {
135            doc.tables.push((name, Vec::new()));
136            table = Some(doc.tables.len() - 1);
137            continue;
138        }
139        if line.starts_with('[') {
140            return Err(format!("line {line_no}: unsupported table header `{line}`"));
141        }
142        let mut assignment = line.to_string();
143        if assignment_starts_array(&assignment) && !array_is_complete(&assignment) {
144            for (_, continuation) in lines.by_ref() {
145                assignment.push('\n');
146                assignment.push_str(strip_trailing_comment(continuation).trim());
147                if array_is_complete(&assignment) {
148                    break;
149                }
150            }
151        }
152        let (key, value) =
153            parse_assignment(&assignment).map_err(|e| format!("line {line_no}: {e}"))?;
154        match table {
155            None => doc.top.push((key, value)),
156            Some(idx) => doc.tables[idx].1.push((key, value)),
157        }
158    }
159    Ok(doc)
160}
161
162fn assignment_starts_array(line: &str) -> bool {
163    line.split_once('=')
164        .is_some_and(|(_, value)| value.trim_start().starts_with('['))
165}
166
167/// Whether the array value in an assignment has a closing bracket outside a
168/// quoted string. The subset has no nested arrays, but brackets inside strings
169/// are valid data and must not terminate the value.
170fn array_is_complete(line: &str) -> bool {
171    let Some((_, value)) = line.split_once('=') else {
172        return false;
173    };
174    let mut in_string = false;
175    let mut escaped = false;
176    for c in value.chars() {
177        if in_string {
178            if escaped {
179                escaped = false;
180            } else if c == '\\' {
181                escaped = true;
182            } else if c == '"' {
183                in_string = false;
184            }
185        } else if c == '"' {
186            in_string = true;
187        } else if c == ']' {
188            return true;
189        }
190    }
191    false
192}
193
194fn parse_assignment(line: &str) -> Result<(String, TomlValue), String> {
195    let eq = line.find('=').ok_or("expected `key = value`")?;
196    let key = line[..eq].trim();
197    if key.is_empty()
198        || !key
199            .chars()
200            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
201    {
202        return Err(format!("invalid key `{key}`"));
203    }
204    let value = parse_value(line[eq + 1..].trim())?;
205    Ok((key.to_string(), value))
206}
207
208fn parse_value(s: &str) -> Result<TomlValue, String> {
209    if s.starts_with('"') {
210        return Ok(TomlValue::Str(parse_string(s)?));
211    }
212    if s.starts_with('[') {
213        return Ok(TomlValue::Array(parse_string_array(s)?));
214    }
215    if s == "true" {
216        return Ok(TomlValue::Bool(true));
217    }
218    if s == "false" {
219        return Ok(TomlValue::Bool(false));
220    }
221    if let Ok(n) = s.parse::<i64>() {
222        return Ok(TomlValue::Int(n));
223    }
224    Err(format!("unrecognized value `{s}`"))
225}
226
227/// Parse one double-quoted string that occupies the whole of `s`.
228fn parse_string(s: &str) -> Result<String, String> {
229    let (value, rest) = take_string(s)?;
230    if !rest.trim().is_empty() {
231        return Err(format!("trailing text after string: `{}`", rest.trim()));
232    }
233    Ok(value)
234}
235
236/// Parse a leading double-quoted string, returning it and the remainder.
237fn take_string(s: &str) -> Result<(String, &str), String> {
238    let bytes = s.as_bytes();
239    if bytes.first() != Some(&b'"') {
240        return Err("expected `\"`".to_string());
241    }
242    let mut out = String::new();
243    let mut chars = s.char_indices().skip(1);
244    while let Some((idx, c)) = chars.next() {
245        match c {
246            '"' => return Ok((out, &s[idx + 1..])),
247            '\\' => match chars.next() {
248                Some((_, 'n')) => out.push('\n'),
249                Some((_, 't')) => out.push('\t'),
250                Some((_, '"')) => out.push('"'),
251                Some((_, '\\')) => out.push('\\'),
252                Some((_, other)) => out.push(other),
253                None => return Err("unterminated escape".to_string()),
254            },
255            other => out.push(other),
256        }
257    }
258    Err("unterminated string".to_string())
259}
260
261fn parse_string_array(s: &str) -> Result<Vec<String>, String> {
262    let s = s.strip_prefix('[').ok_or("expected `[`")?;
263    let inner = s
264        .strip_suffix(']')
265        .ok_or("unterminated array (missing `]`)")?;
266    let mut items = Vec::new();
267    let mut rest = inner.trim();
268    while !rest.is_empty() {
269        if !rest.starts_with('"') {
270            return Err(format!(
271                "array elements must be quoted strings, found `{rest}`"
272            ));
273        }
274        let (value, after) = take_string(rest)?;
275        items.push(value);
276        rest = after.trim_start();
277        if let Some(stripped) = rest.strip_prefix(',') {
278            rest = stripped.trim_start();
279        } else if !rest.is_empty() {
280            return Err(format!(
281                "expected `,` between array elements, found `{rest}`"
282            ));
283        }
284    }
285    Ok(items)
286}
287
288/// Cut a line at the first `#` that is not inside a double-quoted string.
289fn strip_trailing_comment(line: &str) -> &str {
290    let mut in_string = false;
291    let mut escaped = false;
292    for (idx, c) in line.char_indices() {
293        if in_string {
294            if escaped {
295                escaped = false;
296            } else if c == '\\' {
297                escaped = true;
298            } else if c == '"' {
299                in_string = false;
300            }
301        } else if c == '"' {
302            in_string = true;
303        } else if c == '#' {
304            return &line[..idx];
305        }
306    }
307    line
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn parses_scalars_and_arrays() {
316        let doc = parse(
317            r#"
318            # a comment
319            id = "add"        # trailing comment
320            order = 100
321            flag = true
322            tags = ["a", "b"]
323            empty = []
324            "#,
325        )
326        .unwrap();
327        assert_eq!(doc.get("id").unwrap().as_str().unwrap(), "add");
328        assert_eq!(doc.get("order").unwrap().as_int().unwrap(), 100);
329        assert_eq!(doc.get("flag").unwrap(), &TomlValue::Bool(true));
330        assert_eq!(doc.get("tags").unwrap().as_array().unwrap(), &["a", "b"]);
331        assert!(doc.get("empty").unwrap().as_array().unwrap().is_empty());
332    }
333
334    #[test]
335    fn parses_multiline_string_arrays_with_comments_and_trailing_comma() {
336        let doc = parse(
337            r#"
338            chapters = [
339              "01-basics",
340              "02-techniques", # retained ordering
341              "bracket-]--inside-string",
342            ]
343            title = "Book"
344            "#,
345        )
346        .unwrap();
347        assert_eq!(
348            doc.get("chapters").unwrap().as_array().unwrap(),
349            ["01-basics", "02-techniques", "bracket-]--inside-string"]
350        );
351        assert_eq!(doc.get("title").unwrap().as_str().unwrap(), "Book");
352    }
353
354    #[test]
355    fn parses_named_tables() {
356        let doc = parse("title = \"x\"\n[[expect]]\nform = 0\nresult = \"3\"\n").unwrap();
357        let expect = doc.tables_named("expect");
358        assert_eq!(expect.len(), 1);
359        assert_eq!(expect[0][0].0, "form");
360        assert_eq!(expect[0][1].1.as_str().unwrap(), "3");
361        assert!(doc.reject_unknown_tables(&["expect"]).is_ok());
362        assert!(doc.reject_unknown_tables(&[]).is_err());
363    }
364
365    #[test]
366    fn parses_multiple_named_tables() {
367        let doc =
368            parse("[[hide]]\nrecipe = \"a\"\n[[reorder]]\nrecipe = \"b\"\norder = 1\n").unwrap();
369        assert_eq!(doc.tables_named("hide").len(), 1);
370        assert_eq!(doc.tables_named("reorder").len(), 1);
371    }
372
373    #[test]
374    fn hash_inside_string_is_kept() {
375        let doc = parse("title = \"a # b\"\n").unwrap();
376        assert_eq!(doc.get("title").unwrap().as_str().unwrap(), "a # b");
377    }
378
379    #[test]
380    fn rejects_unterminated_string() {
381        assert!(parse("id = \"oops\n").is_err());
382    }
383
384    #[test]
385    fn rejects_unknown_table() {
386        let err = parse("[server]\n").unwrap_err();
387        assert!(err.contains("unsupported table header"));
388    }
389
390    #[test]
391    fn reject_unknown_top_flags_extra_keys() {
392        let doc = parse("id = \"x\"\nbogus = 1\n").unwrap();
393        assert!(doc.reject_unknown_top(&["id"]).is_err());
394        assert!(doc.reject_unknown_top(&["id", "bogus"]).is_ok());
395    }
396}