Skip to main content

prns_config/
configobj.rs

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(&section.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(&section.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}