Skip to main content

dotenvpp_parser/
parser.rs

1//! Core `.env` parser for DotenvPP.
2//!
3//! Parses `.env` content into a list of key-value pairs, handling:
4//! - `KEY=VALUE` basic assignment
5//! - `# comments` (full-line and inline for unquoted values)
6//! - Blank line skipping
7//! - Single-quoted values, including multiline content
8//! - Double-quoted values (escape sequences + multiline support)
9//! - Unquoted values (trim trailing whitespace, strip inline comments,
10//!   and decode common backslash escapes)
11//! - `export KEY=VALUE` prefix stripping
12//! - Escape sequences in double quotes: `\\`, `\"`, `\n`, `\t`, `\r`
13
14use alloc::string::String;
15use alloc::vec::Vec;
16
17use crate::error::ParseError;
18
19/// A parsed key-value pair from a `.env` file.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct EnvPair {
22    /// The environment variable key.
23    pub key: String,
24    /// The environment variable value.
25    pub value: String,
26    /// The 1-based line number where this pair was found.
27    pub line: usize,
28}
29
30/// Parse `.env` file content into a list of [`EnvPair`]s.
31///
32/// # Errors
33///
34/// Returns [`ParseError`] if the input contains syntax errors such as
35/// missing `=` separators, empty keys, invalid key names, or unterminated
36/// quoted values.
37///
38/// # Examples
39///
40/// ```
41/// use dotenvpp_parser::parse;
42///
43/// let input = "KEY=value\nNAME=\"hello world\"\n";
44/// let pairs = parse(input).unwrap();
45/// assert_eq!(pairs.len(), 2);
46/// assert_eq!(pairs[0].key, "KEY");
47/// assert_eq!(pairs[0].value, "value");
48/// assert_eq!(pairs[1].key, "NAME");
49/// assert_eq!(pairs[1].value, "hello world");
50/// ```
51pub fn parse(input: &str) -> Result<Vec<EnvPair>, ParseError> {
52    let mut pairs = Vec::new();
53    let input = input.strip_prefix('\u{feff}').unwrap_or(input);
54    let mut lines = input.lines().enumerate().peekable();
55
56    while let Some((line_idx, raw_line)) = lines.next() {
57        let line_num = line_idx + 1;
58        let trimmed = raw_line.trim();
59
60        // Skip blank lines and comments.
61        if trimmed.is_empty() || trimmed.starts_with('#') {
62            continue;
63        }
64
65        // Strip optional `export` prefix.
66        let effective = strip_export_prefix(trimmed);
67
68        // Find the `=` separator.
69        let eq_pos = match effective.find('=') {
70            Some(pos) => pos,
71            None => {
72                return Err(ParseError::MissingSeparator {
73                    line: line_num,
74                    content: String::from(trimmed),
75                });
76            }
77        };
78
79        let raw_key = &effective[..eq_pos];
80        let key = raw_key.trim();
81
82        if key.is_empty() {
83            return Err(ParseError::EmptyKey {
84                line: line_num,
85            });
86        }
87
88        if !is_valid_key(key) {
89            return Err(ParseError::InvalidKey {
90                line: line_num,
91                key: String::from(key),
92            });
93        }
94
95        let after_eq = &effective[eq_pos + 1..];
96        let value = parse_value(after_eq, line_num, &mut lines)?;
97
98        pairs.push(EnvPair {
99            key: String::from(key),
100            value,
101            line: line_num,
102        });
103    }
104
105    Ok(pairs)
106}
107
108/// Strip the `export ` prefix from a line, if present.
109fn strip_export_prefix(line: &str) -> &str {
110    if let Some(rest) = line.strip_prefix("export ") {
111        rest.trim_start()
112    } else if let Some(rest) = line.strip_prefix("export\t") {
113        rest.trim_start()
114    } else {
115        line
116    }
117}
118
119/// Check if a key is valid: must be ASCII alphanumeric, underscores,
120/// or dots, and must not start with a digit.
121fn is_valid_key(key: &str) -> bool {
122    if key.is_empty() {
123        return false;
124    }
125
126    let first = key.as_bytes()[0];
127    if !first.is_ascii_alphabetic() && first != b'_' {
128        return false;
129    }
130
131    key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.')
132}
133
134/// Parse the value portion of a `KEY=VALUE` line.
135///
136/// Handles single-quoted, double-quoted, and unquoted values,
137/// including multiline double-quoted values across multiple lines.
138fn parse_value<'a, I>(
139    value_start: &str,
140    line_num: usize,
141    lines: &mut core::iter::Peekable<I>,
142) -> Result<String, ParseError>
143where
144    I: Iterator<Item = (usize, &'a str)>,
145{
146    let trimmed_start = value_start.trim_start_matches([' ', '\t']);
147    if trimmed_start.is_empty() {
148        return Ok(String::new());
149    }
150
151    if trimmed_start.starts_with('#') && trimmed_start.len() != value_start.len() {
152        return Ok(String::new());
153    }
154
155    let first_char = trimmed_start.as_bytes()[0];
156
157    match first_char {
158        b'\'' => parse_single_quoted(trimmed_start, line_num, lines),
159        b'"' => parse_double_quoted(trimmed_start, line_num, lines),
160        _ => Ok(parse_unquoted(trimmed_start)),
161    }
162}
163
164/// Parse a single-quoted value. No escape processing.
165/// Content is literal between the opening and closing `'`, including
166/// multiline content.
167fn parse_single_quoted<'a, I>(
168    value_start: &str,
169    line_num: usize,
170    lines: &mut core::iter::Peekable<I>,
171) -> Result<String, ParseError>
172where
173    I: Iterator<Item = (usize, &'a str)>,
174{
175    let mut result = String::new();
176    let mut remaining = &value_start[1..];
177
178    loop {
179        match remaining.find('\'') {
180            Some(close_pos) => {
181                result.push_str(&remaining[..close_pos]);
182                let tail = &remaining[close_pos + 1..];
183                if !tail.is_empty() {
184                    result.push_str(&parse_unquoted(tail));
185                }
186                return Ok(result);
187            }
188            None => {
189                result.push_str(remaining);
190
191                if let Some((_, next_line)) = lines.next() {
192                    result.push('\n');
193                    remaining = next_line;
194                } else {
195                    return Err(ParseError::UnterminatedQuote {
196                        line: line_num,
197                        quote: '\'',
198                    });
199                }
200            }
201        }
202    }
203}
204
205/// Parse a double-quoted value with escape sequence processing.
206/// Supports multiline values that span across multiple input lines.
207fn parse_double_quoted<'a, I>(
208    value_start: &str,
209    line_num: usize,
210    lines: &mut core::iter::Peekable<I>,
211) -> Result<String, ParseError>
212where
213    I: Iterator<Item = (usize, &'a str)>,
214{
215    let mut result = String::new();
216    // Skip the opening quote.
217    let mut remaining = &value_start[1..];
218
219    loop {
220        let mut chars = remaining.char_indices();
221
222        while let Some((idx, ch)) = chars.next() {
223            match ch {
224                '"' => {
225                    // Found the closing quote.
226                    let tail = &remaining[idx + ch.len_utf8()..];
227                    if !tail.is_empty() {
228                        result.push_str(&parse_unquoted(tail));
229                    }
230                    return Ok(result);
231                }
232                '\\' => {
233                    // Process escape sequence.
234                    if let Some((_, escaped)) = chars.next() {
235                        push_escaped_char(&mut result, escaped);
236                    } else {
237                        // Backslash at end of line inside double quotes is
238                        // preserved; the next input line is appended as a
239                        // literal newline.
240                        result.push('\\');
241                    }
242                }
243                _ => {
244                    result.push(ch);
245                }
246            }
247        }
248
249        // We reached the end of this line without finding a closing
250        // quote. This is a multiline value - continue to the next line.
251        if let Some((_, next_line)) = lines.next() {
252            result.push('\n');
253            remaining = next_line;
254        } else {
255            // No more lines - unterminated quote.
256            return Err(ParseError::UnterminatedQuote {
257                line: line_num,
258                quote: '"',
259            });
260        }
261    }
262}
263
264/// Parse an unquoted value.
265/// Strips inline comments (` #` or `\t#`), trims trailing whitespace,
266/// and decodes common backslash escapes.
267fn parse_unquoted(value_start: &str) -> String {
268    // Find inline comment - must be preceded by whitespace.
269    let value = if let Some(pos) = find_inline_comment(value_start) {
270        &value_start[..pos]
271    } else {
272        value_start
273    };
274
275    decode_escapes(value.trim_end())
276}
277
278/// Find the position of an inline comment (`#` preceded by whitespace).
279fn find_inline_comment(s: &str) -> Option<usize> {
280    let bytes = s.as_bytes();
281
282    for i in 1..bytes.len() {
283        if bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
284            return Some(i - 1);
285        }
286    }
287
288    None
289}
290
291/// Decode escape sequences used in unquoted and double-quoted values.
292fn decode_escapes(input: &str) -> String {
293    let mut result = String::new();
294    let mut chars = input.chars();
295
296    while let Some(ch) = chars.next() {
297        if ch == '\\' {
298            if let Some(escaped) = chars.next() {
299                match escaped {
300                    'n' => result.push('\n'),
301                    '\\' => result.push('\\'),
302                    '"' => result.push('"'),
303                    '\'' => result.push('\''),
304                    '$' => result.push('$'),
305                    ' ' => result.push(' '),
306                    '#' => result.push('#'),
307                    _ => {
308                        result.push('\\');
309                        result.push(escaped);
310                    }
311                }
312            } else {
313                result.push('\\');
314            }
315        } else {
316            result.push(ch);
317        }
318    }
319
320    result
321}
322
323/// Push a supported escape sequence into `result`.
324///
325/// Unknown escapes are preserved verbatim so the parser stays
326/// permissive for common dotenv variants.
327fn push_escaped_char(result: &mut String, escaped: char) {
328    match escaped {
329        'n' => result.push('\n'),
330        't' => result.push('\t'),
331        'r' => result.push('\r'),
332        '\\' => result.push('\\'),
333        '"' => result.push('"'),
334        '\'' => result.push('\''),
335        '$' => result.push('$'),
336        ' ' => result.push(' '),
337        '#' => result.push('#'),
338        _ => {
339            result.push('\\');
340            result.push(escaped);
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests;