Variables: a quoted text cell in a data set CSV keeps its leading and trailing spaces

dataset.import trimmed every cell, quoted or not. dataset.export quotes
a text value with leading or trailing spaces, so a value such as
"  padded " came back from its own export as "padded".

The CSV reader now trims an unquoted cell as before and keeps a quoted
cell as written between its quotes, spaces included. White space
between the closing quote and the next comma or line end is still
dropped. Image, graph and visibility cells, data set names and header
cells are trimmed as before.
This commit is contained in:
dotCooCoo
2026-10-16 14:57:25 +07:00
committed by bflatastic
parent 04806a98bf
commit 837d2b5667
2 changed files with 45 additions and 5 deletions
+25 -5
View File
@@ -529,11 +529,14 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
/// [`csv_escape`] writes them. Blank lines are skipped.
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
/// hold as written, spaces included, and drops white space after the closing quote at the end
/// of the cell. Blank lines are skipped.
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
let (mut in_quotes, mut line) = (false, 1usize);
// `closed`: where the closing quote of a quoted cell falls in `cur`.
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
let mut chars = text.chars().peekable();
loop {
let c = chars.next();
@@ -544,13 +547,14 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
chars.next();
} else {
in_quotes = false;
closed = Some(cur.len());
}
}
Some('"') if cur.is_empty() => in_quotes = true,
Some(',') if !in_quotes => row.push(std::mem::take(&mut cur)),
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
Some('\r') if !in_quotes => {}
Some('\n') | None if !in_quotes => {
row.push(std::mem::take(&mut cur));
row.push(end_csv_cell(&mut cur, &mut closed));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push(std::mem::take(&mut row));
// +1 for the header.
@@ -576,6 +580,20 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
}
}
/// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell
/// is trimmed; with one, the text before it is kept as written and the text after it is
/// trimmed at the end.
fn end_csv_cell(cur: &mut String, closed: &mut Option<usize>) -> String {
let cell = std::mem::take(cur);
match closed.take() {
Some(at) => match cell.split_at_checked(at) {
Some((quoted, after)) => format!("{quoted}{}", after.trim_end()),
None => cell,
},
None => cell.trim().to_string(),
}
}
fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}
@@ -590,7 +608,9 @@ fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> {
}
fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> {
let t = cell.trim();
// A text cell is not trimmed here (the parser trims unquoted cells); image, graph and
// visibility cells are trimmed.
let t = if kind == VariableKind::Text { cell } else { cell.trim() };
if t.is_empty() {
return Ok(None);
}
+20
View File
@@ -896,6 +896,26 @@ fn datasets_csv_keeps_line_breaks_in_quoted_cells() {
assert!(s.execute("dataset.import", &json!({"csv": "name,Name\nX,\"open"})).is_err(), "unclosed quote");
}
/// A quoted text cell keeps its leading and trailing spaces, so a padded value comes back from
/// the exported CSV as it went out; an unquoted cell, and spaces after a closing quote, are trimmed.
#[test]
fn datasets_csv_keeps_spaces_in_quoted_cells() {
let mut s = session();
let t = text(&mut s, "One");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": " padded "}})).unwrap();
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap();
s.execute("dataset.import", &json!({"csv": csv})).unwrap();
s.execute("dataset.import", &json!({"csv": "name,Name\nB, typed \nC,\" quoted \" \n"})).unwrap();
let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned();
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
assert_eq!(value("A"), text_value(" padded "));
assert_eq!(value("B"), text_value("typed"));
assert_eq!(value("C"), text_value(" quoted "));
}
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
#[test]
fn datasets_csv_round_trips_and_rejects_bad_input() {