#2225: a field that only looks quoted keeps the tabs and line breaks in it

Addresses CodeRabbit review on #3231, "Reparse consumed separators when a
field is not quoted": parse_tsv read "a<TAB>b"x as one quoted field,
then fell back to its raw text without the separator it had consumed,
so the cells "a, b"x and c pasted back as two. The new copy (which no
longer quotes a cell holding only a quote) makes that text. A field
whose closing quote doesn't end it, or that never closes, is now read
again as unquoted text, so its tabs and line breaks separate cells.
The text read again is bounded by the input's length, so hostile input
stays linear.

Test: a_field_that_is_not_really_quoted_keeps_its_separators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hector VillarrealandClaude Opus 5.5 committed 2026-11-03 04:19:49 -06:00
1 parent fe965e7581
commit 533c0dadaf
2 files changed
+59 -7

No files matched your search

+37 -7
View File
@@ -226,24 +226,54 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
// quote but turned out not to be quoted.
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
// Text is replayed at most as much as the input is long; past that, quotes are text, so
// hostile input full of fields that only look quoted reads in linear time.
let mut replay_budget = text.len();
let mut quoted = false;
let mut at_start = true;
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
let mut raw = String::new();
while let Some(c) = chars.next() {
loop {
let c = match replay.pop_front().or_else(|| chars.next()) {
Some(c) => c,
// A quote that never closes: the field is its text as written.
None if quoted => {
quoted = false;
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
replay.extend(raw.chars().skip(1));
raw.clear();
continue;
}
None => break,
};
if quoted {
raw.push(c);
if c == '"' {
if chars.peek() == Some(&'"') {
let next = replay.front().copied().or_else(|| chars.peek().copied());
if next == Some('"') {
field.push('"');
raw.push('"');
chars.next();
if replay.pop_front().is_none() {
chars.next();
}
} else {
quoted = false;
// A field is quoted only when its closing quote ends it, as Excel reads it:
// `"hi" there` is that text, quotes included.
if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) {
field = std::mem::take(&mut raw);
// `"hi" there` is that text, quotes included, and a tab or line break inside
// it still ends the cell, as if the leading quote weren't there.
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
for ch in rest.into_iter().rev() {
replay.push_front(ch);
}
}
}
} else {
@@ -252,7 +282,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
continue;
}
match c {
'"' if at_start => {
'"' if at_start && replay_budget > 0 => {
quoted = true;
at_start = false;
raw = String::from('"');
+22
View File
@@ -390,3 +390,25 @@ fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
}
#[test]
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
// never closes), the text is read as written, and the tabs and line breaks inside it still
// separate cells, as they would have without the leading quote.
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
// input's length), and every one is still its own cell.
let hostile = "\"x\t".repeat(50_000);
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
}