Skip to main content

quarb_csv/
lib.rs

1//! CSV adapter for Quarb.
2//!
3//! Maps tabular data onto the arbor model the way a DataFrame maps
4//! onto a table: one node per record, one property per column.
5//!
6//! - The unnamed root has one `row` child per record, in file
7//!   order, so `/row` iterates the table, `/row[5]` is the fifth
8//!   record, and `//row @| count` is the row count.
9//! - Columns are properties, named by the header: `::age` reads the
10//!   `age` cell. Numeric-looking cells participate in comparisons
11//!   and arithmetic via the numeric reading (`::price * ::qty`).
12//! - An empty cell is a *missing value*: the property projects as
13//!   null, which numeric aggregates skip and null propagation
14//!   respects — pandas' `NaN` behavior without a sentinel.
15//! - Rows are leaves; there are no traits and no crosslinks.
16//! - `:::columns` on the root lists the column names; `:::n-rows`
17//!   counts records; `:::n-fields` on a row counts its cells;
18//!   `:::delimiter` and `:::decimal` on the root say what was
19//!   decided.
20//!
21//! The first record must be the header. Parsing is strict about
22//! ragged rows (a record with the wrong field count is an error).
23//!
24//! The delimiter is decided, never sniffed: `?delimiter=` on the
25//! mount, else an Excel `sep=X` first line (a declaration, not a
26//! row), else the extension's (tab for `.tsv`, comma otherwise). A
27//! header that never contains the decided delimiter but another
28//! candidate throughout is refused, naming the option. Numbers in
29//! cells read in the canonical form; a source that writes decimal
30//! commas declares it (`?decimal=comma`), and its cells with a
31//! reading under that convention arrive in canonical form.
32
33use quarb::number::Decimal;
34use quarb::{AstAdapter, NodeId, Value};
35
36#[derive(Debug, thiserror::Error)]
37pub enum CsvError {
38    #[error(transparent)]
39    Csv(#[from] csv::Error),
40    #[error(
41        "the header has no {decided:?} but {found:?} throughout; say the delimiter with \
42         ?delimiter={name}"
43    )]
44    Delimiter {
45        decided: char,
46        found: char,
47        name: &'static str,
48    },
49    #[error("unknown delimiter {0:?} (a character, or comma, semicolon, tab, pipe)")]
50    DelimiterLabel(String),
51    #[error("unknown decimal convention {0:?} (point or comma)")]
52    DecimalLabel(String),
53}
54
55/// How a delimited file is read: the options a mount carries
56/// (`?delimiter=`, `?decimal=`) and the extension's default.
57#[derive(Clone, Copy, Debug, Default)]
58pub struct CsvOptions {
59    /// `?delimiter=`, decided before anything is read.
60    pub delimiter: Option<u8>,
61    /// `?decimal=`; `point` when the source says nothing.
62    pub decimal: Decimal,
63}
64
65impl CsvOptions {
66    /// Read the two options off their labels: a delimiter is one
67    /// character or `comma` / `semicolon` / `tab` / `pipe`; the
68    /// decimal convention is `point` or `comma`.
69    pub fn with_delimiter_label(mut self, label: &str) -> Result<Self, CsvError> {
70        self.delimiter = Some(delimiter_label(label)?);
71        Ok(self)
72    }
73
74    pub fn with_decimal_label(mut self, label: &str) -> Result<Self, CsvError> {
75        self.decimal =
76            Decimal::from_label(label).ok_or_else(|| CsvError::DecimalLabel(label.into()))?;
77        Ok(self)
78    }
79}
80
81/// A delimiter by its label.
82pub fn delimiter_label(label: &str) -> Result<u8, CsvError> {
83    match label {
84        "comma" => Ok(b','),
85        "semicolon" => Ok(b';'),
86        "tab" => Ok(b'\t'),
87        "pipe" => Ok(b'|'),
88        _ => {
89            let mut chars = label.chars();
90            match (chars.next(), chars.next()) {
91                (Some(c), None) if c.is_ascii() && c != '"' && c != '\n' => Ok(c as u8),
92                _ => Err(CsvError::DelimiterLabel(label.into())),
93            }
94        }
95    }
96}
97
98fn delimiter_name(d: u8) -> &'static str {
99    match d {
100        b',' => "comma",
101        b';' => "semicolon",
102        b'\t' => "tab",
103        b'|' => "pipe",
104        _ => "CHAR",
105    }
106}
107
108/// A Quarb adapter over a parsed CSV table.
109pub struct CsvAdapter {
110    /// Column names, from the header record.
111    columns: Vec<String>,
112    /// One entry per record, each with one cell per column.
113    rows: Vec<Vec<String>>,
114    /// The delimiter as decided.
115    delimiter: u8,
116    /// The decimal convention the source declared.
117    decimal: Decimal,
118}
119
120impl CsvAdapter {
121    /// Parse comma-separated `text` (header row required) and build
122    /// the adapter.
123    pub fn parse(text: &str) -> Result<Self, csv::Error> {
124        Self::parse_with_delimiter(text, b',')
125    }
126
127    /// Parse with an explicit field delimiter (e.g. `b'\t'` for TSV).
128    pub fn parse_with_delimiter(text: &str, delimiter: u8) -> Result<Self, csv::Error> {
129        match Self::parse_declared(text, delimiter, CsvOptions::default()) {
130            Ok(a) => Ok(a),
131            Err(CsvError::Csv(e)) => Err(e),
132            // Without the option the refusal has nowhere to go:
133            // read as the caller asked, and let the ragged rows say
134            // so (the message before this crate decided anything).
135            Err(_) => Self::read(text, delimiter, Decimal::Point),
136        }
137    }
138
139    /// Parse under the decided delimiter: `?delimiter=` on the
140    /// mount, else an Excel `sep=X` first line, else `by_extension`
141    /// (tab for `.tsv`, comma otherwise). A header that never holds
142    /// the decided delimiter while another candidate runs through
143    /// every record is refused, naming the option.
144    pub fn parse_declared(
145        text: &str,
146        by_extension: u8,
147        options: CsvOptions,
148    ) -> Result<Self, CsvError> {
149        let (text, declared) = match sep_line(text) {
150            Some((d, rest)) => (rest, Some(d)),
151            None => (text, None),
152        };
153        let delimiter = options.delimiter.or(declared).unwrap_or(by_extension);
154        let header = text.lines().next().unwrap_or("");
155        if !header.as_bytes().contains(&delimiter) {
156            for candidate in [b',', b';', b'\t', b'|'] {
157                if candidate != delimiter
158                    && header.as_bytes().contains(&candidate)
159                    && text
160                        .lines()
161                        .filter(|l| !l.trim().is_empty())
162                        .all(|l| l.as_bytes().contains(&candidate))
163                {
164                    return Err(CsvError::Delimiter {
165                        decided: delimiter as char,
166                        found: candidate as char,
167                        name: delimiter_name(candidate),
168                    });
169                }
170            }
171        }
172        Ok(Self::read(text, delimiter, options.decimal)?)
173    }
174
175    fn read(text: &str, delimiter: u8, decimal: Decimal) -> Result<Self, csv::Error> {
176        let mut reader = csv::ReaderBuilder::new()
177            .delimiter(delimiter)
178            .from_reader(text.as_bytes());
179        let columns: Vec<String> = reader
180            .headers()?
181            .iter()
182            .map(|h| h.trim().to_string())
183            .collect();
184        let mut rows = Vec::new();
185        for record in reader.records() {
186            let record = record?;
187            rows.push(record.iter().map(|c| c.to_string()).collect());
188        }
189        Ok(CsvAdapter {
190            columns,
191            rows,
192            delimiter,
193            decimal,
194        })
195    }
196
197    /// A locator path to `node`, like `/row[3]`, for rendering.
198    pub fn locator(&self, node: NodeId) -> String {
199        if node.0 == 0 {
200            "/".to_string()
201        } else {
202            format!("/row[{}]", node.0)
203        }
204    }
205
206    /// The cell's text as the engine reads it: canonical numeric
207    /// form under `?decimal=comma` when the cell reads as a number
208    /// that way, the text as written otherwise.
209    fn canonical(&self, cell: &str) -> String {
210        if self.decimal == Decimal::Comma
211            && let Some(c) = quarb::number::canonical(cell.trim(), Decimal::Comma)
212        {
213            return c;
214        }
215        cell.to_string()
216    }
217
218    fn cell(&self, node: NodeId, column: &str) -> Option<&str> {
219        // The root (id 0) has no cells; guard the 1-based index so
220        // it cannot underflow when property() reaches here at root.
221        let row = self.rows.get((node.0 as usize).checked_sub(1)?)?;
222        let idx = self.columns.iter().position(|c| c == column)?;
223        // An empty cell is a missing value, not an empty string.
224        row.get(idx).map(|c| c.as_str()).filter(|c| !c.is_empty())
225    }
226}
227
228impl AstAdapter for CsvAdapter {
229    /// The arena knows how many nodes a step reaches; nothing is read.
230    fn estimate(&self, scope: &[&str], plan: &quarb::StepPlan<'_>) -> Option<quarb::Estimate> {
231        quarb::adapter::arena_estimate(self, scope, plan, quarb::Latency::Memory, None)
232    }
233
234    /// This source defines no references to resolve.
235    fn resolves_references(&self) -> bool {
236        false
237    }
238
239    fn root(&self) -> NodeId {
240        NodeId(0)
241    }
242
243    /// In memory: an unscoped reverse resolution or value match may
244    /// walk the arbor.
245    fn walks_for_references(&self) -> bool {
246        true
247    }
248
249    fn children(&self, node: NodeId) -> Vec<NodeId> {
250        if node.0 == 0 {
251            (1..=self.rows.len() as u64).map(NodeId).collect()
252        } else {
253            Vec::new()
254        }
255    }
256
257    fn name(&self, node: NodeId) -> Option<String> {
258        if node.0 == 0 {
259            None
260        } else {
261            Some("row".to_string())
262        }
263    }
264
265    fn parent(&self, node: NodeId) -> Option<NodeId> {
266        if node.0 == 0 { None } else { Some(NodeId(0)) }
267    }
268
269    /// A column cell, by header name. Empty cells are missing. Under
270    /// a declared decimal convention a cell with a numeric reading
271    /// arrives in canonical form, so every reading of it downstream
272    /// (an aggregate, a comparison, a pushdown) is the number the
273    /// source meant.
274    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
275        self.cell(node, name).map(|c| Value::Str(self.canonical(c)))
276    }
277
278    /// A row's cells by header, in column order (an empty cell
279    /// missing): the record a row stands for where one is wanted
280    /// (`@| #`, `| json`).
281    fn properties(&self, node: NodeId) -> Vec<(String, Value)> {
282        if node.0 == 0 {
283            return Vec::new();
284        }
285        self.columns
286            .iter()
287            .filter_map(|c| {
288                self.cell(node, c)
289                    .map(|v| (c.clone(), Value::Str(self.canonical(v))))
290            })
291            .collect()
292    }
293
294    /// `:::columns` (root), `:::n-rows` (root), `:::delimiter` and
295    /// `:::decimal` (root, as decided), `:::n-fields` (row), and any
296    /// column by name.
297    /// The `:::` keys this adapter answers (`:meta` lists the ones
298    /// a node answers).
299    fn metadata_catalogue(&self) -> &'static [&'static str] {
300        &["delimiter", "decimal", "columns", "n-rows", "n-fields"]
301    }
302
303    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
304        if node.0 == 0 {
305            match key {
306                "delimiter" => return Some(Value::Str((self.delimiter as char).to_string())),
307                "decimal" => {
308                    return Some(Value::Str(
309                        match self.decimal {
310                            Decimal::Point => "point",
311                            Decimal::Comma => "comma",
312                        }
313                        .into(),
314                    ));
315                }
316                _ => {}
317            }
318            return match key {
319                "columns" => Some(Value::list(
320                    self.columns.iter().map(|c| Value::Str(c.clone())).collect(),
321                )),
322                "n-rows" => Some(Value::Int(self.rows.len() as i64)),
323                _ => None,
324            };
325        }
326        match key {
327            "n-fields" => self
328                .rows
329                .get(node.0 as usize - 1)
330                .map(|r| Value::Int(r.len() as i64)),
331            other => self.property(node, other),
332        }
333    }
334}
335
336/// An Excel `sep=X` first line: the declaration, and the text after
337/// it. Not a row.
338fn sep_line(text: &str) -> Option<(u8, &str)> {
339    let rest = text.strip_prefix("sep=")?;
340    let mut chars = rest.chars();
341    let d = chars.next()?;
342    let after = chars.as_str();
343    let after = after
344        .strip_prefix("\r\n")
345        .or_else(|| after.strip_prefix('\n'))?;
346    (d.is_ascii() && d != '\n').then_some((d as u8, after))
347}