Skip to main content

rich_data/
csv.rs

1//! CSV and TSV: a port of Python's `csv` sniffer and reader, and a
2//! [`CsvReader`] that turns a file into [`Rows`].
3//!
4//! [`sniff`], [`has_header`] and [`read_rows`] are the reader `rich --csv`
5//! has always used (moved here from `rs-rich-cli` in 0.0.16 so every caller
6//! shares it): `csv.Sniffer.sniff`, `csv.Sniffer.has_header` and
7//! `_csv.reader`, ported closely enough that `rich --csv` keeps rich-cli
8//! 1.8.1's output byte for byte. They take text that has already been through
9//! Python's universal newlines (no `\r`); [`CsvReader`] does that first.
10//!
11//! [`CsvReader`] is the adapter: it sniffs the dialect and the header the way
12//! `--csv` does (or takes them as given), drops blank lines, and yields every
13//! cell as [`Value::Str`] exactly as written. Nothing is typed until
14//! [`infer`](crate::infer) is asked to.
15//!
16//! ```
17//! use rich_data::csv::CsvReader;
18//! use rich_data::Value;
19//!
20//! let rows = CsvReader::new().read("name;age\nAda;36\nAlan;41\n").unwrap();
21//! assert_eq!(rows.columns(), ["name", "age"]);
22//! assert_eq!(rows.rows()[1], [Value::from("Alan"), Value::from("41")]);
23//!
24//! let tsv = CsvReader::tsv().header(false).read("a\tb\n").unwrap();
25//! assert_eq!(tsv.columns(), ["1", "2"]);
26//! ```
27
28use std::borrow::Cow;
29use std::collections::VecDeque;
30use std::io::{BufRead, Read};
31
32use crate::{DataError, Row, RowSource, Rows, Value};
33
34/// The delimiters [`CsvReader`] (and `rich --csv`) lets the sniffer choose
35/// from: rich-cli's `",\t|;"`.
36pub const DELIMITERS: [char; 4] = [',', '\t', '|', ';'];
37
38/// How many characters of the input the sniffer reads: rich-cli's
39/// `csv_data[:1024]`.
40pub const SAMPLE_CHARS: usize = 1024;
41
42/// Whether the first row is a header.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Header {
45    /// Decide with [`has_header`], as `rich --csv` does; when the sniffer
46    /// cannot tell, the first row is a header.
47    #[default]
48    Sniff,
49    /// The first row is a header.
50    Yes,
51    /// There is no header; columns are named `1`, `2`, …
52    No,
53    /// The first row is a header unless every cell in it is a number (or
54    /// empty), as `rich chart` and `rich profile` decide. Python's sniffer
55    /// says "no header" whenever every column is ragged in length or type,
56    /// which one empty cell can make it.
57    UnlessNumeric,
58}
59
60/// Reads CSV or TSV text into [`Rows`]. See the [module docs](self).
61#[derive(Clone, Copy, Debug, Default)]
62pub struct CsvReader {
63    dialect: Option<Dialect>,
64    fallback: Option<char>,
65    header: Header,
66}
67
68impl CsvReader {
69    /// Sniff the dialect from [`DELIMITERS`] and the header, failing when the
70    /// sniffer finds no delimiter (set a [`fallback`](Self::fallback) to
71    /// read such input anyway).
72    pub fn new() -> Self {
73        CsvReader::default()
74    }
75
76    /// Tab-separated, with excel's quoting (`csv.get_dialect("excel-tab")`).
77    pub fn tsv() -> Self {
78        CsvReader::new().delimiter('\t')
79    }
80
81    /// Use this delimiter, with excel's quoting, instead of sniffing.
82    #[must_use]
83    pub fn delimiter(mut self, delimiter: char) -> Self {
84        self.dialect = Some(Dialect::excel(delimiter));
85        self
86    }
87
88    /// Use this dialect instead of sniffing.
89    #[must_use]
90    pub fn dialect(mut self, dialect: Dialect) -> Self {
91        self.dialect = Some(dialect);
92        self
93    }
94
95    /// The delimiter to fall back to when the sniffer finds none, as
96    /// `rich --csv` falls back to `,` for a `.csv` file and a tab for a
97    /// `.tsv` one.
98    #[must_use]
99    pub fn fallback(mut self, delimiter: char) -> Self {
100        self.fallback = Some(delimiter);
101        self
102    }
103
104    /// Whether the first row is a header (`true`/`false`), instead of
105    /// sniffing it.
106    #[must_use]
107    pub fn header(mut self, header: bool) -> Self {
108        self.header = if header { Header::Yes } else { Header::No };
109        self
110    }
111
112    /// Set the header decision, [`Header::Sniff`] included.
113    #[must_use]
114    pub fn header_mode(mut self, header: Header) -> Self {
115        self.header = header;
116        self
117    }
118
119    /// The dialect and header decision for `content`, as [`read`](Self::read)
120    /// makes them; `None` when the sniffer finds no delimiter and there is no
121    /// fallback.
122    pub fn detect(&self, content: &str) -> Option<(Dialect, bool)> {
123        let sample: String = content.chars().take(SAMPLE_CHARS).collect();
124        let dialect = match self.dialect {
125            Some(dialect) => dialect,
126            None => {
127                sniff(&sample, Some(&DELIMITERS)).or_else(|| self.fallback.map(Dialect::excel))?
128            }
129        };
130        let header = match self.header {
131            Header::Yes => true,
132            Header::No => false,
133            Header::Sniff => has_header(&sample).unwrap_or(true),
134            Header::UnlessNumeric => {
135                let first = read_rows(&sample, &dialect)
136                    .into_iter()
137                    .find(|row| !row.is_empty())
138                    .unwrap_or_default();
139                first.iter().any(|cell| {
140                    let cell = cell.trim();
141                    !cell.is_empty() && cell.parse::<f64>().is_err()
142                })
143            }
144        };
145        Some((dialect, header))
146    }
147
148    /// Read `content`. Blank lines are skipped; a row longer than the header
149    /// adds columns named by position, and a shorter one is padded with
150    /// nulls, so no field is lost.
151    pub fn read(&self, content: &str) -> Result<Rows, DataError> {
152        let content = universal_newlines(content);
153        // Sniffed past a byte-order mark, as `source` sniffs.
154        let content = content.strip_prefix('\u{feff}').unwrap_or(&content);
155        let (dialect, header) = self
156            .detect(content)
157            .ok_or_else(|| DataError::new("Could not determine delimiter"))?;
158        let mut records = parse_rows(content, &dialect)
159            .into_iter()
160            .filter(|row| !row.is_empty());
161        let names = if header {
162            records.next().unwrap_or_default()
163        } else {
164            Vec::new()
165        };
166        let records: Vec<Vec<String>> = records.collect();
167        let width = records
168            .iter()
169            .map(Vec::len)
170            .max()
171            .unwrap_or(0)
172            .max(names.len());
173        let columns: Vec<String> = (0..width)
174            .map(|i| names.get(i).cloned().unwrap_or_else(|| (i + 1).to_string()))
175            .collect();
176        let mut rows = Rows::new(columns);
177        for record in records {
178            rows.push(record.into_iter().map(Value::Str));
179        }
180        Ok(rows)
181    }
182}
183
184impl CsvReader {
185    /// Stream rows from `reader` instead of reading the text whole, so an
186    /// input larger than memory can be read (a [profile](crate::profile)
187    /// samples it).
188    ///
189    /// The dialect and header are decided from the first [`SAMPLE_CHARS`]
190    /// characters, as [`read`](Self::read) decides them, and records are
191    /// parsed one at a time: a quoted field may span lines, up to
192    /// [`MAX_RECORD`] bytes. The columns are decided from the rows the
193    /// sniffer read, as [`read`](Self::read) decides them from every row (a
194    /// row longer than the header adds columns named by position); a later
195    /// row is padded with nulls or cut to fit, and
196    /// [`longer_rows`](CsvSource::longer_rows) counts the cut ones. Bytes
197    /// that are not UTF-8 are replaced.
198    ///
199    /// ```
200    /// use rich_data::csv::CsvReader;
201    /// use rich_data::{RowSource, Value};
202    ///
203    /// let text = "name,note\nweb,\"two\nlines\"\napi,ok\n";
204    /// let mut source = CsvReader::new().source(text.as_bytes()).unwrap();
205    /// assert_eq!(source.columns(), ["name", "note"]);
206    /// let first = source.next_row().unwrap().unwrap();
207    /// assert_eq!(first[1], Value::from("two\nlines"));
208    /// source.next_row().unwrap().unwrap();
209    /// assert!(source.next_row().is_none());
210    /// ```
211    pub fn source<R: BufRead>(&self, reader: R) -> Result<CsvSource<R>, DataError> {
212        let mut source = CsvSource {
213            reader,
214            dialect: Dialect::excel(','),
215            columns: Vec::new(),
216            pending: VecDeque::new(),
217            record: String::new(),
218            state: State::StartRecord,
219            line: 0,
220            done: false,
221            longer: 0,
222        };
223        // Whole lines until the sniffer has its sample.
224        let mut prefix = String::new();
225        while prefix.chars().count() < SAMPLE_CHARS {
226            match source.read_line()? {
227                Some(line) => prefix.push_str(&line),
228                None => break,
229            }
230        }
231        let prefix = universal_newlines(&prefix);
232        let prefix = prefix.strip_prefix('\u{feff}').unwrap_or(&prefix);
233        let (dialect, header) = self
234            .detect(prefix)
235            .ok_or_else(|| DataError::new("Could not determine delimiter"))?;
236        source.dialect = dialect;
237        source.feed(prefix)?;
238        // The sample may end inside a quoted field: finish that record.
239        if !matches!(source.state, State::StartRecord) {
240            source.fill_record()?;
241        }
242        if source.done {
243            source.flush_record();
244        }
245        let names = if header {
246            source.pending.pop_front().unwrap_or_default()
247        } else {
248            Vec::new()
249        };
250        let width = source
251            .pending
252            .iter()
253            .map(Vec::len)
254            .max()
255            .unwrap_or(0)
256            .max(names.len());
257        source.columns = (0..width)
258            .map(|i| names.get(i).cloned().unwrap_or_else(|| (i + 1).to_string()))
259            .collect();
260        Ok(source)
261    }
262}
263
264/// The longest record a [`CsvSource`] holds, in bytes (64 MiB): a line, or
265/// a quoted field still open, past it is refused rather than read into
266/// memory.
267pub const MAX_RECORD: usize = 64 << 20;
268
269/// A streaming CSV or TSV [`RowSource`], from [`CsvReader::source`]. Every
270/// cell is [`Value::Str`] as written.
271#[derive(Debug)]
272pub struct CsvSource<R> {
273    reader: R,
274    dialect: Dialect,
275    columns: Vec<String>,
276    /// Records parsed and not yet yielded.
277    pending: VecDeque<Vec<String>>,
278    /// The record being read, while a quoted field spans lines.
279    record: String,
280    /// Where `record` ends in the reader's state machine.
281    state: State,
282    line: usize,
283    done: bool,
284    longer: usize,
285}
286
287impl<R: BufRead> CsvSource<R> {
288    /// The dialect in use.
289    pub fn dialect(&self) -> Dialect {
290        self.dialect
291    }
292
293    /// How many rows so far had more cells than there are columns (their
294    /// extra cells are dropped).
295    pub fn longer_rows(&self) -> usize {
296        self.longer
297    }
298
299    /// The next line, its `\n` included; `None` at the end.
300    fn read_line(&mut self) -> Result<Option<String>, DataError> {
301        if self.done {
302            return Ok(None);
303        }
304        let mut buffer = Vec::new();
305        let read = (&mut self.reader)
306            .take(MAX_RECORD as u64 + 1)
307            .read_until(b'\n', &mut buffer)
308            .map_err(|e| {
309                self.done = true;
310                DataError::at(self.line + 1, e.to_string())
311            })?;
312        if read == 0 {
313            self.done = true;
314            return Ok(None);
315        }
316        self.line += 1;
317        if buffer.len() > MAX_RECORD {
318            self.done = true;
319            return Err(DataError::at(
320                self.line,
321                format!("longer than {MAX_RECORD} bytes"),
322            ));
323        }
324        Ok(Some(String::from_utf8_lossy(&buffer).into_owned()))
325    }
326
327    /// Add `text` (universal newlines applied) to the record being read,
328    /// parsing each record it completes.
329    fn feed(&mut self, text: &str) -> Result<(), DataError> {
330        for c in text.chars() {
331            self.state = advance(self.state, c, &self.dialect);
332            self.record.push(c);
333            if matches!(self.state, State::StartRecord) {
334                self.flush_record();
335            }
336        }
337        if self.record.len() > MAX_RECORD {
338            self.done = true;
339            return Err(DataError::at(
340                self.line,
341                format!("a quoted field runs past {MAX_RECORD} bytes"),
342            ));
343        }
344        Ok(())
345    }
346
347    /// Read lines until the record being read ends, or the input does.
348    fn fill_record(&mut self) -> Result<(), DataError> {
349        while let Some(line) = self.read_line()? {
350            self.feed(&universal_newlines(&line))?;
351            if matches!(self.state, State::StartRecord) {
352                break;
353            }
354        }
355        Ok(())
356    }
357
358    /// Parse the record being read into `pending`, dropping blank lines.
359    fn flush_record(&mut self) {
360        if self.record.is_empty() {
361            return;
362        }
363        let text = std::mem::take(&mut self.record);
364        self.state = State::StartRecord;
365        self.pending.extend(
366            parse_rows(&text, &self.dialect)
367                .into_iter()
368                .filter(|row| !row.is_empty()),
369        );
370    }
371}
372
373impl<R: BufRead> RowSource for CsvSource<R> {
374    fn columns(&self) -> &[String] {
375        &self.columns
376    }
377
378    fn next_row(&mut self) -> Option<Result<Row, DataError>> {
379        while self.pending.is_empty() {
380            if self.done {
381                // A last record without a newline, or a quoted field the
382                // input never closed.
383                self.flush_record();
384                if self.pending.is_empty() {
385                    return None;
386                }
387                break;
388            }
389            if let Err(error) = self.fill_record() {
390                return Some(Err(error));
391            }
392        }
393        let record = self.pending.pop_front()?;
394        let width = self.columns.len();
395        if record.len() > width {
396            self.longer += 1;
397        }
398        let mut row: Row = record.into_iter().take(width).map(Value::Str).collect();
399        row.resize(width, Value::Null);
400        Some(Ok(row))
401    }
402}
403
404/// One step of [`read_rows`]'s state machine without collecting fields: just
405/// enough to know where a record ends.
406fn advance(state: State, c: char, dialect: &Dialect) -> State {
407    match state {
408        State::StartRecord | State::StartField => {
409            if c == '\n' {
410                State::StartRecord
411            } else if c == dialect.quotechar {
412                State::InQuotedField
413            } else if c == dialect.delimiter || (c == ' ' && dialect.skipinitialspace) {
414                State::StartField
415            } else {
416                State::InField
417            }
418        }
419        State::InField => match c {
420            '\n' => State::StartRecord,
421            c if c == dialect.delimiter => State::StartField,
422            _ => State::InField,
423        },
424        State::InQuotedField => {
425            if c != dialect.quotechar {
426                State::InQuotedField
427            } else if dialect.doublequote {
428                State::QuoteInQuotedField
429            } else {
430                State::InField
431            }
432        }
433        State::QuoteInQuotedField => match c {
434            c if c == dialect.quotechar => State::InQuotedField,
435            c if c == dialect.delimiter => State::StartField,
436            '\n' => State::StartRecord,
437            _ => State::InField,
438        },
439    }
440}
441
442/// Python's universal newlines: `\r\n` and a lone `\r` both become `\n`.
443fn universal_newlines(content: &str) -> Cow<'_, str> {
444    if content.contains('\r') {
445        Cow::Owned(content.replace("\r\n", "\n").replace('\r', "\n"))
446    } else {
447        Cow::Borrowed(content)
448    }
449}
450
451/// A CSV dialect: everything `csv.Sniffer` decides and `csv.reader` consumes.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub struct Dialect {
454    /// The field separator.
455    pub delimiter: char,
456    /// The quote character.
457    pub quotechar: char,
458    /// Whether a doubled quote inside a quoted field is one literal quote.
459    pub doublequote: bool,
460    /// Whether spaces straight after a delimiter are dropped.
461    pub skipinitialspace: bool,
462}
463
464impl Dialect {
465    /// `csv.get_dialect("excel")` (or `"excel-tab"` for a tab): upstream's
466    /// fallback for a `.csv`/`.tsv` the sniffer cannot read.
467    pub fn excel(delimiter: char) -> Self {
468        Dialect {
469            delimiter,
470            quotechar: '"',
471            doublequote: true,
472            skipinitialspace: false,
473        }
474    }
475}
476
477/// Whether `c` is a `\w` character for CPython's `re` over `str`.
478fn is_word_char(c: char) -> bool {
479    c.is_alphanumeric() || c == '_'
480}
481
482/// The `[^\w\n"']` class the sniffer accepts as a candidate delimiter.
483fn is_delimiter_char(c: char) -> bool {
484    !is_word_char(c) && c != '\n' && c != '"' && c != '\''
485}
486
487/// The `["']` class the sniffer accepts as a candidate quote character.
488fn is_quote_char(c: char) -> bool {
489    c == '"' || c == '\''
490}
491
492/// One hit from the quote/delimiter scan: the quote, the delimiter bracketing
493/// it (absent in the fourth, delimiter-free pattern), and whether a space sat
494/// between the two.
495struct QuoteHit {
496    quote: char,
497    delim: Option<char>,
498    space: bool,
499}
500
501/// Port of `csv.Sniffer.sniff`, restricted to `delimiters` when given (rich-cli
502/// passes `",\t|;"`). `None` is CPython's `csv.Error`: no delimiter found.
503///
504/// `doublequote` is **not** sniffed; it is left at excel's `true`. CPython
505/// decides it with a regex that only fires when a `""` pair sits inside a
506/// quoted field containing neither the delimiter nor a newline, and when it
507/// does not fire `csv.reader` stops unescaping — `"he said ""hi"""` reads back
508/// as `he said "hi"""`. Reproducing that can only ever make a well-formed file
509/// render worse, and upstream's own fallback (`csv.get_dialect("excel")`, used
510/// for every file the sniffer rejects) already sets it true. The divergence is
511/// confined to files that use `""` escaping *and* defeat the heuristic, e.g. a
512/// `""` inside a multi-line cell: there we unescape and upstream does not.
513pub fn sniff(sample: &str, delimiters: Option<&[char]>) -> Option<Dialect> {
514    let data: Vec<char> = sample.chars().collect();
515    let (quote, mut delimiter, mut skipinitialspace) = guess_quote_and_delimiter(&data, delimiters);
516    if delimiter.is_none() {
517        let (guessed, spaced) = guess_delimiter(sample, delimiters);
518        delimiter = guessed;
519        skipinitialspace = spaced;
520    }
521    Some(Dialect {
522        delimiter: delimiter?,
523        // `_csv.reader` won't accept an empty quotechar, so upstream falls back
524        // to `"` when the scan found no quotes at all.
525        quotechar: quote.unwrap_or('"'),
526        doublequote: true,
527        skipinitialspace,
528    })
529}
530
531/// Port of `csv.Sniffer._guess_quote_and_delimiter`: look for text enclosed in
532/// two identical quotes that are themselves bracketed by the same character.
533///
534/// CPython uses four backreferencing regexes (`(?P=quote)`, `(?P=delim)`),
535/// which no Rust regex engine can express, so they are scanned by hand below.
536/// The first pattern that hits anywhere decides; the most frequent quote wins,
537/// and so does the most frequent delimiter seen beside it.
538fn guess_quote_and_delimiter(
539    data: &[char],
540    delimiters: Option<&[char]>,
541) -> (Option<char>, Option<char>, bool) {
542    let hits = quote_hits(data);
543    if hits.is_empty() {
544        return (None, None, false);
545    }
546
547    // Insertion-ordered tallies: Python's `max(dict, key=dict.get)` returns the
548    // FIRST key holding the maximum, so the order these were first seen in
549    // decides ties.
550    let mut quotes: Vec<(char, usize)> = Vec::new();
551    let mut delims: Vec<(char, usize)> = Vec::new();
552    let mut spaces = 0usize;
553    fn bump(table: &mut Vec<(char, usize)>, key: char) {
554        match table.iter_mut().find(|(existing, _)| *existing == key) {
555            Some(entry) => entry.1 += 1,
556            None => table.push((key, 1)),
557        }
558    }
559    for hit in &hits {
560        bump(&mut quotes, hit.quote);
561        // The fourth pattern has no delimiter group at all, so it contributes
562        // only a quote — upstream `continue`s past both tallies below.
563        let Some(delim) = hit.delim else { continue };
564        if delimiters.is_none_or(|allowed| allowed.contains(&delim)) {
565            bump(&mut delims, delim);
566        }
567        if hit.space {
568            spaces += 1;
569        }
570    }
571
572    let first_max = |table: &[(char, usize)]| -> Option<(char, usize)> {
573        let mut best: Option<(char, usize)> = None;
574        for &(key, count) in table {
575            if best.is_none_or(|(_, seen)| count > seen) {
576                best = Some((key, count));
577            }
578        }
579        best
580    };
581    let quotechar = first_max(&quotes).map(|(key, _)| key);
582    match first_max(&delims) {
583        Some((delim, count)) => (quotechar, Some(delim), count == spaces),
584        // A single column of quoted data: quotes but nothing bracketing them.
585        None => (quotechar, None, false),
586    }
587}
588
589/// One of CPython's four quote/delimiter patterns, scanned over the sample.
590type QuoteScan = fn(&[char]) -> Vec<QuoteHit>;
591
592/// Run CPython's four quote/delimiter patterns in order, returning the hits of
593/// the first that matches anything.
594fn quote_hits(data: &[char]) -> Vec<QuoteHit> {
595    let scans: [QuoteScan; 4] = [
596        scan_delim_quote_delim,
597        scan_line_quote_delim,
598        scan_delim_quote_line,
599        scan_line_quote_line,
600    ];
601    for scan in scans {
602        let hits = scan(data);
603        if !hits.is_empty() {
604            return hits;
605        }
606    }
607    Vec::new()
608}
609
610/// `(?P<delim>[^\w\n"'])(?P<space> ?)(?P<quote>["']).*?(?P=quote)(?P=delim)` —
611/// `,"some text",`. The ` ?` needs no backtracking: if the space is there and
612/// the next character is not a quote, dropping the space only offers the space
613/// itself as the quote, which it is not.
614fn scan_delim_quote_delim(data: &[char]) -> Vec<QuoteHit> {
615    scan_delim_quote(data, |data, quote, delim, from| {
616        let mut k = from;
617        while k + 1 < data.len() {
618            if data[k] == quote && data[k + 1] == delim {
619                return Some(k + 2);
620            }
621            k += 1;
622        }
623        None
624    })
625}
626
627/// `(?P<delim>[^\w\n"'])(?P<space> ?)(?P<quote>["']).*?(?P=quote)(?:$|\n)` —
628/// `,"some text"` at the end of a line.
629fn scan_delim_quote_line(data: &[char]) -> Vec<QuoteHit> {
630    scan_delim_quote(data, |data, quote, _delim, from| {
631        let mut k = from;
632        while k < data.len() {
633            if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
634                // `$` is zero-width and the engine prefers it, so the match ends
635                // at the closing quote either way.
636                return Some(k + 1);
637            }
638            k += 1;
639        }
640        None
641    })
642}
643
644/// The shared `<delim><space?><quote> … ` head of patterns one and three;
645/// `close` finds the closing quote and reports where the match ends.
646fn scan_delim_quote(
647    data: &[char],
648    close: fn(&[char], char, char, usize) -> Option<usize>,
649) -> Vec<QuoteHit> {
650    let mut hits = Vec::new();
651    let mut i = 0;
652    while i < data.len() {
653        if is_delimiter_char(data[i]) {
654            let delim = data[i];
655            let mut j = i + 1;
656            let mut space = false;
657            if j < data.len() && data[j] == ' ' {
658                space = true;
659                j += 1;
660            }
661            if j < data.len() && is_quote_char(data[j]) {
662                let quote = data[j];
663                if let Some(end) = close(data, quote, delim, j + 1) {
664                    hits.push(QuoteHit {
665                        quote,
666                        delim: Some(delim),
667                        space,
668                    });
669                    i = end;
670                    continue;
671                }
672            }
673        }
674        i += 1;
675    }
676    hits
677}
678
679/// `(?:^|\n)(?P<quote>["']).*?(?P=quote)(?P<delim>[^\w\n"'])(?P<space> ?)` —
680/// `"some text",` at the start of a line.
681fn scan_line_quote_delim(data: &[char]) -> Vec<QuoteHit> {
682    scan_line_quote(data, |data, quote, from| {
683        let mut k = from;
684        while k + 1 < data.len() {
685            if data[k] == quote && is_delimiter_char(data[k + 1]) {
686                let space = k + 2 < data.len() && data[k + 2] == ' ';
687                return Some((Some(data[k + 1]), space, if space { k + 3 } else { k + 2 }));
688            }
689            k += 1;
690        }
691        None
692    })
693}
694
695/// `(?:^|\n)(?P<quote>["']).*?(?P=quote)(?:$|\n)` — a whole line that is one
696/// quoted field, with no delimiter to learn from.
697fn scan_line_quote_line(data: &[char]) -> Vec<QuoteHit> {
698    scan_line_quote(data, |data, quote, from| {
699        let mut k = from;
700        while k < data.len() {
701            if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
702                return Some((None, false, k + 1));
703            }
704            k += 1;
705        }
706        None
707    })
708}
709
710/// The shared `(?:^|\n)<quote> … ` head of patterns two and four; `close`
711/// reports `(delimiter, saw a space, match end)`.
712#[allow(clippy::type_complexity)]
713fn scan_line_quote(
714    data: &[char],
715    close: fn(&[char], char, usize) -> Option<(Option<char>, bool, usize)>,
716) -> Vec<QuoteHit> {
717    let mut hits = Vec::new();
718    let mut i = 0;
719    while i < data.len() {
720        // `^` is zero-width at a line start; the `\n` branch consumes the
721        // newline and puts the quote on the character after it. The engine tries
722        // them in that order at each position.
723        let mut starts: Vec<usize> = Vec::new();
724        if i == 0 || data[i - 1] == '\n' {
725            starts.push(i);
726        }
727        if data[i] == '\n' {
728            starts.push(i + 1);
729        }
730        let mut advanced = false;
731        for quote_at in starts {
732            if quote_at >= data.len() || !is_quote_char(data[quote_at]) {
733                continue;
734            }
735            let quote = data[quote_at];
736            if let Some((delim, space, end)) = close(data, quote, quote_at + 1) {
737                hits.push(QuoteHit {
738                    quote,
739                    delim,
740                    space,
741                });
742                i = end;
743                advanced = true;
744                break;
745            }
746        }
747        if !advanced {
748            i += 1;
749        }
750    }
751    hits
752}
753
754/// Port of `csv.Sniffer._guess_delimiter`: the character whose per-line
755/// occurrence count is most consistent across the sample wins.
756fn guess_delimiter(sample: &str, delimiters: Option<&[char]>) -> (Option<char>, bool) {
757    // `filter(None, data.split('\n'))` — blank lines carry no evidence.
758    let data: Vec<&str> = sample.split('\n').filter(|line| !line.is_empty()).collect();
759    if data.is_empty() {
760        return (None, false);
761    }
762    /// CPython scans `[chr(c) for c in range(127)]` — 7-bit ASCII.
763    const ASCII: usize = 127;
764
765    let chunk_length = 10.min(data.len());
766    let mut iteration = 0usize;
767    // Per character, an insertion-ordered list of (occurrences on a line, how
768    // many lines had exactly that many) — upstream's "meta-frequency".
769    let mut char_frequency: Vec<Vec<(usize, usize)>> = vec![Vec::new(); ASCII];
770    // The winning (frequency, confidence) per character. Confidence can go
771    // negative once every competing frequency is subtracted, hence `isize`.
772    let mut modes: Vec<Option<(usize, isize)>> = vec![None; ASCII];
773    let mut delims: Vec<(char, (usize, isize))> = Vec::new();
774
775    let (mut start, mut end) = (0usize, chunk_length);
776    while start < data.len() {
777        iteration += 1;
778        for line in &data[start..end.min(data.len())] {
779            for (code, table) in char_frequency.iter_mut().enumerate() {
780                let c = code as u8 as char;
781                // Counted even when zero: a character absent from a line is
782                // evidence against it being the delimiter.
783                let freq = line.matches(c).count();
784                match table.iter_mut().find(|(seen, _)| *seen == freq) {
785                    Some(entry) => entry.1 += 1,
786                    None => table.push((freq, 1)),
787                }
788            }
789        }
790
791        for (code, items) in char_frequency.iter().enumerate() {
792            if items.len() == 1 && items[0].0 == 0 {
793                continue;
794            }
795            if items.len() > 1 {
796                // The first frequency with the highest count, less the sum of
797                // every other count.
798                let mut best = 0usize;
799                for (index, item) in items.iter().enumerate() {
800                    if item.1 > items[best].1 {
801                        best = index;
802                    }
803                }
804                let others: usize = items
805                    .iter()
806                    .enumerate()
807                    .filter(|(index, _)| *index != best)
808                    .map(|(_, item)| item.1)
809                    .sum();
810                modes[code] = Some((items[best].0, items[best].1 as isize - others as isize));
811            } else if let Some(&(freq, count)) = items.first() {
812                modes[code] = Some((freq, count as isize));
813            }
814        }
815
816        let total = (chunk_length * iteration).min(data.len()) as f64;
817        let mut consistency = 1.0f64;
818        let threshold = 0.9f64;
819        while delims.is_empty() && consistency >= threshold {
820            for (code, mode) in modes.iter().enumerate() {
821                let Some((freq, count)) = *mode else { continue };
822                let c = code as u8 as char;
823                if freq > 0
824                    && count > 0
825                    && (count as f64 / total) >= consistency
826                    && delimiters.is_none_or(|allowed| allowed.contains(&c))
827                {
828                    delims.push((c, (freq, count)));
829                }
830            }
831            consistency -= 0.01;
832        }
833
834        if delims.len() == 1 {
835            let delim = delims[0].0;
836            return (Some(delim), saw_space_after(data[0], delim));
837        }
838
839        start = end;
840        end += chunk_length;
841    }
842
843    if delims.is_empty() {
844        return (None, false);
845    }
846    if delims.len() > 1 {
847        for preferred in [',', '\t', ';', ' ', ':'] {
848            if delims.iter().any(|(c, _)| *c == preferred) {
849                return (Some(preferred), saw_space_after(data[0], preferred));
850            }
851        }
852    }
853    // `items = [(v, k) …]; items.sort()` — ordered by the mode, then the char.
854    let best = delims
855        .iter()
856        .max_by_key(|(c, mode)| (*mode, *c))
857        .expect("delims is non-empty");
858    (Some(best.0), saw_space_after(data[0], best.0))
859}
860
861/// Upstream's `skipinitialspace` test: every delimiter on the first line is
862/// followed by a space.
863fn saw_space_after(line: &str, delimiter: char) -> bool {
864    line.matches(delimiter).count() == line.matches(&format!("{delimiter} ")).count()
865}
866
867/// What `csv.Sniffer.has_header` decides a column holds.
868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
869enum ColumnType {
870    /// Every value so far parsed as a Python `complex`.
871    Number,
872    /// They did not, but all had this many characters.
873    Length(usize),
874}
875
876/// Whether a column is still in play, and what it has looked like so far.
877#[derive(Debug, Clone, Copy)]
878enum ColumnVote {
879    /// In `columnTypes` with the value `None`: no data row typed it yet.
880    Untyped,
881    Typed(ColumnType),
882    /// `del columnTypes[col]` — the column was inconsistent.
883    Dropped,
884}
885
886/// Port of `csv.Sniffer.has_header`: if a column is one consistent type in every
887/// row *except* the first, the first row is a label. Each column casts one vote.
888///
889/// The typing is CPython's current one — a single `complex` attempt, falling
890/// back to the string's length. The historical `for thisType in [int, float,
891/// complex]` loop is NOT equivalent: it makes a column mixing `10` and `9.5`
892/// read as inconsistent (int then float), so `price,note / 10,aa / 9.5,bbb`
893/// loses its header.
894///
895/// `None` is CPython's `csv.Error` escaping from the re-sniff, which upstream
896/// handles the same way as a failed `sniff`.
897pub fn has_header(sample: &str) -> Option<bool> {
898    // Note the missing `delimiters` argument: upstream re-sniffs here with the
899    // full candidate set, not the four it renders with.
900    let dialect = sniff(sample, None)?;
901    let mut rows = read_rows(sample, &dialect).into_iter();
902    let header = rows.next()?;
903    let columns = header.len();
904    let mut votes = vec![ColumnVote::Untyped; columns];
905
906    for (checked, row) in rows.enumerate() {
907        // An arbitrary cap, "to keep it sane" — the 22nd data row and beyond are
908        // never looked at, however inconsistent they are.
909        if checked > 20 {
910            break;
911        }
912        if row.len() != columns {
913            continue;
914        }
915        for (col, vote) in votes.iter_mut().enumerate() {
916            if matches!(vote, ColumnVote::Dropped) {
917                continue;
918            }
919            let this = if parses_as_complex(&row[col]) {
920                ColumnType::Number
921            } else {
922                ColumnType::Length(row[col].chars().count())
923            };
924            match vote {
925                ColumnVote::Untyped => *vote = ColumnVote::Typed(this),
926                ColumnVote::Typed(known) if *known != this => *vote = ColumnVote::Dropped,
927                _ => {}
928            }
929        }
930    }
931
932    let mut tally = 0isize;
933    for (col, vote) in votes.iter().enumerate() {
934        match vote {
935            ColumnVote::Dropped => {}
936            // `colType` is still `None`, and `None(header[col])` raises
937            // TypeError — which counts as "the header does not fit the column".
938            ColumnVote::Untyped => tally += 1,
939            ColumnVote::Typed(ColumnType::Length(len)) => {
940                if header[col].chars().count() == *len {
941                    tally -= 1;
942                } else {
943                    tally += 1;
944                }
945            }
946            ColumnVote::Typed(ColumnType::Number) => {
947                if parses_as_complex(&header[col]) {
948                    tally -= 1;
949                } else {
950                    tally += 1;
951                }
952            }
953        }
954    }
955    Some(tally > 0)
956}
957
958/// Whether Python's `complex(value)` would succeed — the type test at the heart
959/// of `has_header`.
960///
961/// `complex()` is much looser than "looks like a number": it takes surrounding
962/// whitespace, one level of parentheses, a sign, `inf`/`infinity`/`nan` in any
963/// case, an exponent, `_` digit separators and an imaginary `j` suffix. All of
964/// that is honoured. The single narrowing is the digit set — Python accepts any
965/// Unicode decimal digit, this accepts ASCII — which can only ever move one
966/// has-header vote, and only for a column written in non-ASCII numerals.
967fn parses_as_complex(value: &str) -> bool {
968    let trimmed = value.trim();
969    let body = match trimmed.strip_prefix('(') {
970        Some(inner) => match inner.strip_suffix(')') {
971            Some(inner) => inner.trim(),
972            None => return false,
973        },
974        None if trimmed.ends_with(')') => return false,
975        None => trimmed,
976    };
977    if body.is_empty() {
978        return false;
979    }
980    // `a`, `aj`, or `a±bj`. The split is the first sign that is not an
981    // exponent's, so `1e-5` stays one number.
982    let chars: Vec<char> = body.chars().collect();
983    let split = (1..chars.len())
984        .find(|&i| matches!(chars[i], '+' | '-') && !matches!(chars[i - 1], 'e' | 'E'));
985    match split {
986        Some(index) => {
987            let at: usize = chars[..index].iter().map(|c| c.len_utf8()).sum();
988            let (real, imaginary) = body.split_at(at);
989            is_float_literal(real) && is_imaginary_literal(imaginary)
990        }
991        None => is_float_literal(body) || is_imaginary_literal(body),
992    }
993}
994
995/// A Python float literal: optional sign, then `inf`/`infinity`/`nan`, or
996/// digits with an optional fraction and exponent.
997fn is_float_literal(value: &str) -> bool {
998    let body = value.strip_prefix(['+', '-']).unwrap_or(value);
999    if matches!(
1000        body.to_ascii_lowercase().as_str(),
1001        "inf" | "infinity" | "nan"
1002    ) {
1003        return true;
1004    }
1005    let chars: Vec<char> = body.chars().collect();
1006    let mut index = 0usize;
1007    let mut digits = 0usize;
1008    let take_digits = |index: &mut usize, digits: &mut usize| {
1009        while *index < chars.len() && (chars[*index].is_ascii_digit() || chars[*index] == '_') {
1010            if chars[*index].is_ascii_digit() {
1011                *digits += 1;
1012            }
1013            *index += 1;
1014        }
1015    };
1016    take_digits(&mut index, &mut digits);
1017    if index < chars.len() && chars[index] == '.' {
1018        index += 1;
1019        take_digits(&mut index, &mut digits);
1020    }
1021    if digits == 0 {
1022        return false;
1023    }
1024    if index < chars.len() && (chars[index] == 'e' || chars[index] == 'E') {
1025        index += 1;
1026        if index < chars.len() && matches!(chars[index], '+' | '-') {
1027            index += 1;
1028        }
1029        let mut exponent = 0usize;
1030        take_digits(&mut index, &mut exponent);
1031        if exponent == 0 {
1032            return false;
1033        }
1034    }
1035    index == chars.len()
1036}
1037
1038/// `<number>j`, or a bare `j`/`+j`/`-j` (which Python reads as ±1j).
1039fn is_imaginary_literal(value: &str) -> bool {
1040    let Some(body) = value.strip_suffix(['j', 'J']) else {
1041        return false;
1042    };
1043    matches!(body, "" | "+" | "-") || is_float_literal(body)
1044}
1045
1046/// Where [`read_rows`] is within a record. Port of `_csv.c`'s reader states
1047/// (its `EAT_CRNL` is unreachable here: universal newlines ran first).
1048#[derive(Clone, Copy, Debug)]
1049enum State {
1050    StartRecord,
1051    StartField,
1052    InField,
1053    InQuotedField,
1054    QuoteInQuotedField,
1055}
1056
1057/// Parse `content` into rows of fields with `dialect`. Port of `_csv.reader`'s
1058/// state machine, minus the escape character (no sniffed dialect sets one) and
1059/// `strict` (always off, as `QUOTE_MINIMAL` leaves it).
1060///
1061/// A blank line yields an **empty** row, as `csv.reader` does — upstream drops
1062/// those before building the table, where a `[""]` row would have drawn a
1063/// spurious blank line in it. `\r` never reaches here: the reader has already
1064/// applied Python's universal newlines.
1065pub fn read_rows(content: &str, dialect: &Dialect) -> Vec<Vec<String>> {
1066    // Strip a leading UTF-8 BOM so it doesn't cling to the first header cell.
1067    parse_rows(content.strip_prefix('\u{feff}').unwrap_or(content), dialect)
1068}
1069
1070/// [`read_rows`] without stripping a byte-order mark: a [`CsvSource`] parses
1071/// each record alone, and only the input's first may lose one.
1072fn parse_rows(content: &str, dialect: &Dialect) -> Vec<Vec<String>> {
1073    let mut rows: Vec<Vec<String>> = Vec::new();
1074    let mut row: Vec<String> = Vec::new();
1075    let mut field = String::new();
1076    let mut state = State::StartRecord;
1077
1078    for c in content.chars() {
1079        match state {
1080            State::StartRecord => {
1081                if c == '\n' {
1082                    rows.push(Vec::new());
1083                } else {
1084                    state = State::StartField;
1085                    // Re-dispatch this character as the start of a field.
1086                    read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows);
1087                }
1088            }
1089            _ => read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows),
1090        }
1091    }
1092    if !matches!(state, State::StartRecord) {
1093        row.push(std::mem::take(&mut field));
1094        row.shrink_to_fit();
1095        rows.push(std::mem::take(&mut row));
1096    }
1097    rows
1098}
1099
1100/// One character of [`read_rows`]'s state machine, for every state but
1101/// `StartRecord`.
1102fn read_csv_char(
1103    c: char,
1104    dialect: &Dialect,
1105    state: &mut State,
1106    field: &mut String,
1107    row: &mut Vec<String>,
1108    rows: &mut Vec<Vec<String>>,
1109) {
1110    let end_record = |field: &mut String, row: &mut Vec<String>, rows: &mut Vec<Vec<String>>| {
1111        row.push(std::mem::take(field));
1112        // Completed records keep only their fields, not Vec's spare growth slots.
1113        row.shrink_to_fit();
1114        rows.push(std::mem::take(row));
1115    };
1116    match state {
1117        State::StartRecord => unreachable!("handled by the caller"),
1118        State::StartField => {
1119            if c == '\n' {
1120                end_record(field, row, rows);
1121                *state = State::StartRecord;
1122            } else if c == dialect.quotechar {
1123                *state = State::InQuotedField;
1124            } else if c == ' ' && dialect.skipinitialspace {
1125                // Stay in StartField, swallowing the padding.
1126            } else if c == dialect.delimiter {
1127                row.push(std::mem::take(field));
1128            } else {
1129                field.push(c);
1130                *state = State::InField;
1131            }
1132        }
1133        State::InField => {
1134            if c == '\n' {
1135                end_record(field, row, rows);
1136                *state = State::StartRecord;
1137            } else if c == dialect.delimiter {
1138                row.push(std::mem::take(field));
1139                *state = State::StartField;
1140            } else {
1141                field.push(c);
1142            }
1143        }
1144        State::InQuotedField => {
1145            if c == dialect.quotechar {
1146                *state = if dialect.doublequote {
1147                    State::QuoteInQuotedField
1148                } else {
1149                    // Without doublequote the quote simply ends the quoted part;
1150                    // anything after it, quotes included, is literal.
1151                    State::InField
1152                };
1153            } else {
1154                field.push(c);
1155            }
1156        }
1157        State::QuoteInQuotedField => {
1158            if c == dialect.quotechar {
1159                field.push(c);
1160                *state = State::InQuotedField;
1161            } else if c == dialect.delimiter {
1162                row.push(std::mem::take(field));
1163                *state = State::StartField;
1164            } else if c == '\n' {
1165                end_record(field, row, rows);
1166                *state = State::StartRecord;
1167            } else {
1168                field.push(c);
1169                *state = State::InField;
1170            }
1171        }
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178
1179    #[test]
1180    fn read_rows_handles_quotes_and_delimiters() {
1181        let comma = Dialect::excel(',');
1182        // Quoted field containing the delimiter, and `""` escaping.
1183        let rows = read_rows("a,\"b,c\",d\n\"he said \"\"hi\"\"\",2\n", &comma);
1184        assert_eq!(
1185            rows,
1186            vec![
1187                vec!["a".to_string(), "b,c".to_string(), "d".to_string()],
1188                vec!["he said \"hi\"".to_string(), "2".to_string()],
1189            ]
1190        );
1191        // No trailing empty row after a final newline (`\r` is gone by now:
1192        // the reader applies universal newlines first).
1193        assert_eq!(read_rows("x\ny\n", &comma), vec![vec!["x"], vec!["y"]]);
1194        // Tab delimiter.
1195        assert_eq!(
1196            read_rows("a\tb", &Dialect::excel('\t')),
1197            vec![vec!["a", "b"]]
1198        );
1199        // A leading UTF-8 BOM is stripped, not glued to the first cell.
1200        assert_eq!(read_rows("\u{feff}a,b", &comma), vec![vec!["a", "b"]]);
1201        // A blank line is an EMPTY record, as `csv.reader` reports it — upstream
1202        // then drops it, where a `[""]` row would have drawn a blank table row.
1203        assert_eq!(
1204            read_rows("a,b\n\nc,d\n", &comma),
1205            vec![vec!["a", "b"], vec![], vec!["c", "d"]]
1206        );
1207        // `skipinitialspace` eats the padding after a delimiter, but only there.
1208        let padded = Dialect {
1209            skipinitialspace: true,
1210            ..Dialect::excel(',')
1211        };
1212        assert_eq!(read_rows("a,  b , c", &padded), vec![vec!["a", "b ", "c"]]);
1213    }
1214
1215    #[test]
1216    fn the_sniffer_finds_the_delimiter_and_the_header() {
1217        // Every expectation here was read out of CPython's own `csv.Sniffer`.
1218        let candidates = [',', '\t', '|', ';'];
1219        let sniffed =
1220            |sample: &str| sniff(sample, Some(&candidates)).map(|dialect| dialect.delimiter);
1221        assert_eq!(
1222            sniffed("name;age;city\nAlice;30;Paris\nBob;25;Lyon\n"),
1223            Some(';')
1224        );
1225        assert_eq!(sniffed("a|b|c\n1|2|3\n4|5|6\n"), Some('|'));
1226        assert_eq!(sniffed("a\tb\tc\n1\t2\t3\n4\t5\t6\n"), Some('\t'));
1227        assert_eq!(sniffed("a, b, c\n1, 2, 3\n4, 5, 6\n"), Some(','));
1228        // A quoted, multi-line cell: only the quote/delimiter scan can find the
1229        // comma here, because the line counts are inconsistent.
1230        assert_eq!(
1231            sniffed("name,bio\nAlice,\"line one\nline two\"\nBob,short\n"),
1232            Some(',')
1233        );
1234        // Ragged rows and a single column defeat the sniffer, exactly as they do
1235        // upstream — that is what the excel fallback is for.
1236        assert_eq!(sniffed("a,b,c\n1,2\n3,4,5,6\n"), None);
1237        assert_eq!(sniffed("alpha\nbeta\ngamma\n"), None);
1238
1239        assert_eq!(has_header("name,age\nAlice,30\nBob,25\n"), Some(true));
1240        // A column mixing an int and a float must stay ONE type. The historical
1241        // `for thisType in [int, float, complex]` loop reads it as inconsistent
1242        // and loses the header.
1243        assert_eq!(has_header("price,note\n10,aa\n9.5,bbb\n"), Some(true));
1244        assert_eq!(has_header("1,2,3\n4,5,6\n7,8,9\n"), Some(false));
1245    }
1246    #[test]
1247    fn the_reader_sniffs_pads_and_names_columns() {
1248        let rows = CsvReader::new()
1249            .delimiter(',')
1250            .header(true)
1251            .read("a,b\r\n1,2,3\r\n\r\n4\r\n")
1252            .unwrap();
1253        assert_eq!(rows.columns(), ["a", "b", "3"]);
1254        assert_eq!(
1255            rows.rows()[0],
1256            [Value::from("1"), Value::from("2"), Value::from("3")]
1257        );
1258        assert_eq!(rows.rows()[1], [Value::from("4"), Value::Null, Value::Null]);
1259        // No header: the first row is data.
1260        let rows = CsvReader::new().read("1,2,3\n4,5,6\n7,8,9\n").unwrap();
1261        assert_eq!(rows.columns(), ["1", "2", "3"]);
1262        assert_eq!(rows.len(), 3);
1263    }
1264
1265    #[test]
1266    fn the_reader_fails_where_rich_csv_does_unless_given_a_fallback() {
1267        let error = CsvReader::new().read("alpha\nbeta\n").unwrap_err();
1268        assert_eq!(error.to_string(), "Could not determine delimiter");
1269        let rows = CsvReader::new()
1270            .fallback(',')
1271            .read("alpha\nbeta\n")
1272            .unwrap();
1273        assert_eq!(rows.columns(), ["alpha"]);
1274        assert_eq!(rows.len(), 1);
1275        let rows = CsvReader::tsv().read("x\ty\n1\t2\n").unwrap();
1276        assert_eq!(rows.columns(), ["x", "y"]);
1277    }
1278
1279    /// A byte-order mark is stripped from the start of the input only:
1280    /// streaming used to strip one from the start of every record, so a
1281    /// cell after a lone `\r` lost it where `read` kept it.
1282    #[test]
1283    fn streaming_strips_only_a_leading_byte_order_mark() {
1284        let text = "\u{feff}a,b\n1,2\r\u{feff}x,y\n\u{feff}3,4\n";
1285        let read = CsvReader::new().header(true).read(text).unwrap();
1286        let streamed = CsvReader::new()
1287            .header(true)
1288            .source(text.as_bytes())
1289            .unwrap()
1290            .collect_rows()
1291            .unwrap();
1292        assert_eq!(streamed.columns(), ["a", "b"]);
1293        assert_eq!(streamed.rows()[1][0], Value::from("\u{feff}x"));
1294        assert_eq!(streamed.rows(), read.rows());
1295    }
1296
1297    /// `read` sniffs the text after a leading byte-order mark, as `source`
1298    /// does: it sniffed the mark too, so the two could choose different
1299    /// dialects (and headers) for the same input.
1300    #[test]
1301    fn read_and_source_sniff_past_a_byte_order_mark_alike() {
1302        for text in [
1303            "\u{feff}\r\nx|y;z\tw w\n",
1304            "\u{feff}\nname,age\nAda,36\n",
1305            "\u{feff}ab,c\nxy,1\nzw,2\n",
1306            // Only the first mark is stripped.
1307            "\u{feff}\u{feff}ab,c\nxy,1\n",
1308        ] {
1309            let read = CsvReader::new().fallback(',').read(text).unwrap();
1310            let streamed = CsvReader::new()
1311                .fallback(',')
1312                .source(text.as_bytes())
1313                .unwrap()
1314                .collect_rows()
1315                .unwrap();
1316            assert_eq!(streamed.columns(), read.columns(), "{text:?}");
1317            assert_eq!(streamed.rows(), read.rows(), "{text:?}");
1318        }
1319    }
1320}