Skip to main content

unparser_shared/
csv.rs

1//! Tables as delimited text: CSV ([RFC 4180](https://www.rfc-editor.org/rfc/rfc4180)) and its
2//! tab-separated variant.
3//!
4//! Every parser in the family records a merged cell once, on the cell that owns it: the
5//! positions it covers — the tail of a horizontal span, and the columns a vertical span occupies
6//! in the rows below — have no cell of their own. Laying such a table on its grid, and quoting
7//! the fields so a spreadsheet or a CSV reader gets the values back, are the same work whatever
8//! the document format was, so it is done here once.
9//!
10//! The layout: a merged cell's text sits at its top-left position and the positions it covers
11//! are empty, so every record has the same number of fields and no value is counted twice (the
12//! shape pandas reads as one value and blanks). Rows are written as given — a table's header
13//! rows first, as they are.
14//!
15//! `std`-only, like the rest of the crate root.
16
17/// One cell of a table row as the family's models record it: its text, and how many rows and
18/// columns it owns. A span of 0 is read as 1.
19///
20/// The text is the cell's plain text; a cell of several paragraphs joins them with `\n`, which
21/// a field keeps (quoted) rather than flattening.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Cell<S> {
24    /// The cell's plain text.
25    pub text: S,
26    /// Rows the cell owns, its own included.
27    pub row_span: u32,
28    /// Columns the cell owns, its own included.
29    pub col_span: u32,
30}
31
32impl<S> Cell<S> {
33    /// A cell owning `row_span` × `col_span` grid positions.
34    pub fn new(text: S, row_span: u32, col_span: u32) -> Self {
35        Self {
36            text,
37            row_span,
38            col_span,
39        }
40    }
41
42    /// A cell owning only its own position.
43    pub fn single(text: S) -> Self {
44        Self::new(text, 1, 1)
45    }
46}
47
48/// The table as CSV: one record per row, fields separated by `,`, records ended by CRLF
49/// (RFC 4180 §2). See [`to_delimited`].
50///
51/// ```
52/// use unparser_shared::csv::{to_csv, Cell};
53///
54/// let rows = [
55///     vec![Cell::single("Item"), Cell::single("Note")],
56///     vec![Cell::single("Bolt, M6"), Cell::single("He said \"no\"")],
57/// ];
58/// assert_eq!(to_csv(rows), "Item,Note\r\n\"Bolt, M6\",\"He said \"\"no\"\"\"\r\n");
59/// ```
60pub fn to_csv<R, C, S>(rows: R) -> String
61where
62    R: IntoIterator<Item = C>,
63    C: IntoIterator<Item = Cell<S>>,
64    S: AsRef<str>,
65{
66    to_delimited(rows, ',')
67}
68
69/// The table as delimited text with `delimiter` between fields (`'\t'` for TSV), laid out on
70/// its grid as the [module](self) describes.
71///
72/// A field holding the delimiter, a double quote or a line break is enclosed in double quotes,
73/// with each quote inside doubled; every other field is written as it is.
74///
75/// ```
76/// use unparser_shared::csv::{to_delimited, Cell};
77///
78/// // ┌────────┬───────────┐
79/// // │ Region │   Sales   │
80/// // │        ├─────┬─────┤
81/// // │        │2024 │2025 │
82/// // ├────────┼─────┼─────┤
83/// // │ North  │ 10  │ 12  │
84/// // └────────┴─────┴─────┘
85/// let rows = [
86///     vec![Cell::new("Region", 2, 1), Cell::new("Sales", 1, 2)],
87///     vec![Cell::single("2024"), Cell::single("2025")],
88///     vec![Cell::single("North"), Cell::single("10"), Cell::single("12")],
89/// ];
90/// assert_eq!(
91///     to_delimited(rows, '\t'),
92///     "Region\tSales\t\r\n\t2024\t2025\r\nNorth\t10\t12\r\n"
93/// );
94/// ```
95pub fn to_delimited<R, C, S>(rows: R, delimiter: char) -> String
96where
97    R: IntoIterator<Item = C>,
98    C: IntoIterator<Item = Cell<S>>,
99    S: AsRef<str>,
100{
101    let mut out = String::new();
102    for record in grid(rows) {
103        for (i, field) in record.iter().enumerate() {
104            if i > 0 {
105                out.push(delimiter);
106            }
107            push_field(&mut out, field, delimiter);
108        }
109        out.push_str("\r\n");
110    }
111    out
112}
113
114fn push_field(out: &mut String, field: &str, delimiter: char) {
115    let quote = field.contains(delimiter)
116        || field.contains('"')
117        || field.contains('\n')
118        || field.contains('\r');
119    if quote {
120        out.push('"');
121        out.push_str(&field.replace('"', "\"\""));
122        out.push('"');
123    } else {
124        out.push_str(field);
125    }
126}
127
128/// The rows laid out on their grid ([`crate::grid::place`]): each merged cell's text at its
129/// top-left position, the positions it covers empty, every record as wide as the table.
130fn grid<R, C, S>(rows: R) -> Vec<Vec<String>>
131where
132    R: IntoIterator<Item = C>,
133    C: IntoIterator<Item = Cell<S>>,
134    S: AsRef<str>,
135{
136    let rows: Vec<Vec<Cell<S>>> = rows.into_iter().map(|r| r.into_iter().collect()).collect();
137    let placement = crate::grid::place(rows.iter().map(|row| {
138        row.iter()
139            .map(|cell| crate::grid::Span::new(cell.row_span, cell.col_span))
140    }));
141    let mut grid = vec![vec![String::new(); placement.width]; rows.len()];
142    for ((record, row), columns) in grid.iter_mut().zip(&rows).zip(&placement.columns) {
143        for (cell, &col) in row.iter().zip(columns) {
144            record[col] = cell.text.as_ref().to_string();
145        }
146    }
147    grid
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn csv(rows: &[&[Cell<&str>]]) -> String {
155        to_csv(rows.iter().map(|row| row.iter().copied()))
156    }
157
158    #[test]
159    fn plain_fields_are_written_as_they_are() {
160        assert_eq!(
161            csv(&[&[Cell::single("a"), Cell::single("b c")]]),
162            "a,b c\r\n"
163        );
164    }
165
166    #[test]
167    fn a_field_with_a_delimiter_quote_or_line_break_is_quoted() {
168        assert_eq!(
169            csv(&[&[
170                Cell::single("1,5"),
171                Cell::single("say \"hi\""),
172                Cell::single("two\nlines"),
173                Cell::single("cr\rhere"),
174            ]]),
175            "\"1,5\",\"say \"\"hi\"\"\",\"two\nlines\",\"cr\rhere\"\r\n"
176        );
177    }
178
179    #[test]
180    fn the_delimiter_decides_what_needs_quoting() {
181        let row = [Cell::single("a,b"), Cell::single("c\td")];
182        assert_eq!(to_delimited([row], '\t'), "a,b\t\"c\td\"\r\n");
183    }
184
185    #[test]
186    fn a_horizontal_span_leaves_the_positions_it_covers_empty() {
187        assert_eq!(
188            csv(&[
189                &[Cell::new("wide", 1, 3)],
190                &[Cell::single("a"), Cell::single("b"), Cell::single("c")],
191            ]),
192            "wide,,\r\na,b,c\r\n"
193        );
194    }
195
196    #[test]
197    fn a_vertical_span_pushes_the_rows_below_rightward() {
198        // The second and third rows have no cell for column 0: the merge owns it.
199        assert_eq!(
200            csv(&[
201                &[Cell::new("tall", 3, 1), Cell::single("a")],
202                &[Cell::single("b")],
203                &[Cell::single("c")],
204                &[Cell::single("d"), Cell::single("e")],
205            ]),
206            "tall,a\r\n,b\r\n,c\r\nd,e\r\n"
207        );
208    }
209
210    #[test]
211    fn a_cell_merged_down_at_the_row_end_leaves_the_next_row_aligned() {
212        assert_eq!(
213            csv(&[
214                &[Cell::single("a"), Cell::new("tall", 2, 1)],
215                &[Cell::single("b")],
216                &[Cell::single("c"), Cell::single("d")],
217            ]),
218            "a,tall\r\nb,\r\nc,d\r\n"
219        );
220    }
221
222    #[test]
223    fn a_block_merge_covers_rows_and_columns() {
224        assert_eq!(
225            csv(&[
226                &[Cell::new("block", 2, 2), Cell::single("x")],
227                &[Cell::single("y")],
228                &[Cell::single("p"), Cell::single("q"), Cell::single("r")],
229            ]),
230            "block,,x\r\n,,y\r\np,q,r\r\n"
231        );
232    }
233
234    #[test]
235    fn short_rows_are_padded_to_the_widest() {
236        assert_eq!(
237            csv(&[
238                &[Cell::single("a")],
239                &[Cell::single("b"), Cell::single("c")]
240            ]),
241            "a,\r\nb,c\r\n"
242        );
243    }
244
245    #[test]
246    fn a_zero_span_is_read_as_one() {
247        assert_eq!(
248            csv(&[
249                &[Cell::new("z", 0, 0), Cell::single("a")],
250                &[Cell::single("b"), Cell::single("c")]
251            ]),
252            "z,a\r\nb,c\r\n"
253        );
254    }
255
256    #[test]
257    fn an_empty_table_is_empty_text() {
258        assert_eq!(csv(&[]), "");
259    }
260
261    #[test]
262    fn owned_text_works_as_well_as_borrowed() {
263        let rows = vec![vec![Cell::single(String::from("own"))]];
264        assert_eq!(to_csv(rows), "own\r\n");
265    }
266}