From 837d2b566754a43571b2bd6b6342045c80f1aac5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 20:14:25 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/variablecmds.rs | 30 ++++++++++++++++++++++----- crates/engine/src/tests_variables.rs | 20 ++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index c3cc152fa..e26a51938 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -529,11 +529,14 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { /// 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>> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::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>> { 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>> { } } +/// 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) -> 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 { } fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result> { - 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); } diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 05b3cbc19..f718bed44 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -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() {