Skip to main content

canic_host/
table.rs

1const COLUMN_GAP: &str = "   ";
2
3///
4/// ColumnAlign
5///
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum ColumnAlign {
9    Left,
10    Right,
11}
12
13/// Render a whitespace-aligned table with an underlined header row.
14#[must_use]
15pub fn render_table<const N: usize>(
16    headers: &[&str; N],
17    rows: &[[String; N]],
18    alignments: &[ColumnAlign; N],
19) -> String {
20    let widths = table_widths(headers, rows);
21    let mut lines = Vec::with_capacity(rows.len() + 2);
22    lines.push(render_table_row(headers, &widths, alignments));
23    lines.push(render_separator(&widths));
24    lines.extend(
25        rows.iter()
26            .map(|row| render_table_row(row, &widths, alignments)),
27    );
28    lines.join("\n")
29}
30
31/// Render an ASCII-bordered table with one space of cell padding.
32#[must_use]
33pub fn render_bordered_table<const N: usize>(
34    headers: &[&str; N],
35    rows: &[[String; N]],
36    alignments: &[ColumnAlign; N],
37) -> String {
38    let widths = table_widths(headers, rows);
39    let mut lines = Vec::with_capacity(rows.len() + 4);
40    lines.push(render_table_border(&widths, '-'));
41    lines.push(render_bordered_table_row(headers, &widths, alignments));
42    lines.push(render_table_border(&widths, '='));
43    lines.extend(
44        rows.iter()
45            .map(|row| render_bordered_table_row(row, &widths, alignments)),
46    );
47    lines.push(render_table_border(&widths, '-'));
48    lines.join("\n")
49}
50
51/// Compute per-column display widths from headers and rows.
52#[must_use]
53pub fn table_widths<const N: usize>(headers: &[&str; N], rows: &[[String; N]]) -> [usize; N] {
54    let mut widths = headers.map(display_width);
55
56    for row in rows {
57        for (index, cell) in row.iter().enumerate() {
58            widths[index] = widths[index].max(display_width(cell));
59        }
60    }
61
62    widths
63}
64
65/// Render one whitespace-aligned table row.
66#[must_use]
67pub fn render_table_row<const N: usize>(
68    row: &[impl AsRef<str>],
69    widths: &[usize; N],
70    alignments: &[ColumnAlign; N],
71) -> String {
72    widths
73        .iter()
74        .zip(alignments)
75        .enumerate()
76        .map(|(index, (width, alignment))| {
77            let value = row.get(index).map_or("", AsRef::as_ref);
78            pad_cell(value, *width, *alignment)
79        })
80        .collect::<Vec<_>>()
81        .join(COLUMN_GAP)
82        .trim_end()
83        .to_string()
84}
85
86/// Render one row with ASCII borders and one space of horizontal cell padding.
87#[must_use]
88pub fn render_bordered_table_row<const N: usize>(
89    row: &[impl AsRef<str>],
90    widths: &[usize; N],
91    alignments: &[ColumnAlign; N],
92) -> String {
93    let cells = widths
94        .iter()
95        .zip(alignments)
96        .enumerate()
97        .map(|(index, (width, alignment))| {
98            let value = row.get(index).map_or("", AsRef::as_ref);
99            format!(" {} ", pad_cell(value, *width, *alignment))
100        })
101        .collect::<Vec<_>>()
102        .join("|");
103    format!("|{cells}|")
104}
105
106/// Render the underline row for a whitespace-aligned table.
107#[must_use]
108pub fn render_separator<const N: usize>(widths: &[usize; N]) -> String {
109    widths
110        .iter()
111        .map(|width| "-".repeat(*width))
112        .collect::<Vec<_>>()
113        .join(COLUMN_GAP)
114}
115
116/// Render a horizontal border for an ASCII-bordered table.
117#[must_use]
118pub fn render_table_border<const N: usize>(widths: &[usize; N], fill: char) -> String {
119    let segments = widths
120        .iter()
121        .map(|width| fill.to_string().repeat(width + 2))
122        .collect::<Vec<_>>()
123        .join("+");
124    format!("+{segments}+")
125}
126
127fn pad_cell(value: &str, width: usize, alignment: ColumnAlign) -> String {
128    let padding = width.saturating_sub(display_width(value));
129    match alignment {
130        ColumnAlign::Left => format!("{value}{}", " ".repeat(padding)),
131        ColumnAlign::Right => format!("{}{value}", " ".repeat(padding)),
132    }
133}
134
135fn display_width(value: &str) -> usize {
136    let mut width = 0;
137    let mut chars = value.chars().peekable();
138    while let Some(character) = chars.next() {
139        if character == '\u{1b}' && chars.next_if_eq(&'[').is_some() {
140            for control in chars.by_ref() {
141                if ('@'..='~').contains(&control) {
142                    break;
143                }
144            }
145            continue;
146        }
147        width += 1;
148    }
149    width
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn bordered_table_keeps_ansi_cells_aligned() {
158        let rows = [["\u{1b}[32mdone\u{1b}[0m".to_string(), "12".to_string()]];
159        let table = render_bordered_table(
160            &["STATUS", "COUNT"],
161            &rows,
162            &[ColumnAlign::Left, ColumnAlign::Right],
163        );
164        let lines = table.lines().collect::<Vec<_>>();
165
166        assert_eq!(lines[0], "+--------+-------+");
167        assert_eq!(display_width(lines[1]), display_width(lines[3]));
168        assert_eq!(display_width(lines[3]), display_width(lines[4]));
169    }
170
171    #[test]
172    fn bordered_table_adds_horizontal_cell_padding() {
173        let table = render_bordered_table(
174            &["A", "B"],
175            &[["left".to_string(), "7".to_string()]],
176            &[ColumnAlign::Left, ColumnAlign::Right],
177        );
178
179        assert!(table.contains("| left | 7 |"));
180    }
181}