use alloc::string::String;
use alloc::vec::Vec;
use crate::error::ParseError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvPair {
pub key: String,
pub value: String,
pub line: usize,
}
pub fn parse(input: &str) -> Result<Vec<EnvPair>, ParseError> {
let mut pairs = Vec::new();
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
let mut lines = input.lines().enumerate().peekable();
while let Some((line_idx, raw_line)) = lines.next() {
let line_num = line_idx + 1;
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let effective = strip_export_prefix(trimmed);
let eq_pos = match effective.find('=') {
Some(pos) => pos,
None => {
return Err(ParseError::MissingSeparator {
line: line_num,
content: String::from(trimmed),
});
}
};
let raw_key = &effective[..eq_pos];
let key = raw_key.trim();
if key.is_empty() {
return Err(ParseError::EmptyKey {
line: line_num,
});
}
if !is_valid_key(key) {
return Err(ParseError::InvalidKey {
line: line_num,
key: String::from(key),
});
}
let after_eq = &effective[eq_pos + 1..];
let value = parse_value(after_eq, line_num, &mut lines)?;
pairs.push(EnvPair {
key: String::from(key),
value,
line: line_num,
});
}
Ok(pairs)
}
fn strip_export_prefix(line: &str) -> &str {
if let Some(rest) = line.strip_prefix("export ") {
rest.trim_start()
} else if let Some(rest) = line.strip_prefix("export\t") {
rest.trim_start()
} else {
line
}
}
fn is_valid_key(key: &str) -> bool {
if key.is_empty() {
return false;
}
let first = key.as_bytes()[0];
if !first.is_ascii_alphabetic() && first != b'_' {
return false;
}
key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.')
}
fn parse_value<'a, I>(
value_start: &str,
line_num: usize,
lines: &mut core::iter::Peekable<I>,
) -> Result<String, ParseError>
where
I: Iterator<Item = (usize, &'a str)>,
{
let trimmed_start = value_start.trim_start_matches([' ', '\t']);
if trimmed_start.is_empty() {
return Ok(String::new());
}
if trimmed_start.starts_with('#') && trimmed_start.len() != value_start.len() {
return Ok(String::new());
}
let first_char = trimmed_start.as_bytes()[0];
match first_char {
b'\'' => parse_single_quoted(trimmed_start, line_num, lines),
b'"' => parse_double_quoted(trimmed_start, line_num, lines),
_ => Ok(parse_unquoted(trimmed_start)),
}
}
fn parse_single_quoted<'a, I>(
value_start: &str,
line_num: usize,
lines: &mut core::iter::Peekable<I>,
) -> Result<String, ParseError>
where
I: Iterator<Item = (usize, &'a str)>,
{
let mut result = String::new();
let mut remaining = &value_start[1..];
loop {
match remaining.find('\'') {
Some(close_pos) => {
result.push_str(&remaining[..close_pos]);
let tail = &remaining[close_pos + 1..];
if tail.starts_with("\\'") && tail[2..].starts_with('\'') {
result.push('\'');
remaining = &tail[3..]; continue;
}
if !tail.is_empty() {
result.push_str(&parse_unquoted(tail));
}
return Ok(result);
}
None => {
result.push_str(remaining);
if let Some((_, next_line)) = lines.next() {
result.push('\n');
remaining = next_line;
} else {
return Err(ParseError::UnterminatedQuote {
line: line_num,
quote: '\'',
});
}
}
}
}
}
fn parse_double_quoted<'a, I>(
value_start: &str,
line_num: usize,
lines: &mut core::iter::Peekable<I>,
) -> Result<String, ParseError>
where
I: Iterator<Item = (usize, &'a str)>,
{
let mut result = String::new();
let mut remaining = &value_start[1..];
loop {
let mut chars = remaining.char_indices();
while let Some((idx, ch)) = chars.next() {
match ch {
'"' => {
let tail = &remaining[idx + ch.len_utf8()..];
if !tail.is_empty() {
result.push_str(&parse_unquoted(tail));
}
return Ok(result);
}
'\\' => {
if let Some((_, escaped)) = chars.next() {
push_escaped_char(&mut result, escaped);
} else {
result.push('\\');
}
}
_ => {
result.push(ch);
}
}
}
if let Some((_, next_line)) = lines.next() {
result.push('\n');
remaining = next_line;
} else {
return Err(ParseError::UnterminatedQuote {
line: line_num,
quote: '"',
});
}
}
}
fn parse_unquoted(value_start: &str) -> String {
let value = if let Some(pos) = find_inline_comment(value_start) {
&value_start[..pos]
} else {
value_start
};
decode_escapes(value.trim_end())
}
fn find_inline_comment(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
for i in 1..bytes.len() {
if bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
return Some(i - 1);
}
}
None
}
fn decode_escapes(input: &str) -> String {
let mut result = String::new();
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(escaped) = chars.next() {
match escaped {
'n' => result.push('\n'),
'\\' => result.push('\\'),
'"' => result.push('"'),
'\'' => result.push('\''),
'$' => result.push('$'),
' ' => result.push(' '),
'#' => result.push('#'),
_ => {
result.push('\\');
result.push(escaped);
}
}
} else {
result.push('\\');
}
} else {
result.push(ch);
}
}
result
}
fn push_escaped_char(result: &mut String, escaped: char) {
match escaped {
'n' => result.push('\n'),
't' => result.push('\t'),
'r' => result.push('\r'),
'\\' => result.push('\\'),
'"' => result.push('"'),
'\'' => result.push('\''),
'$' => result.push('$'),
' ' => result.push(' '),
'#' => result.push('#'),
_ => {
result.push('\\');
result.push(escaped);
}
}
}
#[cfg(test)]
#[path = "../tests/parser/mod.rs"]
mod tests;