Skip to main content

run_stack/
table.rs

1//! One bordered table, drawn the same way everywhere.
2//!
3//! Widths count characters, not bytes: an em dash is three bytes and one
4//! column, and Rust pads by characters — measuring in bytes puts every border
5//! after it out of line.
6
7/// Render `rows` under `headers` as a table with box-drawing borders.
8///
9/// A row with fewer cells than there are headers is padded with blanks, so a
10/// caller never has to pad by hand.
11pub fn render(headers: &[&str], rows: &[Vec<String>]) -> String {
12    if headers.is_empty() {
13        return String::new();
14    }
15
16    let widths: Vec<usize> = headers
17        .iter()
18        .enumerate()
19        .map(|(index, header)| {
20            rows.iter()
21                .filter_map(|row| row.get(index))
22                .map(|cell| cell.chars().count())
23                .chain(std::iter::once(header.chars().count()))
24                .max()
25                .unwrap_or(0)
26        })
27        .collect();
28
29    let header_cells: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
30
31    let mut out = String::new();
32    out.push_str(&rule('┌', '┬', '┐', &widths));
33    out.push_str(&line(&header_cells, &widths));
34    out.push_str(&rule('├', '┼', '┤', &widths));
35    for row in rows {
36        out.push_str(&line(row, &widths));
37    }
38    out.push_str(&rule('└', '┴', '┘', &widths));
39    out
40}
41
42fn rule(left: char, join: char, right: char, widths: &[usize]) -> String {
43    let segments: Vec<String> = widths.iter().map(|w| "─".repeat(w + 2)).collect();
44    format!("{left}{}{right}\n", segments.join(&join.to_string()))
45}
46
47fn line(cells: &[String], widths: &[usize]) -> String {
48    let mut out = String::from("│");
49    for (index, width) in widths.iter().enumerate() {
50        let empty = String::new();
51        let cell = cells.get(index).unwrap_or(&empty);
52        let pad = width.saturating_sub(cell.chars().count());
53        out.push(' ');
54        out.push_str(cell);
55        out.push_str(&" ".repeat(pad));
56        out.push_str(" │");
57    }
58    out.push('\n');
59    out
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    fn row(cells: &[&str]) -> Vec<String> {
67        cells.iter().map(|c| (*c).to_string()).collect()
68    }
69
70    #[test]
71    fn every_line_is_the_same_width() {
72        let table = render(&["A", "B"], &[row(&["one", "two"]), row(&["longer", "x"])]);
73        let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
74
75        assert!(widths.windows(2).all(|pair| pair[0] == pair[1]), "{table}");
76    }
77
78    #[test]
79    fn a_multibyte_cell_does_not_skew_the_border() {
80        let table = render(&["A", "B"], &[row(&["—", "✓"]), row(&["ascii", "y"])]);
81        let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
82
83        assert!(widths.windows(2).all(|pair| pair[0] == pair[1]), "{table}");
84    }
85
86    #[test]
87    fn a_short_row_is_padded_out() {
88        let table = render(&["A", "B", "C"], &[row(&["only"])]);
89        let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
90
91        assert!(widths.windows(2).all(|pair| pair[0] == pair[1]), "{table}");
92    }
93
94    #[test]
95    fn a_wide_cell_sets_the_column_width() {
96        let table = render(&["A"], &[row(&["a much longer cell"])]);
97
98        assert!(table.contains("a much longer cell │"));
99    }
100
101    #[test]
102    fn corners_are_drawn() {
103        let table = render(&["A"], &[row(&["x"])]);
104
105        assert!(table.starts_with('┌'));
106        assert!(table.contains('├'));
107        assert!(table.trim_end().ends_with('┘'));
108    }
109
110    #[test]
111    fn no_headers_renders_nothing() {
112        assert_eq!(render(&[], &[row(&["x"])]), "");
113    }
114}