use super::error::LexError;
use super::span::SourcePos;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvField {
pub value: String,
pub is_quoted: bool,
}
pub fn parse_csv_row(s: &str) -> Result<Vec<CsvField>, LexError> {
if s.is_empty() {
return Ok(Vec::new());
}
if s.trim_end().ends_with(',') {
return Err(LexError::InvalidToken {
message: "trailing comma not allowed in CSV row".to_string(),
pos: SourcePos::new(1, 1),
});
}
let estimated_fields = s.matches(',').count() + 1;
let mut fields = Vec::with_capacity(estimated_fields);
let mut i = 0;
let chars: Vec<char> = s.chars().collect();
while i < chars.len() {
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
if i >= chars.len() {
break;
}
let (field, new_i) = parse_field(&chars, i)?;
fields.push(field);
i = new_i;
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
if i < chars.len() {
if chars[i] == ',' {
i += 1; } else {
return Err(LexError::InvalidToken {
message: format!("expected comma or end of line, got '{}'", chars[i]),
pos: SourcePos::new(1, i + 1),
});
}
}
}
Ok(fields)
}
fn parse_field(chars: &[char], start: usize) -> Result<(CsvField, usize), LexError> {
let mut i = start;
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
if i >= chars.len() {
return Ok((
CsvField {
value: String::new(),
is_quoted: false,
},
i,
));
}
if chars[i] == '"' {
parse_quoted_field(chars, i)
} else {
parse_unquoted_field(chars, i)
}
}
fn parse_quoted_field(chars: &[char], start: usize) -> Result<(CsvField, usize), LexError> {
let mut i = start + 1; let mut value = String::new();
while i < chars.len() {
if chars[i] == '"' {
if i + 1 < chars.len() && chars[i + 1] == '"' {
value.push('"');
i += 2;
} else {
i += 1; return Ok((
CsvField {
value,
is_quoted: true,
},
i,
));
}
} else if chars[i] == '\\' && i + 1 < chars.len() {
let next = chars[i + 1];
match next {
'n' => {
value.push('\n');
i += 2;
}
't' => {
value.push('\t');
i += 2;
}
'\\' => {
value.push('\\');
i += 2;
}
'"' => {
value.push('"');
i += 2;
}
_ => {
return Err(LexError::InvalidEscape {
sequence: format!("\\{}", next),
pos: SourcePos::new(1, i + 1),
});
}
}
} else {
value.push(chars[i]);
i += 1;
}
}
Err(LexError::UnclosedQuote {
pos: SourcePos::new(1, start + 1),
})
}
fn parse_unquoted_field(chars: &[char], start: usize) -> Result<(CsvField, usize), LexError> {
let mut i = start;
let mut value = String::new();
let mut expr_depth = 0;
let mut in_expr_quotes = false;
while i < chars.len() {
let ch = chars[i];
if ch == ',' && expr_depth == 0 {
break;
}
value.push(ch);
if ch == '$' && i + 1 < chars.len() && chars[i + 1] == '(' {
expr_depth += 1;
value.push(chars[i + 1]);
i += 2;
continue;
}
if expr_depth > 0 {
if ch == '"' {
if in_expr_quotes {
if i + 1 < chars.len() && chars[i + 1] == '"' {
value.push(chars[i + 1]);
i += 2;
continue;
} else {
in_expr_quotes = false;
}
} else {
in_expr_quotes = true;
}
} else if !in_expr_quotes {
if ch == '(' {
expr_depth += 1;
} else if ch == ')' {
expr_depth -= 1;
}
}
}
i += 1;
}
if expr_depth > 0 {
return Err(LexError::UnclosedExpression {
pos: SourcePos::new(1, start + 1),
});
}
let trimmed = value.trim().to_string();
if trimmed.contains('"') && !trimmed.starts_with("$(") {
return Err(LexError::InvalidToken {
message: format!(
"quote character '\"' found in unquoted field: '{}'",
trimmed
),
pos: SourcePos::new(1, start + 1),
});
}
Ok((
CsvField {
value: trimmed,
is_quoted: false,
},
i,
))
}
pub fn split_inline_children(s: &str, quote_char: char) -> Result<Vec<&str>, LexError> {
if s.is_empty() {
return Ok(Vec::new());
}
let mut parts = Vec::new();
let mut start = 0;
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
let mut quote_open = false;
let mut bracket_depth = 0; let mut paren_depth = 0;
while i < chars.len() {
let ch = chars[i];
if ch == quote_char && bracket_depth == 0 && paren_depth == 0 {
if quote_open {
if i + 1 < chars.len() && chars[i + 1] == quote_char {
i += 2; continue;
}
quote_open = false;
} else {
quote_open = true;
}
i += 1;
continue;
}
if quote_open {
i += 1;
continue;
}
if ch == '\\' && i + 1 < chars.len() {
i += 2; continue;
}
if ch == '[' {
bracket_depth += 1;
} else if ch == ']' && bracket_depth > 0 {
bracket_depth -= 1;
}
if ch == '(' {
paren_depth += 1;
} else if ch == ')' && paren_depth > 0 {
paren_depth -= 1;
}
if ch == '|' && bracket_depth == 0 && paren_depth == 0 {
let byte_start: usize = chars[..start].iter().map(|c| c.len_utf8()).sum();
let byte_end: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
parts.push(&s[byte_start..byte_end]);
start = i + 1;
}
i += 1;
}
if quote_open {
return Err(LexError::UnclosedQuote {
pos: SourcePos::new(1, start + 1),
});
}
if bracket_depth > 0 {
return Err(LexError::InvalidToken {
message: "unclosed tensor literal '[' in inline children".to_string(),
pos: SourcePos::new(1, start + 1),
});
}
if paren_depth > 0 {
return Err(LexError::InvalidToken {
message: "unclosed list literal '(' in inline children".to_string(),
pos: SourcePos::new(1, start + 1),
});
}
let byte_start: usize = chars[..start].iter().map(|c| c.len_utf8()).sum();
parts.push(&s[byte_start..]);
Ok(parts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_csv_simple() {
let fields = parse_csv_row("a, b, c").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "a");
assert_eq!(fields[1].value, "b");
assert_eq!(fields[2].value, "c");
assert!(!fields[0].is_quoted);
}
#[test]
fn test_parse_csv_no_spaces() {
let fields = parse_csv_row("a,b,c").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "a");
}
#[test]
fn test_parse_csv_quoted_field() {
let fields = parse_csv_row(r#""quoted, value", other"#).unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "quoted, value");
assert!(fields[0].is_quoted);
assert_eq!(fields[1].value, "other");
assert!(!fields[1].is_quoted);
}
#[test]
fn test_parse_csv_escaped_quote() {
let input = "\"escaped \"\"quote\"\"\"";
let fields = parse_csv_row(input).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "escaped \"quote\"");
assert!(fields[0].is_quoted);
}
#[test]
fn test_parse_csv_empty_fields() {
let fields = parse_csv_row("a,,c").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "a");
assert_eq!(fields[1].value, "");
assert_eq!(fields[2].value, "c");
}
#[test]
fn test_parse_csv_whitespace_preserved_quoted() {
let fields = parse_csv_row(r#"" spaced ", other"#).unwrap();
assert_eq!(fields[0].value, " spaced ");
assert!(fields[0].is_quoted);
}
#[test]
fn test_parse_csv_whitespace_trimmed_unquoted() {
let fields = parse_csv_row(" spaced , other ").unwrap();
assert_eq!(fields[0].value, "spaced");
assert_eq!(fields[1].value, "other");
}
#[test]
fn test_parse_csv_empty_string() {
let fields = parse_csv_row("").unwrap();
assert!(fields.is_empty());
}
#[test]
fn test_parse_csv_trailing_comma_error() {
let result = parse_csv_row("a, b,");
assert!(result.is_err());
}
#[test]
fn test_parse_csv_unclosed_quote_error() {
let result = parse_csv_row(r#""unclosed"#);
assert!(matches!(result, Err(LexError::UnclosedQuote { .. })));
}
#[test]
fn test_parse_csv_quote_in_unquoted_field_error() {
let result = parse_csv_row(r#"hello"world"#);
assert!(result.is_err());
}
#[test]
fn test_parse_csv_with_expression() {
let fields = parse_csv_row("$(x + 1), other").unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "$(x + 1)");
assert!(!fields[0].is_quoted);
}
#[test]
fn test_parse_csv_expression_with_comma() {
let fields = parse_csv_row("$(a, b), other").unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "$(a, b)");
}
#[test]
fn test_parse_csv_expression_with_nested_parens() {
let fields = parse_csv_row("$((a + b)), other").unwrap();
assert_eq!(fields[0].value, "$((a + b))");
}
#[test]
fn test_parse_csv_expression_with_quotes() {
let fields = parse_csv_row(r#"$(concat("a", "b")), other"#).unwrap();
assert_eq!(fields[0].value, r#"$(concat("a", "b"))"#);
}
#[test]
fn test_parse_csv_unclosed_expression_error() {
let result = parse_csv_row("$(incomplete, other");
assert!(matches!(result, Err(LexError::UnclosedExpression { .. })));
}
#[test]
fn test_parse_csv_single_field() {
let fields = parse_csv_row("single").unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "single");
}
#[test]
fn test_parse_csv_all_quoted() {
let fields = parse_csv_row(r#""a", "b", "c""#).unwrap();
assert_eq!(fields.len(), 3);
assert!(fields.iter().all(|f| f.is_quoted));
}
#[test]
fn test_parse_csv_escape_newline() {
let fields = parse_csv_row(r#""line1\nline2""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "line1\nline2");
assert!(fields[0].value.contains('\n'));
}
#[test]
fn test_parse_csv_escape_tab() {
let fields = parse_csv_row(r#""col1\tcol2""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "col1\tcol2");
assert!(fields[0].value.contains('\t'));
}
#[test]
fn test_parse_csv_escape_backslash() {
let fields = parse_csv_row(r#""path\\to\\file""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, r"path\to\file");
}
#[test]
fn test_parse_csv_escape_quote() {
let fields = parse_csv_row(r#""say \"hello\"""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "say \"hello\"");
}
#[test]
fn test_parse_csv_multiple_escapes() {
let fields = parse_csv_row(r#""line1\nline2\ttabbed""#).unwrap();
assert_eq!(fields.len(), 1);
assert!(fields[0].value.contains('\n'));
assert!(fields[0].value.contains('\t'));
}
#[test]
fn test_parse_csv_carriage_return_invalid() {
let result = parse_csv_row(r#""windows\r\nline""#);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LexError::InvalidEscape { .. }
));
}
#[test]
fn test_parse_csv_unicode_unquoted() {
let fields = parse_csv_row("日本語, émoji, über").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "日本語");
assert_eq!(fields[1].value, "émoji");
assert_eq!(fields[2].value, "über");
}
#[test]
fn test_parse_csv_unicode_quoted() {
let fields = parse_csv_row(r#""日本語", "émoji 🎉", "über""#).unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "日本語");
assert!(fields[0].is_quoted);
assert!(fields[1].value.contains('🎉'));
}
#[test]
fn test_parse_csv_only_whitespace_between_commas() {
let fields = parse_csv_row("a, ,c").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "a");
assert_eq!(fields[1].value, "");
assert_eq!(fields[2].value, "c");
}
#[test]
fn test_parse_csv_numbers() {
let fields = parse_csv_row("123, 45.67, -89, 0").unwrap();
assert_eq!(fields.len(), 4);
assert_eq!(fields[0].value, "123");
assert_eq!(fields[1].value, "45.67");
assert_eq!(fields[2].value, "-89");
assert_eq!(fields[3].value, "0");
}
#[test]
fn test_parse_csv_booleans() {
let fields = parse_csv_row("true, false, null").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "true");
assert_eq!(fields[1].value, "false");
assert_eq!(fields[2].value, "null");
}
#[test]
fn test_parse_csv_references() {
let fields = parse_csv_row("@User:123, @Post:456").unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "@User:123");
assert_eq!(fields[1].value, "@Post:456");
}
#[test]
fn test_parse_csv_mixed_types() {
let fields = parse_csv_row(r#"123, "hello", true, null, @Ref:1"#).unwrap();
assert_eq!(fields.len(), 5);
assert_eq!(fields[0].value, "123");
assert_eq!(fields[1].value, "hello");
assert!(fields[1].is_quoted);
assert_eq!(fields[2].value, "true");
assert_eq!(fields[3].value, "null");
assert_eq!(fields[4].value, "@Ref:1");
}
#[test]
fn test_parse_csv_many_fields() {
let input = (0..20)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(", ");
let fields = parse_csv_row(&input).unwrap();
assert_eq!(fields.len(), 20);
for (i, field) in fields.iter().enumerate() {
assert_eq!(field.value, i.to_string());
}
}
#[test]
fn test_parse_csv_expression_nested_quotes() {
let fields = parse_csv_row(r#"$(concat("a""b", "c")), other"#).unwrap();
assert_eq!(fields.len(), 2);
assert!(fields[0].value.starts_with("$("));
}
#[test]
fn test_parse_csv_multiple_expressions() {
let fields = parse_csv_row("$(a), $(b), $(c)").unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, "$(a)");
assert_eq!(fields[1].value, "$(b)");
assert_eq!(fields[2].value, "$(c)");
}
#[test]
fn test_parse_csv_expression_with_nested_calls() {
let fields = parse_csv_row("$(outer(inner(x))), other").unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "$(outer(inner(x)))");
}
#[test]
fn test_parse_csv_quoted_empty() {
let fields = parse_csv_row(r#""""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "");
assert!(fields[0].is_quoted);
}
#[test]
fn test_parse_csv_quoted_only_spaces() {
let fields = parse_csv_row(r#"" ""#).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, " ");
assert!(fields[0].is_quoted);
}
#[test]
fn test_parse_csv_unknown_escape_error() {
let result = parse_csv_row(r#""\x""#);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LexError::InvalidEscape { .. }
));
}
#[test]
fn test_parse_csv_double_quotes_in_expression() {
let fields = parse_csv_row(r#"$(say("hello""world")), other"#).unwrap();
assert_eq!(fields.len(), 2);
assert!(fields[0].value.contains("hello\"\"world"));
}
#[test]
fn test_parse_csv_special_chars_in_quotes() {
let fields = parse_csv_row(r#""!@#$%^&*()[]{}|;:'<>?/""#).unwrap();
assert_eq!(fields.len(), 1);
assert!(fields[0].is_quoted);
assert!(fields[0].value.contains('#'));
assert!(fields[0].value.contains('!'));
}
#[test]
fn test_parse_csv_comma_in_quoted() {
let fields = parse_csv_row(r#""a,b,c""#).unwrap();
assert_eq!(fields.len(), 1);
assert!(fields[0].is_quoted);
assert!(fields[0].value.contains(','));
assert_eq!(fields[0].value, "a,b,c");
}
#[test]
fn test_csv_field_equality() {
let a = CsvField {
value: "test".to_string(),
is_quoted: true,
};
let b = CsvField {
value: "test".to_string(),
is_quoted: true,
};
let c = CsvField {
value: "test".to_string(),
is_quoted: false,
};
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn test_csv_field_clone() {
let original = CsvField {
value: "test".to_string(),
is_quoted: true,
};
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn test_csv_field_debug() {
let field = CsvField {
value: "test".to_string(),
is_quoted: true,
};
let debug = format!("{:?}", field);
assert!(debug.contains("test"));
assert!(debug.contains("is_quoted"));
}
#[test]
fn test_parse_csv_trailing_comma_with_spaces() {
let result = parse_csv_row("a, b, ");
assert!(result.is_err());
}
#[test]
fn test_parse_csv_leading_comma_error() {
let fields = parse_csv_row(",a").unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value, "");
assert_eq!(fields[1].value, "a");
}
#[test]
fn test_parse_csv_multiple_consecutive_commas() {
let fields = parse_csv_row("a,,,b").unwrap();
assert_eq!(fields.len(), 4);
assert_eq!(fields[0].value, "a");
assert_eq!(fields[1].value, "");
assert_eq!(fields[2].value, "");
assert_eq!(fields[3].value, "b");
}
#[test]
fn test_parse_csv_tab_in_unquoted_field() {
let fields = parse_csv_row("a\tb, c").unwrap();
assert_eq!(fields.len(), 2);
assert!(fields[0].value.contains('\t') || fields[0].value == "a\tb");
}
#[test]
fn test_parse_csv_tab_in_quoted_field() {
let fields = parse_csv_row("\"a\tb\", c").unwrap();
assert_eq!(fields.len(), 2);
assert!(fields[0].value.contains('\t'));
}
#[test]
fn test_parse_csv_very_long_field() {
let long_value = "x".repeat(10000);
let input = format!("\"{}\", other", long_value);
let fields = parse_csv_row(&input).unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].value.len(), 10000);
}
#[test]
fn test_parse_csv_all_valid_escapes_together() {
let fields = parse_csv_row(r#""line1\nline2\ttab\\path\"""#).unwrap();
assert_eq!(fields.len(), 1);
assert!(fields[0].value.contains('\n'));
assert!(fields[0].value.contains('\t'));
assert!(fields[0].value.contains('\\'));
assert!(fields[0].value.contains('"'));
}
}