alpm_parsers/custom_ini/
parser.rs

1//! A custom parser for INI-style file formats.
2
3use std::collections::BTreeMap;
4
5use serde::Deserialize;
6use winnow::{
7    ModalResult,
8    Parser,
9    ascii::{newline, space0, till_line_ending},
10    combinator::{
11        alt,
12        cut_err,
13        eof,
14        opt,
15        preceded,
16        repeat,
17        repeat_till,
18        separated_pair,
19        terminated,
20    },
21    error::{StrContext, StrContextValue},
22    token::none_of,
23};
24
25use super::de::Error;
26
27const INVALID_KEY_NAME_SYMBOLS: [char; 3] = ['=', ' ', '\n'];
28
29/// Representation of parsed items.
30#[derive(Clone, Debug, Deserialize, PartialEq)]
31#[serde(untagged)]
32pub enum Item {
33    /// A single value.
34    Value(String),
35    /// A list of values.
36    List(Vec<String>),
37}
38
39impl Item {
40    /// Returns a string slice representing a single value.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the [`Item`] represents a list of values.
45    pub fn value_or_error(&self) -> Result<&str, Error> {
46        match self {
47            Item::Value(value) => Ok(value),
48            Item::List(_) => Err(Error::InvalidState),
49        }
50    }
51}
52
53/// Representation of a parsed line.
54#[derive(Debug)]
55enum ParsedLine<'s> {
56    /// A key value pair.
57    KeyValue { key: &'s str, value: &'s str },
58    /// A comment.
59    Comment(&'s str),
60}
61
62/// Take all chars, until we hit a char that isn't allowed in a key.
63fn key(input: &mut &str) -> ModalResult<()> {
64    repeat(1.., none_of(INVALID_KEY_NAME_SYMBOLS)).parse_next(input)
65}
66
67/// Parse a single key value pair.
68/// The delimiter includes two surrounding spaces, i.e. ` = `.
69///
70/// ## Examples
71///
72/// ```ini
73/// key = value
74/// ```
75fn key_value<'s>(input: &mut &'s str) -> ModalResult<(&'s str, &'s str)> {
76    separated_pair(
77        cut_err(key.take())
78            .context(StrContext::Label("key"))
79            .context(StrContext::Expected(StrContextValue::Description(
80                "a key followed by a ` = ` delimiter.",
81            ))),
82        cut_err((" ", "=", " "))
83            .context(StrContext::Label("delimiter"))
84            .context(StrContext::Expected(StrContextValue::Description(
85                "a '=' that delimits the key value pair, surrounded by a single space.",
86            ))),
87        till_line_ending,
88    )
89    .parse_next(input)
90}
91
92/// One or multiple newlines.
93/// This also handles the case where there might be multiple lines with spaces.
94fn newlines(input: &mut &str) -> ModalResult<()> {
95    repeat(0.., (newline, space0)).parse_next(input)
96}
97
98/// Parse a comment (a line starting with `#`).
99fn comment<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
100    preceded('#', till_line_ending).parse_next(input)
101}
102
103/// Parse a single line consisting of a key value pair or a comment, followed by 0 or more newlines.
104fn line<'s>(input: &mut &'s str) -> ModalResult<ParsedLine<'s>> {
105    alt((
106        terminated(comment, opt(newlines)).map(ParsedLine::Comment),
107        terminated(key_value, opt(newlines))
108            .map(|(key, value)| ParsedLine::KeyValue { key, value }),
109    ))
110    .parse_next(input)
111}
112
113/// Parse multiple lines.
114fn lines<'s>(input: &mut &'s str) -> ModalResult<Vec<ParsedLine<'s>>> {
115    let (value, _terminator) = repeat_till(0.., line, eof).parse_next(input)?;
116
117    Ok(value)
118}
119
120/// Parse the content of a whole ini file.
121pub fn ini_file(input: &mut &str) -> ModalResult<BTreeMap<String, Item>> {
122    let mut items: BTreeMap<String, Vec<String>> = BTreeMap::new();
123
124    // Ignore any preceding newlines at the start of the file.
125    let parsed_lines = preceded(newlines, lines).parse_next(input)?;
126    for parsed_line in parsed_lines {
127        match parsed_line {
128            ParsedLine::KeyValue { key, value } => {
129                let values = items.entry(key.to_string()).or_default();
130                values.push(value.to_string());
131            }
132            ParsedLine::Comment(_v) => {}
133        }
134    }
135
136    // Collapse the list of all items into their final representation.
137    //
138    // Keys that only occur a single time are interpreted as a single item.
139    // Keys that occur multiple times are interpreted as a list.
140    Ok(items
141        .into_iter()
142        .map(|(key, mut values)| {
143            if values.len() == 1 {
144                (key, Item::Value(values.remove(0)))
145            } else {
146                (key, Item::List(values))
147            }
148        })
149        .collect())
150}
151
152#[cfg(test)]
153mod test {
154    use testresult::TestResult;
155
156    use super::*;
157
158    static TEST_NEWLINES_INPUT: &str = "
159
160foo = bar
161
162test = nice
163
164";
165
166    /// Make sure that newlines at any place are just ignored.
167    #[test]
168    fn test_newlines() -> TestResult<()> {
169        let results = ini_file(&mut TEST_NEWLINES_INPUT.to_string().as_str())?;
170
171        let mut expected = BTreeMap::new();
172        expected.insert("foo".to_string(), Item::Value("bar".to_string()));
173        expected.insert("test".to_string(), Item::Value("nice".to_string()));
174
175        assert_eq!(expected, results);
176
177        Ok(())
178    }
179
180    static TEST_LISTS_INPUT: &str = "foo = bar
181
182test = very
183test = nice
184test = indeed";
185
186    /// Ensure that parsing lists works.
187    #[test]
188    fn test_lists() -> TestResult<()> {
189        let results = ini_file(&mut TEST_LISTS_INPUT.to_string().as_str())?;
190
191        let mut expected = BTreeMap::new();
192        expected.insert("foo".to_string(), Item::Value("bar".to_string()));
193        expected.insert(
194            "test".to_string(),
195            Item::List(vec![
196                "very".to_string(),
197                "nice".to_string(),
198                "indeed".to_string(),
199            ]),
200        );
201
202        assert_eq!(expected, results);
203
204        Ok(())
205    }
206
207    static TEST_COMMENT_INPUT: &str = "
208# Hey
209# This is a comment
210foo = bar
211# This is another comment
212bar = baz
213# And another one";
214
215    /// Ensure that comments are ignored.
216    #[test]
217    fn test_comments() -> TestResult<()> {
218        let results = ini_file(&mut TEST_COMMENT_INPUT.to_string().as_str())?;
219
220        let mut expected = BTreeMap::new();
221        expected.insert("foo".to_string(), Item::Value("bar".to_string()));
222        expected.insert("bar".to_string(), Item::Value("baz".to_string()));
223
224        assert_eq!(expected, results);
225
226        Ok(())
227    }
228}