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