1const COLUMN_GAP: &str = " ";
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum ColumnAlign {
9 Left,
10 Right,
11}
12
13#[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#[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#[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#[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#[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#[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#[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}