Skip to main content

dotenv_verbatim/
parse.rs

1const COMMENT_PREFIX: char = '#';
2const KEY_VALUE_SEPARATOR: char = '=';
3const EXPORT_PREFIX: &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
10/// One parsed key/value pair, borrowed from the parsed content.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Entry<'a> {
13    /// The name left of the first `=`, trimmed and never empty.
14    pub key: &'a str,
15    /// Everything right of the first `=`, trimmed, with at most one pair of quotes removed.
16    pub value: &'a str,
17}
18
19/// The result of a parse: the entries in file order plus the line numbers that were skipped.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Parsed<'a> {
22    /// The pairs that parsed, in file order.
23    pub entries: Vec<Entry<'a>>,
24    /// Line numbers, counted from 1, that were neither a pair nor a comment or blank.
25    pub skipped: Vec<usize>,
26}
27
28/// Parse `.env` content. Pure: touches neither the environment nor the filesystem.
29pub fn parse(content: &str) -> Parsed<'_> {
30    let mut entries = Vec::new();
31    let mut skipped = Vec::new();
32    for (index, line) in content.lines().enumerate() {
33        match classify_line(line) {
34            LineKind::Ignored => {}
35            LineKind::Assignment(entry) => entries.push(entry),
36            LineKind::Unusable => skipped.push(index + FIRST_LINE_NUMBER),
37        }
38    }
39    Parsed { entries, skipped }
40}
41
42/// What one line of the file turned out to be.
43enum LineKind<'a> {
44    /// Blank line or whole-line comment: expected, not reported.
45    Ignored,
46    Assignment(Entry<'a>),
47    /// Neither, so the line is reported by number and parsing continues.
48    Unusable,
49}
50
51fn classify_line(line: &str) -> LineKind<'_> {
52    let trimmed = line.trim();
53    if trimmed.is_empty() || trimmed.starts_with(COMMENT_PREFIX) {
54        return LineKind::Ignored;
55    }
56    let assignment = match trimmed.strip_prefix(EXPORT_PREFIX) {
57        Some(rest) => rest,
58        None => trimmed,
59    };
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/// Strip one pair of matching surrounding quotes (double or single), if any.
73fn strip_surrounding_quotes(value: &str) -> &str {
74    for quote in QUOTE_CHARS {
75        if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
76            return &value[1..value.len() - 1];
77        }
78    }
79    value
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn entry<'a>(key: &'a str, value: &'a str) -> Entry<'a> {
87        Entry { key, value }
88    }
89
90    #[test]
91    fn keeps_unquoted_spaces_in_a_value() {
92        let parsed = parse("BASE_ADDRESSES=http://a http://b application");
93        assert_eq!(
94            parsed.entries,
95            vec![entry("BASE_ADDRESSES", "http://a http://b application")]
96        );
97        assert!(parsed.skipped.is_empty());
98    }
99
100    #[test]
101    fn a_malformed_line_does_not_stop_the_rest_of_the_file() {
102        let parsed = parse("A=1\nnosep\nB=2\n");
103        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
104        assert_eq!(parsed.skipped, vec![2]);
105    }
106
107    #[test]
108    fn does_not_expand_dollar_signs() {
109        let parsed = parse("PLAIN=abc\nSECRET=p$word${PLAIN}x");
110        assert_eq!(
111            parsed.entries,
112            vec![entry("PLAIN", "abc"), entry("SECRET", "p$word${PLAIN}x")]
113        );
114    }
115
116    #[test]
117    fn does_not_treat_a_hash_inside_a_value_as_a_comment() {
118        assert_eq!(parse("HASH=a#b").entries, vec![entry("HASH", "a#b")]);
119    }
120
121    #[test]
122    fn strips_quotes_and_export_prefix() {
123        assert_eq!(
124            parse("export NAME=\"quoted value\"").entries,
125            vec![entry("NAME", "quoted value")]
126        );
127        assert_eq!(parse("S='single'").entries, vec![entry("S", "single")]);
128    }
129
130    #[test]
131    fn keeps_everything_after_the_first_separator() {
132        assert_eq!(parse("KEY=a=b=c").entries, vec![entry("KEY", "a=b=c")]);
133    }
134
135    #[test]
136    fn skips_comments_blanks_and_malformed_lines() {
137        let parsed = parse("# comment\n   \nno separator\n=value-without-key\n");
138        assert!(parsed.entries.is_empty());
139        assert_eq!(parsed.skipped, vec![3, 4]);
140    }
141
142    #[test]
143    fn reads_crlf_line_endings() {
144        let parsed = parse("A=1\r\nB=2\r\n");
145        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
146        assert!(parsed.skipped.is_empty());
147    }
148
149    #[test]
150    fn skips_a_nul_in_the_key_or_the_value() {
151        let parsed = parse("K\0EY=1\nVAL=a\0b\nOK=2\n");
152        assert_eq!(parsed.entries, vec![entry("OK", "2")]);
153        assert_eq!(parsed.skipped, vec![1, 2]);
154    }
155}