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>(
173 value_start: &str,
174 line_num: usize,
175 lines: &mut core::iter::Peekable<I>,
176) -> Result<String, ParseError>
177where
178 I: Iterator<Item = (usize, &'a str)>,
179{
180 let mut result = String::new();
181 let mut remaining = &value_start[1..];
182
183 loop {
184 match remaining.find('\'') {
185 Some(close_pos) => {
186 result.push_str(&remaining[..close_pos]);
187 let tail = &remaining[close_pos + 1..];
188
189 if tail.starts_with("\\'") && tail[2..].starts_with('\'') {
193 result.push('\'');
194 remaining = &tail[3..]; continue;
196 }
197
198 if !tail.is_empty() {
199 result.push_str(&parse_unquoted(tail));
200 }
201 return Ok(result);
202 }
203 None => {
204 result.push_str(remaining);
205
206 if let Some((_, next_line)) = lines.next() {
207 result.push('\n');
208 remaining = next_line;
209 } else {
210 return Err(ParseError::UnterminatedQuote {
211 line: line_num,
212 quote: '\'',
213 });
214 }
215 }
216 }
217 }
218}
219
220fn parse_double_quoted<'a, I>(
223 value_start: &str,
224 line_num: usize,
225 lines: &mut core::iter::Peekable<I>,
226) -> Result<String, ParseError>
227where
228 I: Iterator<Item = (usize, &'a str)>,
229{
230 let mut result = String::new();
231 let mut remaining = &value_start[1..];
233
234 loop {
235 let mut chars = remaining.char_indices();
236
237 while let Some((idx, ch)) = chars.next() {
238 match ch {
239 '"' => {
240 let tail = &remaining[idx + ch.len_utf8()..];
242 if !tail.is_empty() {
243 result.push_str(&parse_unquoted(tail));
244 }
245 return Ok(result);
246 }
247 '\\' => {
248 if let Some((_, escaped)) = chars.next() {
250 push_escaped_char(&mut result, escaped);
251 } else {
252 result.push('\\');
256 }
257 }
258 _ => {
259 result.push(ch);
260 }
261 }
262 }
263
264 if let Some((_, next_line)) = lines.next() {
267 result.push('\n');
268 remaining = next_line;
269 } else {
270 return Err(ParseError::UnterminatedQuote {
272 line: line_num,
273 quote: '"',
274 });
275 }
276 }
277}
278
279fn parse_unquoted(value_start: &str) -> String {
283 let value = if let Some(pos) = find_inline_comment(value_start) {
285 &value_start[..pos]
286 } else {
287 value_start
288 };
289
290 decode_escapes(value.trim_end())
291}
292
293fn find_inline_comment(s: &str) -> Option<usize> {
295 let bytes = s.as_bytes();
296
297 for i in 1..bytes.len() {
298 if bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
299 return Some(i - 1);
300 }
301 }
302
303 None
304}
305
306fn decode_escapes(input: &str) -> String {
308 let mut result = String::new();
309 let mut chars = input.chars();
310
311 while let Some(ch) = chars.next() {
312 if ch == '\\' {
313 if let Some(escaped) = chars.next() {
314 match escaped {
315 'n' => result.push('\n'),
316 '\\' => result.push('\\'),
317 '"' => result.push('"'),
318 '\'' => result.push('\''),
319 '$' => result.push('$'),
320 ' ' => result.push(' '),
321 '#' => result.push('#'),
322 _ => {
323 result.push('\\');
324 result.push(escaped);
325 }
326 }
327 } else {
328 result.push('\\');
329 }
330 } else {
331 result.push(ch);
332 }
333 }
334
335 result
336}
337
338fn push_escaped_char(result: &mut String, escaped: char) {
343 match escaped {
344 'n' => result.push('\n'),
345 't' => result.push('\t'),
346 'r' => result.push('\r'),
347 '\\' => result.push('\\'),
348 '"' => result.push('"'),
349 '\'' => result.push('\''),
350 '$' => result.push('$'),
351 ' ' => result.push(' '),
352 '#' => result.push('#'),
353 _ => {
354 result.push('\\');
355 result.push(escaped);
356 }
357 }
358}
359
360#[cfg(test)]
361#[path = "../tests/parser/mod.rs"]
362mod tests;