use super::js;
use super::position::Position;
use super::{Path, heuristics};
pub(crate) const COMMA: char = ',';
pub(crate) const TAB: char = '\t';
const QUOTE: char = '"';
const ENDINGS: [&str; 3] = ["\r\n", "\n", "\r"];
pub(crate) fn extract(content: &str, delimiter: char) -> Vec<Path> {
let label = if delimiter == TAB { "TSV" } else { "CSV" };
if js::is_blank(content) {
return Vec::new();
}
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let Some(records) = rows(content, delimiter) else {
return Vec::new();
};
let mut paths = Vec::new();
for (row_index, record) in records.iter().enumerate() {
for (column_index, cell) in record.iter().enumerate() {
let cell = js::trim(cell);
if !heuristics::is_path_like(cell) {
continue;
}
let line = row_index + 1;
let column = column_index + 1;
paths.push(Path {
value: cell.to_string(),
kind: heuristics::classify_path_type(cell),
position: Position { line, column },
context: format!("{label} cell [{line},{column}]"),
});
}
}
paths
}
struct Cell {
text: String,
was_quoted: bool,
quoting: bool,
}
impl Cell {
fn take(&mut self) -> String {
let mut text = std::mem::take(&mut self.text);
if !self.was_quoted {
text.truncate(js::trim_end(&text).len());
}
self.was_quoted = false;
text
}
fn push(&mut self, c: char) -> Option<usize> {
let space = js::is_js_whitespace(c);
let keeps = self.quoting || !self.text.is_empty() || !space;
if keeps && !self.was_quoted {
self.text.push(c);
return Some(c.len_utf8());
}
if !space {
return None;
}
if !keeps {
return Some(c.len_utf8());
}
(c.len_utf8() == 1).then_some(1)
}
}
fn rows(content: &str, delimiter: char) -> Option<Vec<Vec<String>>> {
let mut records: Vec<Vec<String>> = Vec::new();
let mut record: Vec<String> = Vec::new();
let mut cell = Cell {
text: String::new(),
was_quoted: false,
quoting: false,
};
let mut ending: Option<&str> = None;
let mut rest = content;
while let Some(c) = rest.chars().next() {
if cell.quoting {
rest = quoted(&mut cell, &mut ending, rest, delimiter)?;
continue;
}
if ending.is_none() {
ending = line_ending(rest);
}
if let Some(tail) = ending.and_then(|e| rest.strip_prefix(e)) {
rest = tail;
if !cell.was_quoted && cell.text.is_empty() && record.is_empty() {
continue;
}
record.push(cell.take());
records.push(std::mem::take(&mut record));
continue;
}
if let Some(tail) = rest.strip_prefix(delimiter) {
record.push(cell.take());
rest = tail;
continue;
}
if c == QUOTE && cell.text.is_empty() {
cell.quoting = true;
rest = &rest[QUOTE.len_utf8()..];
continue;
}
rest = &rest[cell.push(c)?..];
}
if cell.quoting {
return None;
}
if cell.was_quoted || !cell.text.is_empty() || !record.is_empty() {
record.push(cell.take());
records.push(record);
}
Some(records)
}
fn quoted<'a>(
cell: &mut Cell,
ending: &mut Option<&'a str>,
rest: &'a str,
delimiter: char,
) -> Option<&'a str> {
if let Some(tail) = rest.strip_prefix("\"\"") {
cell.push(QUOTE)?;
return Some(tail);
}
let c = rest.chars().next()?;
if c != QUOTE {
return Some(&rest[cell.push(c)?..]);
}
let after = &rest[QUOTE.len_utf8()..];
if ending.is_none() {
*ending = line_ending(after);
}
if !closes(after, delimiter, *ending) {
return None;
}
cell.quoting = false;
cell.was_quoted = true;
Some(after)
}
fn closes(after: &str, delimiter: char, ending: Option<&str>) -> bool {
after.is_empty()
|| after.starts_with(delimiter)
|| ending.is_some_and(|e| after.starts_with(e))
|| after.chars().next().is_some_and(js::is_js_whitespace)
}
fn line_ending(rest: &str) -> Option<&'static str> {
ENDINGS.into_iter().find(|e| rest.starts_with(e))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::PathType;
#[test]
fn a_tab_row_is_cells_under_tab_and_one_cell_under_comma() {
let text = "name\tpath\nalpha\t./src/a.ts\n";
let tabbed = extract(text, TAB);
assert_eq!(tabbed.len(), 1);
assert_eq!(tabbed[0].value, "./src/a.ts");
assert_eq!(tabbed[0].context, "TSV cell [2,2]");
assert!(extract(text, COMMA).is_empty());
}
#[test]
fn a_blank_document_yields_nothing() {
assert!(extract("", COMMA).is_empty());
assert!(extract(" \n ", COMMA).is_empty());
}
#[test]
fn positions_are_cell_coordinates() {
let paths = extract("a,b\nx,/srv/f.txt\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position, Position { line: 2, column: 2 });
assert_eq!(paths[0].context, "CSV cell [2,2]");
}
#[test]
fn a_quoted_cell_may_contain_a_space() {
let paths = extract("a\n\"./with space/f.png\"\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "./with space/f.png");
assert_eq!(paths[0].kind, PathType::Relative);
}
#[test]
fn cells_are_trimmed_before_the_heuristic_sees_them() {
let paths = extract("a\n /srv/f.txt \n", COMMA);
assert_eq!(paths[0].value, "/srv/f.txt");
}
#[test]
fn ragged_rows_are_data_not_an_error() {
let paths = extract(
"a,b,c\n/one.txt\n/two.txt,/three.txt,/four.txt,/five.txt\n",
COMMA,
);
assert_eq!(paths.len(), 5);
}
#[test]
fn version_strings_and_plain_words_are_not_paths() {
let paths = extract("version,name\n3.4.5,not-a-path\n", COMMA);
assert!(paths.is_empty());
}
#[test]
fn cells_are_trimmed_with_javascripts_whitespace_not_rusts() {
let paths = extract("a\n\u{85}/a.txt\n", COMMA);
assert_eq!(paths[0].value, "\u{85}/a.txt");
assert_eq!(paths[0].kind, PathType::File);
let paths = extract("a\nx,\u{feff}/a.txt\n", COMMA);
assert_eq!(paths[0].value, "/a.txt");
assert_eq!(paths[0].kind, PathType::Absolute);
}
#[test]
fn a_byte_order_mark_does_not_corrupt_the_first_cell() {
let paths = extract("\u{feff}/srv/f.txt\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "/srv/f.txt");
}
#[test]
fn empty_lines_do_not_shift_the_rows_after_them() {
let paths = extract("a\n\n/srv/f.txt\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position.line, 2);
}
#[test]
fn a_quote_that_closes_mid_cell_refuses_the_document() {
assert!(extract("\"./no-extension\",two\n1,2\n", TAB).is_empty());
assert!(rows("\"./no-extension\",two\n1,2\n", TAB).is_none());
let paths = extract("\"./no-extension\",two\n1,2\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "./no-extension");
assert_eq!(paths[0].position, Position { line: 1, column: 1 });
}
#[test]
fn a_refusal_abandons_every_row_including_the_good_ones() {
assert!(extract("\"a\"x\n/real/path.txt\n", COMMA).is_empty());
assert!(extract("\"a\"x\t/real/path.txt\n", TAB).is_empty());
}
#[test]
fn every_malformed_quote_refuses_on_either_delimiter() {
for delimiter in [COMMA, TAB] {
assert!(rows("\"abc\"def\n", delimiter).is_none());
assert!(rows(" \"a\" x,b\n", delimiter).is_none());
assert!(rows("\"abc\"x", delimiter).is_none());
assert!(rows("\"unterminated\n", delimiter).is_none());
assert!(rows("\"", delimiter).is_none());
assert!(rows("\"a\"\"\n", delimiter).is_none());
}
}
#[test]
fn a_quote_inside_a_cell_is_literal_text() {
assert_eq!(rows("a\"b,c\n", COMMA), Some(vec![vec_of(&["a\"b", "c"])]));
assert_eq!(
rows("abc\"def\"\n", COMMA),
Some(vec![vec_of(&["abc\"def\""])])
);
assert_eq!(
rows("ab\"\ncd\n", COMMA),
Some(vec![vec_of(&["ab\""]), vec_of(&["cd"]),])
);
assert_eq!(rows("a\"b,c\n", TAB), Some(vec![vec_of(&["a\"b,c"])]));
}
#[test]
fn a_delimiter_inside_a_well_formed_quoted_cell_does_not_split_it() {
assert_eq!(
rows("\"a,b\",c\n", COMMA),
Some(vec![vec_of(&["a,b", "c"])])
);
assert_eq!(
rows("\"a\tb\"\tc\n", TAB),
Some(vec![vec_of(&["a\tb", "c"])])
);
assert_eq!(rows("\"a\"\"b\"\n", COMMA), Some(vec![vec_of(&["a\"b"])]));
assert_eq!(rows("\"\"\"\"\n", COMMA), Some(vec![vec_of(&["\""])]));
assert_eq!(
rows("\"a\nb\",c\n", COMMA),
Some(vec![vec_of(&["a\nb", "c"])])
);
}
#[test]
fn whitespace_after_a_closing_quote_follows_the_readers_own_rule() {
assert_eq!(rows("\"a\" ,b\n", COMMA), Some(vec![vec_of(&["a", "b"])]));
assert_eq!(
rows(" \"a\" ,b\n", COMMA),
Some(vec![vec_of(&["a", "b"])])
);
assert!(rows("\"a\"\u{a0},b\n", COMMA).is_none());
assert!(rows("\"a\" \u{a0},b\n", COMMA).is_none());
assert!(rows("\"a\"\u{feff},b\n", COMMA).is_none());
assert_eq!(
rows("\"\"\u{a0},b\n", COMMA),
Some(vec![vec_of(&["", "b"])])
);
assert_eq!(
rows("\"\" \u{feff},b\n", COMMA),
Some(vec![vec_of(&["", "b"])])
);
}
#[test]
fn a_cell_that_re_opens_its_quotes_may_still_keep_nothing() {
assert_eq!(rows("\"\" \"\" ", COMMA), Some(vec![vec_of(&[""])]));
assert_eq!(rows("\"\" \"\",b\n", COMMA), Some(vec![vec_of(&["", "b"])]));
assert!(rows("\"\" \"./b.ts\"\n", COMMA).is_none());
assert!(rows("\"\"\t\"\"\"\"\r./b.ts", COMMA).is_none());
assert!(rows("\"\" \u{feff}\"./b.ts\"\r/c.md", TAB).is_none());
}
#[test]
fn the_row_separator_is_whichever_one_came_first() {
assert_eq!(
rows("a,b\nc\r\nd\n", COMMA),
Some(vec![vec_of(&["a", "b"]), vec_of(&["c"]), vec_of(&["d"])])
);
assert_eq!(
rows("a,b\r\nc\nd\r\n", COMMA),
Some(vec![vec_of(&["a", "b"]), vec_of(&["c\nd"])])
);
assert_eq!(
rows("a,b\rc,d\r", COMMA),
Some(vec![vec_of(&["a", "b"]), vec_of(&["c", "d"])])
);
}
#[test]
fn a_whitespace_only_row_does_not_shift_the_rows_after_it() {
let paths = extract("a\n \n/srv/f.txt\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position.line, 2);
let paths = extract("a\n\u{85}\n/srv/f.txt\n", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position.line, 3);
}
#[test]
fn a_document_that_stops_without_a_row_separator_keeps_its_last_cell() {
let paths = extract("one,two\n\"./x.ts\",2", COMMA);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position, Position { line: 2, column: 1 });
assert_eq!(rows("a,", COMMA), Some(vec![vec_of(&["a", ""])]));
assert_eq!(rows("\"abc\" ", COMMA), Some(vec![vec_of(&["abc"])]));
}
fn vec_of(cells: &[&str]) -> Vec<String> {
cells.iter().map(|c| (*c).to_string()).collect()
}
}