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: each merged cell's text at its top-left position, the
129/// positions it covers empty, every record as wide as the widest.
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    // For each column, how many further rows a cell merged down from above still covers.
137    let mut covered: Vec<usize> = Vec::new();
138    let mut grid: Vec<Vec<String>> = Vec::new();
139    for row in rows {
140        let mut record: Vec<String> = Vec::new();
141        let mut col = 0;
142        for cell in row {
143            skip_covered(&mut covered, &mut record, &mut col);
144            record.push(cell.text.as_ref().to_string());
145            let span = cell.col_span.max(1) as usize;
146            record.extend(std::iter::repeat_n(String::new(), span - 1));
147            if covered.len() < col + span {
148                covered.resize(col + span, 0);
149            }
150            for c in &mut covered[col..col + span] {
151                *c = cell.row_span.max(1) as usize - 1;
152            }
153            col += span;
154        }
155        // Columns past the row's last cell that a cell above still covers.
156        while col < covered.len() {
157            if covered[col] > 0 {
158                covered[col] -= 1;
159            }
160            record.push(String::new());
161            col += 1;
162        }
163        grid.push(record);
164    }
165    let width = grid.iter().map(Vec::len).max().unwrap_or(0);
166    for record in &mut grid {
167        record.resize(width, String::new());
168    }
169    grid
170}
171
172/// Pass the columns, from `col` on, that a cell merged down from a row above still covers,
173/// leaving each one empty in `record`.
174fn skip_covered(covered: &mut [usize], record: &mut Vec<String>, col: &mut usize) {
175    while covered.get(*col).is_some_and(|&n| n > 0) {
176        covered[*col] -= 1;
177        record.push(String::new());
178        *col += 1;
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn csv(rows: &[&[Cell<&str>]]) -> String {
187        to_csv(rows.iter().map(|row| row.iter().copied()))
188    }
189
190    #[test]
191    fn plain_fields_are_written_as_they_are() {
192        assert_eq!(
193            csv(&[&[Cell::single("a"), Cell::single("b c")]]),
194            "a,b c\r\n"
195        );
196    }
197
198    #[test]
199    fn a_field_with_a_delimiter_quote_or_line_break_is_quoted() {
200        assert_eq!(
201            csv(&[&[
202                Cell::single("1,5"),
203                Cell::single("say \"hi\""),
204                Cell::single("two\nlines"),
205                Cell::single("cr\rhere"),
206            ]]),
207            "\"1,5\",\"say \"\"hi\"\"\",\"two\nlines\",\"cr\rhere\"\r\n"
208        );
209    }
210
211    #[test]
212    fn the_delimiter_decides_what_needs_quoting() {
213        let row = [Cell::single("a,b"), Cell::single("c\td")];
214        assert_eq!(to_delimited([row], '\t'), "a,b\t\"c\td\"\r\n");
215    }
216
217    #[test]
218    fn a_horizontal_span_leaves_the_positions_it_covers_empty() {
219        assert_eq!(
220            csv(&[
221                &[Cell::new("wide", 1, 3)],
222                &[Cell::single("a"), Cell::single("b"), Cell::single("c")],
223            ]),
224            "wide,,\r\na,b,c\r\n"
225        );
226    }
227
228    #[test]
229    fn a_vertical_span_pushes_the_rows_below_rightward() {
230        // The second and third rows have no cell for column 0: the merge owns it.
231        assert_eq!(
232            csv(&[
233                &[Cell::new("tall", 3, 1), Cell::single("a")],
234                &[Cell::single("b")],
235                &[Cell::single("c")],
236                &[Cell::single("d"), Cell::single("e")],
237            ]),
238            "tall,a\r\n,b\r\n,c\r\nd,e\r\n"
239        );
240    }
241
242    #[test]
243    fn a_cell_merged_down_at_the_row_end_leaves_the_next_row_aligned() {
244        assert_eq!(
245            csv(&[
246                &[Cell::single("a"), Cell::new("tall", 2, 1)],
247                &[Cell::single("b")],
248                &[Cell::single("c"), Cell::single("d")],
249            ]),
250            "a,tall\r\nb,\r\nc,d\r\n"
251        );
252    }
253
254    #[test]
255    fn a_block_merge_covers_rows_and_columns() {
256        assert_eq!(
257            csv(&[
258                &[Cell::new("block", 2, 2), Cell::single("x")],
259                &[Cell::single("y")],
260                &[Cell::single("p"), Cell::single("q"), Cell::single("r")],
261            ]),
262            "block,,x\r\n,,y\r\np,q,r\r\n"
263        );
264    }
265
266    #[test]
267    fn short_rows_are_padded_to_the_widest() {
268        assert_eq!(
269            csv(&[
270                &[Cell::single("a")],
271                &[Cell::single("b"), Cell::single("c")]
272            ]),
273            "a,\r\nb,c\r\n"
274        );
275    }
276
277    #[test]
278    fn a_zero_span_is_read_as_one() {
279        assert_eq!(
280            csv(&[
281                &[Cell::new("z", 0, 0), Cell::single("a")],
282                &[Cell::single("b"), Cell::single("c")]
283            ]),
284            "z,a\r\nb,c\r\n"
285        );
286    }
287
288    #[test]
289    fn an_empty_table_is_empty_text() {
290        assert_eq!(csv(&[]), "");
291    }
292
293    #[test]
294    fn owned_text_works_as_well_as_borrowed() {
295        let rows = vec![vec![Cell::single(String::from("own"))]];
296        assert_eq!(to_csv(rows), "own\r\n");
297    }
298}