Skip to main content

brep_kernel/text/
delimited.rs

1//! Delimited text (CSV, and the tab-separated text a spreadsheet puts on the
2//! clipboard): ONE reader, so a spreadsheet export pasted into the CAD app and
3//! the same file imported into the PLM read the same cells.
4//!
5//! The grammar is RFC 4180's, read generously where that costs nothing:
6//!
7//! * a record ends at `\n`, `\r\n` or a lone `\r`; a trailing line break does
8//!   not make an empty last record;
9//! * a field is quoted when its first character other than spaces is `"`
10//!   (the spaces are dropped); inside quotes `""` is a quote, and separators
11//!   and line breaks are text;
12//! * a `"` anywhere else in an unquoted field is an ordinary character
13//!   (`5" bolt`);
14//! * a blank line is a record of one empty field. Callers that skip blank
15//!   lines test [`Record::is_blank`].
16//!
17//! [`Strictness`] decides only what malformed quoting does. A [`Strictness::Lenient`]
18//! read (a paste: show what the user copied, never refuse it) closes an
19//! unterminated quote at the end of the text and keeps text after a closing
20//! quote; a [`Strictness::Strict`] read (an import that writes data) refuses
21//! both, naming the line.
22//!
23//! A byte-order mark is not part of the grammar: [`strip_bom`] removes one, and
24//! every caller that reads a file or a spreadsheet's clipboard calls it.
25
26/// What malformed quoting does (see the module documentation).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Strictness {
29    /// Never refuses: an unterminated quote ends at the end of the text, and
30    /// text after a closing quote is appended to the field.
31    Lenient,
32    /// Refuses an unterminated quote and text (other than spaces) after a
33    /// closing quote.
34    Strict,
35}
36
37/// One record, with the 1-based line it starts on.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Record {
40    pub line: usize,
41    pub fields: Vec<String>,
42}
43
44impl Record {
45    /// Whether every field is empty or only whitespace (a blank line, or a
46    /// line of bare separators).
47    pub fn is_blank(&self) -> bool {
48        self.fields.iter().all(|field| field.trim().is_empty())
49    }
50}
51
52/// `text` without a leading UTF-8 byte-order mark.
53pub fn strip_bom(text: &str) -> &str {
54    text.strip_prefix('\u{feff}').unwrap_or(text)
55}
56
57/// The separator a CSV-or-TSV text uses: a tab when its first non-blank line
58/// holds a tab and no comma (a paste from a spreadsheet), else a comma.
59pub fn sniff_separator(text: &str) -> char {
60    let first_line = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("");
61    if first_line.contains('\t') && !first_line.contains(',') {
62        '\t'
63    } else {
64        ','
65    }
66}
67
68/// The records of `text`, fields split on `separator`. A
69/// [`Strictness::Lenient`] read never returns `Err`.
70pub fn parse(text: &str, separator: char, strictness: Strictness) -> Result<Vec<Record>, String> {
71    let strict = strictness == Strictness::Strict;
72    let mut records = Vec::new();
73    let mut fields = Vec::new();
74    let mut field = String::new();
75    // Inside an open quote.
76    let mut quoted = false;
77    // The current field had a quoted part that has closed.
78    let mut closed = false;
79    let mut line = 1;
80    let mut record_line = 1;
81    let mut chars = text.chars().peekable();
82    while let Some(c) = chars.next() {
83        if quoted {
84            match c {
85                '"' if chars.peek() == Some(&'"') => {
86                    chars.next();
87                    field.push('"');
88                }
89                '"' => {
90                    quoted = false;
91                    closed = true;
92                }
93                '\n' => {
94                    line += 1;
95                    field.push('\n');
96                }
97                _ => field.push(c),
98            }
99            continue;
100        }
101        match c {
102            '"' if !closed && field.trim().is_empty() => {
103                field.clear();
104                quoted = true;
105            }
106            c if c == separator => {
107                fields.push(std::mem::take(&mut field));
108                closed = false;
109            }
110            '\r' | '\n' => {
111                if c == '\r' && chars.peek() == Some(&'\n') {
112                    chars.next();
113                }
114                fields.push(std::mem::take(&mut field));
115                records.push(Record { line: record_line, fields: std::mem::take(&mut fields) });
116                closed = false;
117                line += 1;
118                record_line = line;
119            }
120            c if closed && strict => {
121                if !c.is_whitespace() {
122                    return Err(format!("line {line}: text after a closing quote"));
123                }
124            }
125            _ => field.push(c),
126        }
127    }
128    if quoted && strict {
129        return Err(format!("line {record_line}: a quoted field is never closed"));
130    }
131    if !field.is_empty() || !fields.is_empty() || closed || quoted {
132        fields.push(field);
133        records.push(Record { line: record_line, fields });
134    }
135    Ok(records)
136}
137