1const COMMENT_PREFIX: char = '#';
2const KEY_VALUE_SEPARATOR: char = '=';
3const EXPORT_KEYWORD: &str = "export";
4const QUOTE_CHARS: [char; 2] = ['"', '\''];
5const NUL: char = '\0';
7const FIRST_LINE_NUMBER: usize = 1;
9const BYTE_ORDER_MARK: char = '\u{feff}';
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Entry<'a> {
15 pub key: &'a str,
17 pub value: &'a str,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Parsed<'a> {
24 pub entries: Vec<Entry<'a>>,
26 pub skipped: Vec<usize>,
28}
29
30pub 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
45enum LineKind<'a> {
47 Ignored,
49 Assignment(Entry<'a>),
50 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
72fn 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
80fn 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
88fn 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}