Skip to main content

datui_lib/formats/
delimited_spec.rs

1//! Format specs of `kind = "delimited"`: the reading options for a family of CSV-like
2//! files, with header rows that have roles (names, units), a metadata line, and a few
3//! derived columns.
4//!
5//! A spec is parsed in [`crate::formats`], beside the binary specs, and matched by the
6//! same `match`. Reading it is the CSV reader's: [`Delimited::apply`] sets the
7//! dialect options a matched file is opened with, and [`Delimited::derive`] adds the
8//! derived columns to the frame. The only lines read apart from the scan are the
9//! header lines and the metadata line ([`Delimited::facts`]).
10
11use crate::formats::{Chosen, Spec};
12use polars::prelude::*;
13use std::io::BufRead;
14use std::sync::Arc;
15
16/// What the lines before the data hold, by role.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct HeaderRows {
19    /// The 1-based lines whose pieces, joined, name the columns.
20    pub name: Vec<usize>,
21    /// The line that gives each column's unit.
22    pub unit: Option<usize>,
23}
24
25impl HeaderRows {
26    /// The last header line: the data starts after it.
27    pub fn last(&self) -> usize {
28        self.name
29            .iter()
30            .copied()
31            .chain(self.unit)
32            .max()
33            .unwrap_or(0)
34    }
35}
36
37pub use crate::formats::column_types::{Derived, DerivedKind};
38
39/// A delimited spec's reading options. Each one left out keeps what the command line
40/// or the config says.
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct Delimited {
43    pub delimiter: Option<u8>,
44    pub comment_char: Option<String>,
45    pub skip_initial_space: Option<bool>,
46    pub header_rows: Option<HeaderRows>,
47    pub header_join: Option<String>,
48    pub metadata_line: Option<usize>,
49    pub null_values: Vec<String>,
50    pub skip_lines: Option<usize>,
51    pub columns: Vec<Derived>,
52    /// Columns of the file read as a declared type, by name.
53    pub types: Vec<(String, crate::formats::column_types::ColumnType)>,
54}
55
56/// The `key="value"` line at the top of a file.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Metadata {
59    /// The line as it is in the file, without its comment prefix and line break.
60    pub raw: String,
61    /// The leading item with no `=`, such as `device_info` in `#device_info, a="1"`.
62    pub title: Option<String>,
63    /// Key and value, in the order the line has them. Empty when the line does not
64    /// parse, and then `raw` is shown.
65    pub pairs: Vec<(String, String)>,
66}
67
68/// What a file's header lines say besides its column names.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct HeadFacts {
71    /// Each column's unit, by the name the column is shown with.
72    pub units: Vec<(String, String)>,
73    pub metadata: Option<Metadata>,
74}
75
76/// What a read through a delimited spec found, as the open carries it to the dataset.
77#[derive(Debug, Clone)]
78pub struct DelimitedRead {
79    pub spec: Arc<Spec>,
80    pub by: Chosen,
81    /// The other specs that matched as well as `spec`, by the same rule.
82    pub also: Vec<String>,
83    /// Each column's unit, by the name the column is shown with.
84    pub units: Vec<(String, String)>,
85    pub metadata: Option<Metadata>,
86    /// The file the header lines were read from, when more than one file was read.
87    pub facts_from: Option<String>,
88}
89
90impl DelimitedRead {
91    /// The read before its header lines are read: the spec and why.
92    pub fn chosen(spec: Arc<Spec>, by: Chosen, also: Vec<String>) -> Self {
93        Self {
94            spec,
95            by,
96            also,
97            units: Vec::new(),
98            metadata: None,
99            facts_from: None,
100        }
101    }
102
103    pub fn delimited(&self) -> &Delimited {
104        self.spec
105            .delimited
106            .as_deref()
107            .expect("a delimited read has a delimited spec")
108    }
109
110    /// The unit of the column shown as `column`.
111    pub fn unit_of(&self, column: &str) -> Option<&str> {
112        self.units
113            .iter()
114            .find(|(name, _)| name == column)
115            .map(|(_, unit)| unit.as_str())
116    }
117
118    /// The dataset's notes about the read: which spec read it and why, and what else
119    /// matched.
120    pub fn notes(&self) -> Vec<crate::notes::Note> {
121        let note = |summary: String, scope: String| crate::notes::Note {
122            summary,
123            scope,
124            read_as_text: None,
125            passed_over: None,
126        };
127        let from = self
128            .spec
129            .path
130            .as_ref()
131            .map_or_else(|| "the spec".to_string(), |p| p.display().to_string());
132        let mut notes = vec![note(
133            format!(
134                "read as {}, {}",
135                self.spec.name,
136                crate::formats::chosen_words(&self.spec, self.by)
137            ),
138            format!("from {from}"),
139        )];
140        if !self.also.is_empty() {
141            notes.push(note(
142                format!(
143                    "{} also {} this file",
144                    self.also.join(", "),
145                    if self.also.len() == 1 {
146                        "matches"
147                    } else {
148                        "match"
149                    }
150                ),
151                format!("by {}", self.by.words()),
152            ));
153        }
154        notes
155    }
156}
157
158/// The most lines [`Delimited::facts`] reads: the header lines and the metadata line
159/// sit at the top of a file.
160pub const MAX_HEAD_LINE: usize = 1000;
161
162impl Delimited {
163    /// `options` with the spec's dialect: each option the spec gives replaces what the
164    /// config said, but not a flag typed on the command line (#651), and the file is
165    /// read as CSV unless its name says TSV or PSV.
166    pub fn apply(&self, options: &mut crate::OpenOptions) {
167        let typed = options.typed_dialect;
168        if let Some(d) = self.delimiter.filter(|_| !typed.delimiter) {
169            options.delimiter = Some(d);
170        }
171        if let Some(c) = self.comment_char.as_ref().filter(|_| !typed.comment_char) {
172            options.comment_char = Some(c.clone());
173        }
174        if let Some(s) = self
175            .skip_initial_space
176            .filter(|_| !typed.skip_initial_space)
177        {
178            options.skip_initial_space = s;
179        }
180        if let Some(rows) = self.header_rows.as_ref().filter(|_| !typed.header_rows) {
181            options.header_rows = rows.name.clone();
182            // The unit line is a header line too: the data starts after the last one.
183            options.skip_lines = Some(options.skip_lines.unwrap_or(0).max(rows.last()));
184        }
185        if let Some(join) = &self.header_join {
186            options.header_join = join.clone();
187        }
188        if let Some(n) = self.skip_lines.filter(|_| !typed.skip_lines) {
189            options.skip_lines = Some(options.skip_lines.unwrap_or(0).max(n));
190        }
191        if !self.null_values.is_empty() {
192            // Applied again to a read again's options, so each value once.
193            let values = options.null_values.get_or_insert_with(Vec::new);
194            for value in &self.null_values {
195                if !values.contains(value) {
196                    values.push(value.clone());
197                }
198            }
199        }
200        if options
201            .format
202            .is_none_or(|f| crate::FileFormat::separator(f).is_none())
203        {
204            options.format = Some(crate::FileFormat::Csv);
205        }
206    }
207
208    /// The lines [`Self::facts`] reads, 1-based.
209    pub fn head_lines(&self) -> Vec<usize> {
210        let mut lines: Vec<usize> = self
211            .header_rows
212            .iter()
213            .flat_map(|rows| rows.name.iter().copied().chain(rows.unit))
214            .chain(self.metadata_line)
215            .collect();
216        lines.sort_unstable();
217        lines.dedup();
218        lines
219    }
220
221    /// The units and the metadata from the top of the text `source` holds, split on
222    /// `separator`. Only the lines the spec names are read.
223    pub fn facts(
224        &self,
225        source: impl BufRead,
226        separator: u8,
227        join: &str,
228    ) -> color_eyre::Result<HeadFacts> {
229        let wanted = self.head_lines();
230        if wanted.is_empty() {
231            return Ok(HeadFacts::default());
232        }
233        let lines = crate::formats::csv_dialect::named_lines(source, &wanted)?;
234        Ok(self.facts_of(&wanted, &lines, separator, join))
235    }
236
237    /// [`Self::facts`] from `lines`, the lines `wanted` names as
238    /// [`crate::formats::csv_dialect::named_lines`] read them; a line not among them is blank.
239    pub fn facts_of(
240        &self,
241        wanted: &[usize],
242        lines: &[Vec<u8>],
243        separator: u8,
244        join: &str,
245    ) -> HeadFacts {
246        let line = |n: usize| -> &[u8] {
247            wanted
248                .iter()
249                .position(|&w| w == n)
250                .map_or(&[][..], |i| lines[i].as_slice())
251        };
252        let comment = self.comment_char.as_deref();
253        let mut units = Vec::new();
254        if let Some(rows) = &self.header_rows
255            && let Some(unit) = rows.unit
256        {
257            let names = crate::formats::csv_dialect::names_of(
258                lines, wanted, &rows.name, join, separator, comment,
259            );
260            let raw: Vec<PlSmallStr> = (1..=names.len())
261                .map(|i| format!("column_{i}").into())
262                .collect();
263            let shown = crate::formats::csv_dialect::shown_names(&raw, Some(&names));
264            let unit_fields =
265                crate::formats::csv_dialect::header_fields(line(unit), unit, separator, comment);
266            for (name, unit) in shown.into_iter().zip(unit_fields) {
267                // A derived column of the same name takes the column's place, and the
268                // unit was the text's.
269                if !unit.is_empty() && !self.columns.iter().any(|d| d.name == name) {
270                    units.push((name, unit));
271                }
272            }
273        }
274        let metadata = self.metadata_line.map(|n| {
275            let mut text = line(n);
276            if n == 1 {
277                text = text.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(text);
278            }
279            let text = String::from_utf8_lossy(text);
280            let text = text.trim_end_matches(['\n', '\r']);
281            let text = comment.and_then(|c| text.strip_prefix(c)).unwrap_or(text);
282            parse_metadata(text)
283        });
284        HeadFacts { units, metadata }
285    }
286
287    /// `lf` with the derived columns, each before the first column it is made from.
288    /// Lazy: nothing is read.
289    pub fn derive(&self, mut lf: LazyFrame) -> PolarsResult<LazyFrame> {
290        if self.columns.is_empty() {
291            return Ok(lf);
292        }
293        let schema = lf.collect_schema()?;
294        let mut order: Vec<PlSmallStr> = schema.iter_names().cloned().collect();
295        let mut exprs = Vec::with_capacity(self.columns.len());
296        for derived in &self.columns {
297            for from in &derived.from {
298                if !schema.contains(from) {
299                    polars_bail!(
300                        ColumnNotFound: "\"{}\" is made from \"{from}\", which the file has no column of",
301                        derived.name
302                    );
303                }
304            }
305            let name = PlSmallStr::from(derived.name.as_str());
306            if !order.contains(&name) {
307                let at = order
308                    .iter()
309                    .position(|c| c.as_str() == derived.from[0])
310                    .unwrap_or(order.len());
311                order.insert(at, name.clone());
312            }
313            exprs.push(derived.expr().alias(name));
314        }
315        Ok(lf
316            .with_columns(exprs)
317            .select(order.into_iter().map(col).collect::<Vec<_>>()))
318    }
319}
320
321/// `read` with the units and metadata from the header lines of the first of
322/// `paths`, read in `options`' dialect.
323pub fn read_facts(
324    read: &DelimitedRead,
325    paths: &[std::path::PathBuf],
326    options: &crate::OpenOptions,
327) -> color_eyre::Result<DelimitedRead> {
328    let separator = options.separator_or(
329        options
330            .format
331            .and_then(crate::FileFormat::separator)
332            .unwrap_or(b','),
333    );
334    let facts_of = |file: &std::path::Path| -> color_eyre::Result<HeadFacts> {
335        let compression = options
336            .compression
337            .or_else(|| crate::CompressionFormat::from_extension(file));
338        let source = crate::formats::readers::csv::text_source(file, compression)
339            .map_err(|e| crate::error_display::in_file(file, e.into()))?;
340        read.delimited()
341            .facts(source, separator, &options.header_join)
342            .map_err(|e| crate::error_display::in_file(file, e))
343    };
344    // From the first file with a header: of several, one with nothing in it is
345    // passed over by the read too.
346    let mut found = None;
347    for file in paths {
348        match facts_of(file) {
349            Err(e) if paths.len() > 1 && crate::formats::csv_dialect::is_blank_file(&e) => continue,
350            facts => {
351                found = Some((file, facts?));
352                break;
353            }
354        }
355    }
356    let Some((file, HeadFacts { units, metadata })) = found else {
357        return Ok(read.clone());
358    };
359    Ok(DelimitedRead {
360        units,
361        metadata,
362        facts_from: (paths.len() > 1).then(|| {
363            file.file_name().map_or_else(
364                || file.display().to_string(),
365                |n| n.to_string_lossy().into_owned(),
366            )
367        }),
368        ..read.clone()
369    })
370}
371
372/// What `datui formats check` prints for a delimited spec and, given `file`, the
373/// file's metadata, units and first `rows` rows as read with `base`, the command
374/// line's options, whose typed dialect flags win over the spec's.
375pub fn check(
376    spec: &Arc<Spec>,
377    file: Option<&std::path::Path>,
378    rows: usize,
379    base: &crate::OpenOptions,
380) -> Result<String, String> {
381    let delimited = spec
382        .delimited
383        .as_deref()
384        .ok_or_else(|| format!("error: {} is not a delimited spec\n", spec.name))?;
385    let mut out = String::new();
386    out.push_str(&format!("  delimited: {}\n", delimited.summary()));
387    for derived in &delimited.columns {
388        out.push_str(&format!(
389            "  {} = {} from {}\n",
390            derived.name,
391            derived.kind.name(),
392            derived.from.join(", ")
393        ));
394    }
395    for (name, ty) in &delimited.types {
396        let format = ty
397            .format
398            .as_ref()
399            .map_or_else(String::new, |f| format!(", format {f:?}"));
400        out.push_str(&format!("  {name}: {}{format}\n", ty.name()));
401    }
402    let typed = typed_summary(base);
403    if !typed.is_empty() {
404        out.push_str(&format!("  command line, over the spec: {typed}\n"));
405    }
406    let Some(file) = file else {
407        return Ok(out);
408    };
409    let fail = |out: &str, e: &color_eyre::Report| {
410        let said = crate::error_display::user_message_from_report(e, Some(file));
411        format!("{out}error: {said}\n")
412    };
413    let mut options = crate::OpenOptions {
414        format: crate::FileFormat::from_path(file),
415        ..base.clone()
416    };
417    delimited.apply(&mut options);
418    let chosen = DelimitedRead::chosen(spec.clone(), Chosen::SpecFile, Vec::new());
419    options.delimited = Some(Arc::new(chosen.clone()));
420    let read = read_facts(&chosen, &[file.to_path_buf()], &options).map_err(|e| fail(&out, &e))?;
421    if let Some(metadata) = &read.metadata {
422        if metadata.pairs.is_empty() {
423            out.push_str(&format!(
424                "metadata (not key=value pairs): {}\n",
425                metadata.raw
426            ));
427        } else {
428            let pairs: Vec<String> = metadata
429                .pairs
430                .iter()
431                .map(|(k, v)| format!("{k} = {v}"))
432                .collect();
433            let title = metadata
434                .title
435                .as_ref()
436                .map_or_else(String::new, |t| format!("{t}: "));
437            out.push_str(&format!("metadata: {title}{}\n", pairs.join(", ")));
438        }
439    }
440    if !read.units.is_empty() {
441        let units: Vec<String> = read
442            .units
443            .iter()
444            .map(|(column, unit)| format!("{column} = {unit}"))
445            .collect();
446        out.push_str(&format!("units: {}\n", units.join(", ")));
447    }
448    let separator = options.separator_or(b',');
449    let read = crate::formats::readers::csv::read_delimited(
450        file,
451        separator,
452        &options,
453        &Default::default(),
454    )
455    .map_err(|e| fail(&out, &crate::error_display::in_file(file, e)))?;
456    let df = crate::formats::readers::polars::resolved(read.lf)
457        .map_err(|e| fail(&out, &crate::error_display::in_file(file, e)))?
458        .limit(rows as IdxSize)
459        .collect()
460        .map_err(|e| fail(&out, &crate::error_display::in_file(file, e.into())))?;
461    out.push_str(&crate::formats::text_table(&df));
462    Ok(out)
463}
464
465/// The dialect flags typed on the command line, in a line: `delimiter ','`.
466fn typed_summary(options: &crate::OpenOptions) -> String {
467    let typed = options.typed_dialect;
468    let mut said = Vec::new();
469    if let Some(d) = options.delimiter.filter(|_| typed.delimiter) {
470        said.push(format!("delimiter {:?}", d as char));
471    }
472    if let Some(c) = options.comment_char.as_ref().filter(|_| typed.comment_char) {
473        said.push(format!("comment {c:?}"));
474    }
475    if typed.skip_initial_space {
476        said.push(format!("skip initial space {}", options.skip_initial_space));
477    }
478    if typed.header_rows {
479        let rows: Vec<String> = options.header_rows.iter().map(usize::to_string).collect();
480        said.push(format!("header rows {}", rows.join(",")));
481    }
482    if let Some(n) = options.skip_lines.filter(|_| typed.skip_lines) {
483        said.push(format!("skip lines {n}"));
484    }
485    said.join(", ")
486}
487
488impl Delimited {
489    /// The options the spec sets, in a line: `header line 3, unit line 2, ...`.
490    pub fn summary(&self) -> String {
491        let lines = |rows: &[usize]| {
492            let rows: Vec<String> = rows.iter().map(usize::to_string).collect();
493            rows.join(" + ")
494        };
495        let mut said = Vec::new();
496        if let Some(d) = self.delimiter {
497            said.push(format!("delimiter {:?}", d as char));
498        }
499        if let Some(rows) = &self.header_rows {
500            said.push(format!("names on line {}", lines(&rows.name)));
501            if let Some(unit) = rows.unit {
502                said.push(format!("units on line {unit}"));
503            }
504        }
505        if let Some(n) = self.metadata_line {
506            said.push(format!("metadata on line {n}"));
507        }
508        if let Some(c) = &self.comment_char {
509            said.push(format!("comments start {c:?}"));
510        }
511        if self.skip_initial_space == Some(true) {
512            said.push("skip initial space".to_string());
513        }
514        if let Some(n) = self.skip_lines {
515            said.push(format!("skip {n} lines"));
516        }
517        if !self.null_values.is_empty() {
518            said.push(format!("null {}", self.null_values.join(", ")));
519        }
520        if said.is_empty() {
521            "CSV with a header line".to_string()
522        } else {
523            said.join(", ")
524        }
525    }
526}
527
528/// `name, key="value", key=value`: the items separated by commas outside quotes. A
529/// first item with no `=` is the title. A line with anything else, or with no pairs,
530/// is kept raw only.
531pub fn parse_metadata(line: &str) -> Metadata {
532    let raw = line.trim().to_string();
533    let mut items = Vec::new();
534    let mut item = String::new();
535    let mut quoted = false;
536    for c in line.chars() {
537        match c {
538            '"' => {
539                quoted = !quoted;
540                item.push(c);
541            }
542            ',' if !quoted => items.push(std::mem::take(&mut item)),
543            c => item.push(c),
544        }
545    }
546    items.push(item);
547    let unparsed = |raw: String| Metadata {
548        raw,
549        title: None,
550        pairs: Vec::new(),
551    };
552    if quoted {
553        return unparsed(raw);
554    }
555    let mut title = None;
556    let mut pairs = Vec::new();
557    for (i, item) in items.iter().map(|s| s.trim()).enumerate() {
558        if item.is_empty() {
559            continue;
560        }
561        match item.split_once('=') {
562            Some((key, value)) => {
563                let key = key.trim();
564                if key.is_empty() || key.contains('"') {
565                    return unparsed(raw);
566                }
567                pairs.push((key.to_string(), unquote(value.trim())));
568            }
569            None if i == 0 && !item.contains('"') => title = Some(item.to_string()),
570            None => return unparsed(raw),
571        }
572    }
573    if pairs.is_empty() {
574        return unparsed(raw);
575    }
576    Metadata { raw, title, pairs }
577}
578
579/// `"a ""b"""` is `a "b"`; a value with no quotes around it is itself.
580fn unquote(value: &str) -> String {
581    match value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
582        Some(inner) => inner.replace("\"\"", "\""),
583        None => value.to_string(),
584    }
585}
586
587#[cfg(test)]
588mod tests;