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
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
183fn 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
192fn 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
244fn 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}