1use std::collections::BTreeMap;
2use std::fmt;
3use std::ops::Range;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Value {
7 Scalar(String),
8 List(Vec<String>),
9}
10
11impl Value {
12 pub fn as_scalar(&self) -> Option<&str> {
13 match self {
14 Value::Scalar(text) => Some(text),
15 Value::List(_) => None,
16 }
17 }
18
19 pub fn as_list(&self) -> Vec<&str> {
20 match self {
21 Value::Scalar(text) => std::vec![text.as_str()],
22 Value::List(items) => items.iter().map(String::as_str).collect(),
23 }
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct Section {
29 pub scalars: Vec<(String, Value)>,
30 pub sections: Vec<(String, Section)>,
31}
32
33impl Section {
34 pub fn get(&self, key: &str) -> Option<&Value> {
35 self.scalars
36 .iter()
37 .find(|(name, _)| name == key)
38 .map(|(_, value)| value)
39 }
40
41 pub fn section(&self, name: &str) -> Option<&Section> {
42 self.sections
43 .iter()
44 .find(|(child, _)| child == name)
45 .map(|(_, section)| section)
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ConfigError {
51 UnterminatedQuote {
52 line: usize,
53 },
54 UnmatchedSectionBrackets {
55 line: usize,
56 },
57 SectionDepthJump {
58 line: usize,
59 found: usize,
60 parent: usize,
61 },
62 DuplicateKey {
63 line: usize,
64 key: String,
65 },
66 DuplicateSection {
67 line: usize,
68 name: String,
69 },
70 MissingEquals {
71 line: usize,
72 },
73 EmptyKey {
74 line: usize,
75 },
76 MalformedList {
77 line: usize,
78 },
79 MalformedValue {
80 line: usize,
81 },
82}
83
84impl fmt::Display for ConfigError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 ConfigError::UnterminatedQuote { line } => {
88 write!(f, "line {line}: unterminated quoted value")
89 }
90 ConfigError::UnmatchedSectionBrackets { line } => {
91 write!(f, "line {line}: section brackets do not match")
92 }
93 ConfigError::SectionDepthJump { line, found, parent } => write!(
94 f,
95 "line {line}: section nested {found} deep under a section {parent} deep (skipped a level)"
96 ),
97 ConfigError::DuplicateKey { line, key } => {
98 write!(f, "line {line}: duplicate key '{key}'")
99 }
100 ConfigError::DuplicateSection { line, name } => {
101 write!(f, "line {line}: duplicate section '{name}'")
102 }
103 ConfigError::MissingEquals { line } => {
104 write!(f, "line {line}: expected 'key = value'")
105 }
106 ConfigError::EmptyKey { line } => write!(f, "line {line}: empty key name"),
107 ConfigError::MalformedList { line } => {
108 write!(f, "line {line}: malformed comma-separated value")
109 }
110 ConfigError::MalformedValue { line } => write!(f, "line {line}: malformed value"),
111 }
112 }
113}
114
115impl std::error::Error for ConfigError {}
116
117impl ConfigError {
118 pub fn line(&self) -> usize {
119 match self {
120 ConfigError::UnterminatedQuote { line }
121 | ConfigError::UnmatchedSectionBrackets { line }
122 | ConfigError::SectionDepthJump { line, .. }
123 | ConfigError::DuplicateKey { line, .. }
124 | ConfigError::DuplicateSection { line, .. }
125 | ConfigError::MissingEquals { line }
126 | ConfigError::EmptyKey { line }
127 | ConfigError::MalformedList { line }
128 | ConfigError::MalformedValue { line } => *line,
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub struct SourceLocations {
135 lines: BTreeMap<Vec<String>, usize>,
136}
137
138impl SourceLocations {
139 pub fn line<I, S>(&self, path: I) -> Option<usize>
140 where
141 I: IntoIterator<Item = S>,
142 S: AsRef<str>,
143 {
144 let path = path
145 .into_iter()
146 .map(|part| part.as_ref().to_string())
147 .collect::<Vec<_>>();
148 self.lines.get(&path).copied()
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ParsedConfigObj {
154 pub root: Section,
155 pub locations: SourceLocations,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159struct DocumentSection {
160 path: Vec<String>,
161 depth: usize,
162 range: Range<usize>,
163 header: Range<usize>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167struct DocumentKey {
168 path: Vec<String>,
169 range: Range<usize>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ConfigDocument {
174 source: String,
175 parsed: ParsedConfigObj,
176 sections: Vec<DocumentSection>,
177 keys: Vec<DocumentKey>,
178 newline: &'static str,
179}
180
181impl ConfigDocument {
182 pub fn parse(input: &str) -> Result<Self, ConfigError> {
183 parse_document(input)
184 }
185
186 pub fn source(&self) -> &str {
187 &self.source
188 }
189
190 pub fn root(&self) -> &Section {
191 &self.parsed.root
192 }
193
194 pub fn locations(&self) -> &SourceLocations {
195 &self.parsed.locations
196 }
197
198 pub fn newline(&self) -> &'static str {
199 self.newline
200 }
201
202 pub(crate) fn section_range(&self, path: &[&str]) -> Option<Range<usize>> {
203 self.sections
204 .iter()
205 .find(|section| path_matches(§ion.path, path))
206 .map(|section| section.range.clone())
207 }
208
209 pub(crate) fn section_header_range(&self, path: &[&str]) -> Option<Range<usize>> {
210 self.sections
211 .iter()
212 .find(|section| path_matches(§ion.path, path))
213 .map(|section| section.header.clone())
214 }
215
216 pub(crate) fn key_range(&self, path: &[&str]) -> Option<Range<usize>> {
217 self.keys
218 .iter()
219 .find(|key| path_matches(&key.path, path))
220 .map(|key| key.range.clone())
221 }
222
223 pub(crate) fn child_section_names(&self, path: &[&str]) -> Vec<&str> {
224 let depth = path.len() + 1;
225 self.sections
226 .iter()
227 .filter(|section| {
228 section.depth == depth
229 && section.path.len() == depth
230 && section
231 .path
232 .iter()
233 .zip(path)
234 .all(|(actual, expected)| actual == expected)
235 })
236 .filter_map(|section| section.path.last().map(String::as_str))
237 .collect()
238 }
239
240 pub(crate) fn first_child_section_start(&self, path: &[&str]) -> Option<usize> {
241 let depth = path.len() + 1;
242 self.sections
243 .iter()
244 .filter(|section| {
245 section.depth == depth
246 && section.path.len() == depth
247 && section
248 .path
249 .iter()
250 .zip(path)
251 .all(|(actual, expected)| actual == expected)
252 })
253 .map(|section| section.range.start)
254 .min()
255 }
256
257 pub(crate) fn section_value(&self, path: &[&str], key: &str) -> Option<&Value> {
258 let mut section = &self.parsed.root;
259 for part in path {
260 section = section.section(part)?;
261 }
262 section.get(key)
263 }
264
265 pub(crate) fn into_parsed(self) -> ParsedConfigObj {
266 self.parsed
267 }
268}
269
270fn path_matches(actual: &[String], expected: &[&str]) -> bool {
271 actual.len() == expected.len()
272 && actual
273 .iter()
274 .zip(expected)
275 .all(|(actual, expected)| actual == expected)
276}
277
278struct Frame {
279 name: String,
280 depth: usize,
281 section: Section,
282}
283
284#[derive(Default)]
285struct SectionStack {
286 root: Section,
287 open: Vec<Frame>,
288}
289
290impl SectionStack {
291 fn current_depth(&self) -> usize {
292 self.open.last().map_or(0, |frame| frame.depth)
293 }
294
295 fn current_section(&self) -> &Section {
296 self.open.last().map_or(&self.root, |frame| &frame.section)
297 }
298
299 fn current_section_mut(&mut self) -> &mut Section {
300 self.open
301 .last_mut()
302 .map_or(&mut self.root, |frame| &mut frame.section)
303 }
304
305 fn open(&mut self, name: String, depth: usize) {
306 self.open.push(Frame {
307 name,
308 depth,
309 section: Section::default(),
310 });
311 }
312
313 fn close_to(&mut self, target_depth: usize) {
314 while self
315 .open
316 .last()
317 .is_some_and(|frame| frame.depth > target_depth)
318 {
319 let Some(frame) = self.open.pop() else {
320 break;
321 };
322 self.current_section_mut()
323 .sections
324 .push((frame.name, frame.section));
325 }
326 }
327
328 fn finish(mut self) -> Section {
329 self.close_to(0);
330 self.root
331 }
332}
333
334pub fn parse(input: &str) -> Result<Section, ConfigError> {
335 parse_located(input).map(|parsed| parsed.root)
336}
337
338pub fn parse_located(input: &str) -> Result<ParsedConfigObj, ConfigError> {
339 parse_document(input).map(ConfigDocument::into_parsed)
340}
341
342#[derive(Clone, Copy)]
343struct SourceLine<'a> {
344 number: usize,
345 start: usize,
346 full_end: usize,
347 text: &'a str,
348}
349
350fn source_lines(input: &str) -> Vec<SourceLine<'_>> {
351 let mut lines = Vec::new();
352 let mut start = 0;
353 for (index, raw) in input.split_inclusive('\n').enumerate() {
354 let full_end = start + raw.len();
355 let without_lf = raw.strip_suffix('\n').unwrap_or(raw);
356 let text = without_lf.strip_suffix('\r').unwrap_or(without_lf);
357 lines.push(SourceLine {
358 number: index + 1,
359 start,
360 full_end,
361 text,
362 });
363 start = full_end;
364 }
365 if input.is_empty() {
366 return lines;
367 }
368 if start < input.len() {
369 let text = &input[start..];
370 lines.push(SourceLine {
371 number: lines.len() + 1,
372 start,
373 full_end: input.len(),
374 text,
375 });
376 }
377 lines
378}
379
380fn parse_document(input: &str) -> Result<ConfigDocument, ConfigError> {
381 let mut stack = SectionStack::default();
382 let mut locations = SourceLocations::default();
383 let mut current_path = Vec::new();
384 let mut sections: Vec<DocumentSection> = Vec::new();
385 let mut open_sections: Vec<usize> = Vec::new();
386 let mut keys = Vec::new();
387 let lines = source_lines(input);
388 let mut index = 0;
389 while index < lines.len() {
390 let line = lines[index];
391 let line_no = line.number;
392 let trimmed = line.text.trim();
393 if trimmed.is_empty() || trimmed.starts_with('#') {
394 index += 1;
395 continue;
396 }
397
398 if trimmed.starts_with('[') {
399 let (name, depth) = parse_section_header(trimmed, line_no)?;
400 stack.close_to(depth - 1);
401 let parent_depth = stack.current_depth();
402 if depth != parent_depth + 1 {
403 return Err(ConfigError::SectionDepthJump {
404 line: line_no,
405 found: depth,
406 parent: parent_depth,
407 });
408 }
409 if stack.current_section().section(&name).is_some() {
410 return Err(ConfigError::DuplicateSection {
411 line: line_no,
412 name,
413 });
414 }
415 stack.open(name.clone(), depth);
416 current_path.truncate(depth - 1);
417 current_path.push(name);
418 locations.lines.insert(current_path.clone(), line_no);
419 while open_sections
420 .last()
421 .is_some_and(|open| sections[*open].depth >= depth)
422 {
423 let Some(open) = open_sections.pop() else {
424 break;
425 };
426 sections[open].range.end = line.start;
427 }
428 let section_index = sections.len();
429 sections.push(DocumentSection {
430 path: current_path.clone(),
431 depth,
432 range: line.start..input.len(),
433 header: line.start..line.full_end,
434 });
435 open_sections.push(section_index);
436 index += 1;
437 continue;
438 }
439
440 let key_start = line.start;
441 let mut key_end = line.full_end;
442 let mut raw_value_line = line.text.to_string();
443 while unterminated_triple(
444 raw_value_line
445 .split_once('=')
446 .map_or(raw_value_line.as_str(), |(_, value)| value),
447 )
448 .is_some()
449 {
450 index += 1;
451 let Some(next) = lines.get(index).copied() else {
452 return Err(ConfigError::UnterminatedQuote { line: line_no });
453 };
454 raw_value_line.push('\n');
455 raw_value_line.push_str(next.text);
456 key_end = next.full_end;
457 }
458 let (key, value) = parse_key_value(&raw_value_line, line_no)?;
459 let current = stack.current_section_mut();
460 if current.get(&key).is_some() {
461 return Err(ConfigError::DuplicateKey { line: line_no, key });
462 }
463 let mut key_path = current_path.clone();
464 key_path.push(key.clone());
465 locations.lines.insert(key_path.clone(), line_no);
466 keys.push(DocumentKey {
467 path: key_path,
468 range: key_start..key_end,
469 });
470 current.scalars.push((key, value));
471 index += 1;
472 }
473 for open in open_sections {
474 sections[open].range.end = input.len();
475 }
476 Ok(ConfigDocument {
477 source: input.to_string(),
478 parsed: ParsedConfigObj {
479 root: stack.finish(),
480 locations,
481 },
482 sections,
483 keys,
484 newline: if input.contains("\r\n") { "\r\n" } else { "\n" },
485 })
486}
487
488fn parse_section_header(trimmed: &str, line_no: usize) -> Result<(String, usize), ConfigError> {
489 let depth = trimmed.chars().take_while(|c| *c == '[').count();
490 let close_run = "]".repeat(depth);
491 let after_open = &trimmed[depth..];
492 let close_at = after_open
493 .find(&close_run)
494 .ok_or(ConfigError::UnmatchedSectionBrackets { line: line_no })?;
495 let name = after_open[..close_at].trim();
496 let tail = after_open[close_at + depth..].trim_start();
497 if !tail.is_empty() && !tail.starts_with('#') {
498 return Err(ConfigError::UnmatchedSectionBrackets { line: line_no });
499 }
500 Ok((unquote(name).to_string(), depth))
501}
502
503fn parse_key_value(raw_line: &str, line_no: usize) -> Result<(String, Value), ConfigError> {
504 let equals = raw_line
505 .find('=')
506 .ok_or(ConfigError::MissingEquals { line: line_no })?;
507 let key = unquote(raw_line[..equals].trim());
508 if key.is_empty() {
509 return Err(ConfigError::EmptyKey { line: line_no });
510 }
511 let value_text = &raw_line[equals + 1..];
512 Ok((key.to_string(), parse_value(value_text, line_no)?))
513}
514
515fn unterminated_triple(value_text: &str) -> Option<&'static str> {
516 let trimmed = value_text.trim_start();
517 for delimiter in ["\"\"\"", "'''"] {
518 if let Some(rest) = trimmed.strip_prefix(delimiter) {
519 if !rest.contains(delimiter) {
520 return Some(delimiter);
521 }
522 }
523 }
524 None
525}
526
527fn parse_value(raw: &str, line_no: usize) -> Result<Value, ConfigError> {
528 for delimiter in ["\"\"\"", "'''"] {
529 let trimmed = raw.trim();
530 if let Some(rest) = trimmed.strip_prefix(delimiter) {
531 let end = rest
532 .find(delimiter)
533 .ok_or(ConfigError::UnterminatedQuote { line: line_no })?;
534 return Ok(Value::Scalar(rest[..end].to_string()));
535 }
536 }
537
538 let mut elements = Vec::new();
539 let mut current = String::new();
540 let mut current_was_quoted = false;
541 let mut current_quote_closed = false;
542 let mut had_comma = false;
543 let mut chars = raw.chars().peekable();
544 while let Some(c) = chars.next() {
545 match c {
546 '\'' | '"' => {
547 if !current.trim().is_empty() {
548 current.push(c);
549 continue;
550 }
551 if current_was_quoted {
552 return Err(ConfigError::MalformedValue { line: line_no });
553 }
554 current_was_quoted = true;
555 let mut closed = false;
556 for inner in chars.by_ref() {
557 if inner == c {
558 closed = true;
559 break;
560 }
561 current.push(inner);
562 }
563 if !closed {
564 return Err(ConfigError::UnterminatedQuote { line: line_no });
565 }
566 current_quote_closed = true;
567 }
568 '#' => break,
569 ',' => {
570 had_comma = true;
571 elements.push((current.trim().to_string(), current_was_quoted));
572 current = String::new();
573 current_was_quoted = false;
574 current_quote_closed = false;
575 }
576 other if current_quote_closed && !other.is_whitespace() => {
577 return Err(ConfigError::MalformedValue { line: line_no });
578 }
579 other if !current_quote_closed => current.push(other),
580 _ => {}
581 }
582 }
583 let tail = current.trim();
584 if !tail.is_empty() || current_was_quoted || (had_comma && elements.is_empty()) {
585 elements.push((tail.to_string(), current_was_quoted));
586 }
587
588 if had_comma {
589 let standalone_empty_list =
590 elements.len() == 1 && elements[0].0.is_empty() && !elements[0].1 && tail.is_empty();
591 if !standalone_empty_list
592 && elements
593 .iter()
594 .any(|(element, quoted)| element.is_empty() && !quoted)
595 {
596 return Err(ConfigError::MalformedList { line: line_no });
597 }
598 Ok(Value::List(
599 elements
600 .into_iter()
601 .filter_map(|(element, quoted)| (!element.is_empty() || quoted).then_some(element))
602 .collect(),
603 ))
604 } else {
605 Ok(Value::Scalar(
606 elements
607 .into_iter()
608 .next()
609 .map(|(element, _)| element)
610 .unwrap_or_default(),
611 ))
612 }
613}
614
615fn unquote(text: &str) -> &str {
616 let bytes = text.as_bytes();
617 if bytes.len() >= 2 {
618 let first = bytes[0];
619 if (first == b'\'' || first == b'"') && bytes[bytes.len() - 1] == first {
620 return &text[1..text.len() - 1];
621 }
622 }
623 text
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 fn scalar(section: &Section, key: &str) -> String {
631 section
632 .get(key)
633 .and_then(Value::as_scalar)
634 .unwrap()
635 .to_string()
636 }
637
638 #[test]
639 fn nested_sections_track_bracket_depth() {
640 let root = parse(
641 "[reticulum]\n\
642 share_instance = Yes\n\
643 [interfaces]\n\
644 [[Default Interface]]\n\
645 type = AutoInterface\n\
646 enabled = Yes\n",
647 )
648 .unwrap();
649 assert_eq!(
650 scalar(root.section("reticulum").unwrap(), "share_instance"),
651 "Yes"
652 );
653 let interfaces = root.section("interfaces").unwrap();
654 let default = interfaces.section("Default Interface").unwrap();
655 assert_eq!(scalar(default, "type"), "AutoInterface");
656 }
657
658 #[test]
659 fn triple_nested_subinterfaces_attach_to_their_parent() {
660 let root = parse(
661 "[interfaces]\n\
662 [[Radio]]\n\
663 type = RNodeMultiInterface\n\
664 [[[Sub A]]]\n\
665 vport = 0\n\
666 [[[Sub B]]]\n\
667 vport = 1\n",
668 )
669 .unwrap();
670 let radio = root
671 .section("interfaces")
672 .unwrap()
673 .section("Radio")
674 .unwrap();
675 assert_eq!(radio.sections.len(), 2);
676 assert_eq!(scalar(radio.section("Sub A").unwrap(), "vport"), "0");
677 assert_eq!(scalar(radio.section("Sub B").unwrap(), "vport"), "1");
678 }
679
680 #[test]
681 fn comma_values_become_lists_and_lone_values_stay_scalar() {
682 let root = parse("[x]\ndevices = eth0, wlan0\nsingle = eth0\ntrailing = eth0,\n").unwrap();
683 let x = root.section("x").unwrap();
684 assert_eq!(
685 x.get("devices").unwrap().as_list(),
686 std::vec!["eth0", "wlan0"]
687 );
688 assert_eq!(x.get("single").unwrap(), &Value::Scalar("eth0".to_string()));
689 assert_eq!(
690 x.get("trailing").unwrap(),
691 &Value::List(std::vec!["eth0".to_string()])
692 );
693 }
694
695 #[test]
696 fn inline_and_full_line_comments_are_stripped() {
697 let root = parse("# top comment\n[x]\nkey = value # trailing\n").unwrap();
698 assert_eq!(scalar(root.section("x").unwrap(), "key"), "value");
699 }
700
701 #[test]
702 fn a_hash_inside_quotes_is_not_a_comment() {
703 let root = parse("[x]\npassphrase = \"a # b\"\n").unwrap();
704 assert_eq!(scalar(root.section("x").unwrap(), "passphrase"), "a # b");
705 }
706
707 #[test]
708 fn quoted_list_elements_keep_their_commas() {
709 let root = parse("[x]\npeers = \"a, b\", c\n").unwrap();
710 assert_eq!(
711 root.section("x").unwrap().get("peers").unwrap().as_list(),
712 std::vec!["a, b", "c"]
713 );
714 }
715
716 #[test]
717 fn malformed_quoted_and_list_values_are_rejected() {
718 assert!(matches!(
719 parse("[x]\nk = 'unterminated\n"),
720 Err(ConfigError::UnterminatedQuote { .. })
721 ));
722 assert!(matches!(
723 parse("[x]\nk = alpha,,beta\n"),
724 Err(ConfigError::MalformedList { .. })
725 ));
726 assert!(matches!(
727 parse("[x]\nk = \"alpha\"tail\n"),
728 Err(ConfigError::MalformedValue { .. })
729 ));
730 }
731
732 #[test]
733 fn explicitly_quoted_empty_list_elements_are_preserved() {
734 let root = parse("[x]\nk = \"\", alpha\n").unwrap();
735 assert_eq!(
736 root.section("x").unwrap().get("k").unwrap().as_list(),
737 std::vec!["", "alpha"]
738 );
739 }
740
741 #[test]
742 fn a_section_depth_jump_is_an_error() {
743 let result = parse("[a]\n[[[c]]]\n");
744 assert!(matches!(
745 result,
746 Err(ConfigError::SectionDepthJump {
747 found: 3,
748 parent: 1,
749 ..
750 })
751 ));
752 }
753
754 #[test]
755 fn mismatched_section_brackets_are_an_error() {
756 assert!(matches!(
757 parse("[[foo]\n"),
758 Err(ConfigError::UnmatchedSectionBrackets { .. })
759 ));
760 }
761
762 #[test]
763 fn duplicate_keys_are_rejected() {
764 assert!(matches!(
765 parse("[x]\nkey = 1\nkey = 2\n"),
766 Err(ConfigError::DuplicateKey { .. })
767 ));
768 }
769
770 #[test]
771 fn a_multi_line_triple_quoted_value_is_joined() {
772 let root = parse("[x]\nbanner = '''line one\nline two'''\n").unwrap();
773 assert_eq!(
774 scalar(root.section("x").unwrap(), "banner"),
775 "line one\nline two"
776 );
777 }
778
779 #[test]
780 fn located_parse_tracks_full_section_and_key_paths() {
781 let parsed = parse_located(
782 "[reticulum]\nenable_transport = Yes\n[interfaces]\n[[Hub]]\ntype = TCPClientInterface\n",
783 )
784 .unwrap();
785 assert_eq!(parsed.locations.line(["reticulum"]), Some(1));
786 assert_eq!(
787 parsed.locations.line(["reticulum", "enable_transport"]),
788 Some(2)
789 );
790 assert_eq!(parsed.locations.line(["interfaces", "Hub"]), Some(4));
791 assert_eq!(
792 parsed.locations.line(["interfaces", "Hub", "type"]),
793 Some(5)
794 );
795 }
796}