confget/backend/
ini_re.rs

1/*
2 * SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
3 * SPDX-License-Identifier: BSD-2-Clause
4 */
5//! Parse INI-style configuration files.
6
7use anyhow::anyhow;
8use regex::{Captures, Regex};
9
10use crate::backend::{Backend, DataRead};
11use crate::defs::{ConfgetError, Config, FileData, SectionData};
12
13/// A backend type for parsing INI-style configuration files.
14#[derive(Debug)]
15#[non_exhaustive]
16#[allow(clippy::module_name_repetitions)]
17pub struct IniREBackend<'cfg> {
18    /// Configuration settings, e.g. filename and section.
19    config: &'cfg Config,
20
21    /// The filename specified in the configuration.
22    filename: &'cfg str,
23}
24
25/// The current state of the INI-style file parser.
26#[derive(Debug)]
27struct State {
28    /// The regular expression to use to detect comments.
29    re_comment: Regex,
30    /// The regular expression to use to detect a section header.
31    re_section: Regex,
32    /// The regular expression to use to detect a variable definition.
33    re_variable: Regex,
34    /// The name of the input file to read.
35    filename: String,
36    /// The name of the first section in the file, if there was one at all.
37    first_section: Option<String>,
38    /// The name of the current section.
39    section: String,
40    /// If this is a continuation line, the name and value of the current variable.
41    cont: Option<(String, String)>,
42    /// Have we found any variables or sections at all already?
43    /// Used when determining whether the first section should be the default one
44    /// or there were any variables defined before that.
45    found: bool,
46}
47
48impl State {
49    /// Process the next line of input, return the updated parser state.
50    fn feed_line(self, line: &str, res: &mut FileData) -> Result<Self, ConfgetError> {
51        if let Some((name, value)) = self.cont {
52            if let Some(stripped) = line.strip_suffix('\\') {
53                Ok(Self {
54                    cont: Some((name, format!("{value}{stripped}"))),
55                    ..self
56                })
57            } else {
58                res.get_mut(&self.section)
59                    .ok_or_else(|| {
60                        ConfgetError::Internal(format!(
61                            "Internal error: no data for section {section}",
62                            section = self.section
63                        ))
64                    })?
65                    .insert(name, format!("{value}{trimmed}", trimmed = line.trim_end()));
66                Ok(Self { cont: None, ..self })
67            }
68        } else if self.re_comment.is_match(line) {
69            Ok(self)
70        } else {
71            /// Extract a regex capture group that we know must be there.
72            fn extr<'data>(
73                caps: &'data Captures<'_>,
74                name: &str,
75            ) -> Result<&'data str, ConfgetError> {
76                Ok(caps
77                    .name(name)
78                    .ok_or_else(|| {
79                        ConfgetError::Internal(format!("Internal error: no '{name}' in {caps:?}"))
80                    })?
81                    .as_str())
82            }
83
84            if let Some(caps) = self.re_section.captures(line) {
85                let name = extr(&caps, "name")?;
86                res.entry(name.to_owned()).or_insert_with(SectionData::new);
87                Ok(Self {
88                    first_section: if self.first_section.is_none() && !self.found {
89                        Some(name.to_owned())
90                    } else {
91                        self.first_section
92                    },
93                    section: name.to_owned(),
94                    found: true,
95                    ..self
96                })
97            } else {
98                let caps = if let Some(caps) = self.re_variable.captures(line) {
99                    caps
100                } else {
101                    return Err(ConfgetError::FileFormat(
102                        self.filename,
103                        anyhow!(format!(
104                            "Unexpected line: '{escaped}'",
105                            escaped = line.escape_debug()
106                        )),
107                    ));
108                };
109                let name = extr(&caps, "name")?;
110                let value = extr(&caps, "value")?;
111                let cont = caps.name("cont").is_some();
112                if cont {
113                    Ok(Self {
114                        cont: Some((
115                            name.to_owned(),
116                            format!("{value}{ws}", ws = extr(&caps, "ws")?),
117                        )),
118                        found: true,
119                        ..self
120                    })
121                } else {
122                    res.get_mut(&self.section)
123                        .ok_or_else(|| {
124                            ConfgetError::Internal(format!(
125                                "Internal error: no data for section {section}",
126                                section = self.section
127                            ))
128                        })?
129                        .insert(name.to_owned(), value.to_owned());
130                    Ok(Self {
131                        found: true,
132                        ..self
133                    })
134                }
135            }
136        }
137    }
138}
139
140/// The regular expression to use for matching comment lines.
141static RE_COMMENT: &str = r"(?x) ^ \s* (?: [\#;] .* )?  $ ";
142
143/// The regular expression to use for matching section headers.
144static RE_SECTION: &str = r"(?x)
145    ^ \s*
146    \[ \s*
147    (?P<name> [^\]]+? )
148    \s* \]
149    \s* $ ";
150
151/// The regular expression to use for matching var=value lines.
152static RE_VARIABLE: &str = r"(?x)
153    ^ \s*
154    (?P<name> [^\s=]+ )
155    \s* = \s*
156    (?P<value> .*? )
157    (?P<ws> \s* )
158    (?P<cont> [\\] )?
159    $ ";
160
161impl<'cfg> Backend<'cfg> for IniREBackend<'cfg> {
162    /// Initialize an INI-style backend object.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`ConfgetError`] if no filename is specified in the config.
167    #[inline]
168    fn from_config(config: &'cfg Config) -> Result<Self, ConfgetError> {
169        let filename: &str = config
170            .filename
171            .as_ref()
172            .ok_or_else(|| ConfgetError::Config("No filename supplied".to_owned()))?;
173        Ok(Self { config, filename })
174    }
175
176    /// Parse an INI-style file consisting of zero or more sections.
177    ///
178    /// # Errors
179    ///
180    /// Returns a [`ConfgetError`] error on
181    /// configuration errors or if the file's contents does not
182    /// follow the expected format.
183    /// Propagates errors returned by filesystem operations.
184    #[inline]
185    fn read_file(&self) -> Result<DataRead, ConfgetError> {
186        let mut res = FileData::new();
187        res.insert(String::new(), SectionData::new());
188
189        let init_state = State {
190            re_comment: Regex::new(RE_COMMENT).map_err(|err| {
191                ConfgetError::Internal(format!(
192                    "Could not compile the '{RE_COMMENT}' regular expression: {err}"
193                ))
194            })?,
195            re_section: Regex::new(RE_SECTION).map_err(|err| {
196                ConfgetError::Internal(format!(
197                    "Could not compile the '{RE_SECTION}' regular expression: {err}"
198                ))
199            })?,
200            re_variable: Regex::new(RE_VARIABLE).map_err(|err| {
201                ConfgetError::Internal(format!(
202                    "Could not compile the '{RE_VARIABLE}' regular expression: {err}"
203                ))
204            })?,
205            filename: self.filename.to_owned(),
206            first_section: self
207                .config
208                .section_specified
209                .then(|| self.config.section.clone()),
210            section: String::new(),
211            cont: None,
212            found: false,
213        };
214
215        let final_state = super::get_file_lines(self.filename, &self.config.encoding)?
216            .iter()
217            .try_fold(init_state, |state, line| state.feed_line(line, &mut res))?;
218        if final_state.cont.is_some() {
219            return Err(ConfgetError::FileFormat(
220                self.filename.to_owned(),
221                anyhow!("Line continuation on the last line"),
222            ));
223        }
224        Ok((
225            res,
226            final_state
227                .first_section
228                .unwrap_or_else(|| self.config.section.clone()),
229        ))
230    }
231}