1use crate::escape::terminal_text;
3use std::borrow::Cow;
4use unicode_width::UnicodeWidthStr;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum Alignment {
9 Left,
11 Right,
13}
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct Options {
18 pub column_gap: usize,
20 pub header_separator: bool,
22}
23
24#[must_use]
33pub fn render<const N: usize>(
34 headers: &[impl AsRef<str>; N],
35 rows: &[[impl AsRef<str>; N]],
36 alignments: &[Alignment; N],
37 options: Options,
38) -> String {
39 if N == 0 {
40 return String::new();
41 }
42 let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
43 let rows = rows
44 .iter()
45 .map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
46 .collect::<Vec<_>>();
47 let widths = std::array::from_fn(|index| {
48 rows.iter().fold(
49 UnicodeWidthStr::width(headers[index].as_ref()),
50 |width, row| width.max(UnicodeWidthStr::width(row[index].as_ref())),
51 )
52 });
53 let gap = " ".repeat(options.column_gap);
54 let mut output = String::new();
55 render_row(&headers, &widths, alignments, &gap, &mut output);
56 if options.header_separator {
57 output.push('\n');
58 for (index, width) in widths.iter().enumerate() {
59 if index != 0 {
60 output.push_str(&gap);
61 }
62 output.extend(std::iter::repeat_n('-', *width));
63 }
64 }
65 for row in &rows {
66 output.push('\n');
67 render_row(row, &widths, alignments, &gap, &mut output);
68 }
69 output
70}
71
72fn render_row<const N: usize>(
73 row: &[Cow<'_, str>; N],
74 widths: &[usize; N],
75 alignments: &[Alignment; N],
76 gap: &str,
77 output: &mut String,
78) {
79 let start = output.len();
80 for (index, ((value, width), alignment)) in row.iter().zip(widths).zip(alignments).enumerate() {
81 if index != 0 {
82 output.push_str(gap);
83 }
84 let padding = width.saturating_sub(UnicodeWidthStr::width(value.as_ref()));
85 if *alignment == Alignment::Right {
86 output.extend(std::iter::repeat_n(' ', padding));
87 }
88 output.push_str(value);
89 if *alignment == Alignment::Left {
90 output.extend(std::iter::repeat_n(' ', padding));
91 }
92 }
93 output.truncate(start + output[start..].trim_end().len());
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 const OPTIONS: Options = Options {
101 column_gap: 2,
102 header_separator: true,
103 };
104
105 #[test]
106 fn wide_and_combining_cells_align_in_display_columns() {
107 assert_eq!(
108 render(
109 &["NAME", "N"],
110 &[["界", "7"], ["e\u{301}", "123"]],
111 &[Alignment::Left, Alignment::Right],
112 OPTIONS
113 ),
114 "NAME N\n---- ---\n界 7\ne\u{301} 123"
115 );
116 }
117
118 #[test]
119 fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
120 let text = render(
121 &["H\n"],
122 &[["x\x1b[2J\t\u{2029}"]],
123 &[Alignment::Left],
124 OPTIONS,
125 );
126 assert_eq!(text.lines().count(), 3);
127 assert!(text.starts_with("H\\n\n"));
128 assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
129 assert!(!text.contains('\x1b'));
130 }
131
132 #[test]
133 fn whitespace_cells_preserve_empty_rows_and_separator_spacing() {
134 assert_eq!(
135 render(
136 &[" ", "B "],
137 &[["", ""], ["x ", "\u{2003}"], ["界", "7"]],
138 &[Alignment::Left, Alignment::Right],
139 OPTIONS,
140 ),
141 " B\n-- --\n\nx\n界 7"
142 );
143 assert_eq!(
144 render(&["H"], &[[""], [""]], &[Alignment::Left], OPTIONS),
145 "H\n-\n\n"
146 );
147 assert_eq!(
148 render(&["", ""], &[["", ""]], &[Alignment::Left; 2], OPTIONS),
149 "\n \n"
150 );
151 }
152
153 #[test]
154 fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
155 assert_eq!(
156 render(
157 &["A", "B"],
158 &[["1", "2"]],
159 &[Alignment::Left; 2],
160 Options {
161 column_gap: 0,
162 header_separator: false
163 }
164 ),
165 "AB\n12"
166 );
167 assert_eq!(
168 render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
169 "A\n-"
170 );
171 assert_eq!(
172 render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
173 ""
174 );
175 }
176}