dotenvpp_parser/
parser.rs1use alloc::string::String;
15use alloc::vec::Vec;
16
17use crate::error::ParseError;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct EnvPair {
22 pub key: String,
24 pub value: String,
26 pub line: usize,
28}
29
30pub 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 if trimmed.is_empty() || trimmed.starts_with('#') {
62 continue;
63 }
64
65 let effective = strip_export_prefix(trimmed);
67
68 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
108fn 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
119fn 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
134fn 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
164fn 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
205fn 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 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 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 if let Some((_, escaped)) = chars.next() {
235 push_escaped_char(&mut result, escaped);
236 } else {
237 result.push('\\');
241 }
242 }
243 _ => {
244 result.push(ch);
245 }
246 }
247 }
248
249 if let Some((_, next_line)) = lines.next() {
252 result.push('\n');
253 remaining = next_line;
254 } else {
255 return Err(ParseError::UnterminatedQuote {
257 line: line_num,
258 quote: '"',
259 });
260 }
261 }
262}
263
264fn parse_unquoted(value_start: &str) -> String {
268 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
278fn 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
291fn 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
323fn 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;