1#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum TomlValue {
15 Str(String),
17 Int(i64),
19 Bool(bool),
21 Array(Vec<String>),
23}
24
25impl TomlValue {
26 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 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
62pub struct TomlDoc {
63 pub top: Vec<(String, TomlValue)>,
65 pub tables: Vec<(String, Vec<(String, TomlValue)>)>,
67}
68
69impl TomlDoc {
70 pub fn get(&self, key: &str) -> Option<&TomlValue> {
72 self.top.iter().find(|(k, _)| k == key).map(|(_, v)| v)
73 }
74
75 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 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 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
105fn 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
120pub fn parse(text: &str) -> Result<TomlDoc, String> {
123 let mut doc = TomlDoc::default();
124 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
167fn 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
227fn 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
236fn 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
288fn 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}