Skip to main content

dotenv_verbatim/
parse.rs

1const COMMENT_PREFIX: char = '#';
2const KEY_VALUE_SEPARATOR: char = '=';
3const EXPORT_KEYWORD: &str = "export";
4const QUOTE_CHARS: [char; 2] = ['"', '\''];
5/// `env::set_var` panics on a NUL in the key or the value, so such a line is unusable.
6const NUL: char = '\0';
7/// Line numbers are reported the way an editor shows them.
8const FIRST_LINE_NUMBER: usize = 1;
9/// A UTF-8 BOM is an encoding artefact of the file, not part of the first key.
10const BYTE_ORDER_MARK: char = '\u{feff}';
11
12/// One parsed key/value pair, borrowed from the parsed content.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Entry<'a> {
15    /// The name left of the first `=`, trimmed and never empty.
16    pub key: &'a str,
17    /// Everything right of the first `=`, trimmed, with at most one pair of quotes removed.
18    pub value: &'a str,
19}
20
21/// The result of a parse: the entries in file order plus the line numbers that were skipped.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Parsed<'a> {
24    /// The pairs that parsed, in file order.
25    pub entries: Vec<Entry<'a>>,
26    /// Line numbers, counted from 1, that were neither a pair nor a comment or blank.
27    pub skipped: Vec<usize>,
28}
29
30/// Parse `.env` content. Pure: touches neither the environment nor the filesystem.
31pub fn parse(content: &str) -> Parsed<'_> {
32    let content = strip_byte_order_mark(content);
33    let mut entries = Vec::new();
34    let mut skipped = Vec::new();
35    for (index, line) in content.lines().enumerate() {
36        match classify_line(line) {
37            LineKind::Ignored => {}
38            LineKind::Assignment(entry) => entries.push(entry),
39            LineKind::Unusable => skipped.push(index + FIRST_LINE_NUMBER),
40        }
41    }
42    Parsed { entries, skipped }
43}
44
45/// What one line of the file turned out to be.
46enum LineKind<'a> {
47    /// Blank line or whole-line comment: expected, not reported.
48    Ignored,
49    Assignment(Entry<'a>),
50    /// Neither, so the line is reported by number and parsing continues.
51    Unusable,
52}
53
54fn classify_line(line: &str) -> LineKind<'_> {
55    let trimmed = line.trim();
56    if trimmed.is_empty() || trimmed.starts_with(COMMENT_PREFIX) {
57        return LineKind::Ignored;
58    }
59    let assignment = strip_export_prefix(trimmed);
60    let (key, value) = match assignment.split_once(KEY_VALUE_SEPARATOR) {
61        Some(pair) => pair,
62        None => return LineKind::Unusable,
63    };
64    let key = key.trim();
65    let value = strip_surrounding_quotes(value.trim());
66    if key.is_empty() || key.contains(NUL) || value.contains(NUL) {
67        return LineKind::Unusable;
68    }
69    LineKind::Assignment(Entry { key, value })
70}
71
72/// Drop a leading UTF-8 BOM so it does not become part of the first key.
73fn strip_byte_order_mark(content: &str) -> &str {
74    match content.strip_prefix(BYTE_ORDER_MARK) {
75        Some(rest) => rest,
76        None => content,
77    }
78}
79
80/// Strip the optional `export` prefix; whitespace must follow, so `exported=1` keeps its key.
81fn strip_export_prefix(line: &str) -> &str {
82    match line.strip_prefix(EXPORT_KEYWORD) {
83        Some(rest) if rest.starts_with(char::is_whitespace) => rest,
84        _ => line,
85    }
86}
87
88/// Strip one pair of matching surrounding quotes (double or single), if any.
89fn strip_surrounding_quotes(value: &str) -> &str {
90    for quote in QUOTE_CHARS {
91        if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
92            return &value[1..value.len() - 1];
93        }
94    }
95    value
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn entry<'a>(key: &'a str, value: &'a str) -> Entry<'a> {
103        Entry { key, value }
104    }
105
106    #[test]
107    fn keeps_unquoted_spaces_in_a_value() {
108        let parsed = parse("BASE_ADDRESSES=http://a http://b application");
109        assert_eq!(
110            parsed.entries,
111            vec![entry("BASE_ADDRESSES", "http://a http://b application")]
112        );
113        assert!(parsed.skipped.is_empty());
114    }
115
116    #[test]
117    fn a_malformed_line_does_not_stop_the_rest_of_the_file() {
118        let parsed = parse("A=1\nnosep\nB=2\n");
119        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
120        assert_eq!(parsed.skipped, vec![2]);
121    }
122
123    #[test]
124    fn does_not_expand_dollar_signs() {
125        let parsed = parse("PLAIN=abc\nSECRET=p$word${PLAIN}x");
126        assert_eq!(
127            parsed.entries,
128            vec![entry("PLAIN", "abc"), entry("SECRET", "p$word${PLAIN}x")]
129        );
130    }
131
132    #[test]
133    fn does_not_treat_a_hash_inside_a_value_as_a_comment() {
134        assert_eq!(parse("HASH=a#b").entries, vec![entry("HASH", "a#b")]);
135    }
136
137    #[test]
138    fn strips_quotes_and_export_prefix() {
139        assert_eq!(
140            parse("export NAME=\"quoted value\"").entries,
141            vec![entry("NAME", "quoted value")]
142        );
143        assert_eq!(parse("S='single'").entries, vec![entry("S", "single")]);
144    }
145
146    #[test]
147    fn keeps_everything_after_the_first_separator() {
148        assert_eq!(parse("KEY=a=b=c").entries, vec![entry("KEY", "a=b=c")]);
149    }
150
151    #[test]
152    fn skips_comments_blanks_and_malformed_lines() {
153        let parsed = parse("# comment\n   \nno separator\n=value-without-key\n");
154        assert!(parsed.entries.is_empty());
155        assert_eq!(parsed.skipped, vec![3, 4]);
156    }
157
158    #[test]
159    fn reads_crlf_line_endings() {
160        let parsed = parse("A=1\r\nB=2\r\n");
161        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
162        assert!(parsed.skipped.is_empty());
163    }
164
165    #[test]
166    fn ignores_a_leading_byte_order_mark() {
167        let parsed = parse("\u{feff}A=1\nB=2\n");
168        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
169        assert!(parsed.skipped.is_empty());
170    }
171
172    #[test]
173    fn accepts_any_whitespace_after_export() {
174        let parsed = parse("export\tTAB=1\nexport  WIDE=2\nexported=3\nexport=4\n");
175        assert_eq!(
176            parsed.entries,
177            vec![
178                entry("TAB", "1"),
179                entry("WIDE", "2"),
180                entry("exported", "3"),
181                entry("export", "4"),
182            ]
183        );
184        assert!(parsed.skipped.is_empty());
185    }
186
187    #[test]
188    fn skips_a_nul_in_the_key_or_the_value() {
189        let parsed = parse("K\0EY=1\nVAL=a\0b\nOK=2\n");
190        assert_eq!(parsed.entries, vec![entry("OK", "2")]);
191        assert_eq!(parsed.skipped, vec![1, 2]);
192    }
193}