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