1use crate::{escape::terminal_text, width::display_width};
3use std::borrow::Cow;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum Alignment {
8 Left,
10 Right,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct Options {
17 pub column_gap: usize,
19 pub header_separator: bool,
21}
22
23#[must_use]
34pub fn render<const N: usize>(
35 headers: &[impl AsRef<str>; N],
36 rows: &[[impl AsRef<str>; N]],
37 alignments: &[Alignment; N],
38 options: Options,
39) -> String {
40 if N == 0 {
41 return String::new();
42 }
43 let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
44 let rows = rows
45 .iter()
46 .map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
47 .collect::<Vec<_>>();
48 let mut widths = [0; N];
49 measure_row(&mut widths, &headers);
50 for row in &rows {
51 measure_row(&mut widths, row);
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
72#[must_use]
81pub fn render_bordered(headers: &[impl AsRef<str>], rows: &[Vec<impl AsRef<str>>]) -> String {
82 let headers = headers
83 .iter()
84 .map(|cell| terminal_text(cell.as_ref()))
85 .collect::<Vec<_>>();
86 let rows = rows
87 .iter()
88 .map(|row| {
89 row.iter()
90 .map(|cell| terminal_text(cell.as_ref()))
91 .collect::<Vec<_>>()
92 })
93 .collect::<Vec<_>>();
94 let mut widths = vec![0; headers.len()];
95 measure_row(&mut widths, &headers);
96 for row in &rows {
97 widths.resize(widths.len().max(row.len()), 0);
98 measure_row(&mut widths, row);
99 }
100 if widths.is_empty() {
101 return String::new();
102 }
103 let mut separator = String::from("+");
104 for width in &widths {
105 separator.extend(std::iter::repeat_n('-', width.saturating_add(2)));
106 separator.push('+');
107 }
108 let alignments = vec![Alignment::Left; widths.len()];
109 let mut output = separator.clone();
110 for (index, row) in std::iter::once(&headers).chain(&rows).enumerate() {
113 output.push_str("\n| ");
114 append_cells(row, &widths, &alignments, " | ", &mut output);
115 output.push_str(" |");
116 if index == 0 {
117 output.push('\n');
118 output.push_str(&separator);
119 }
120 }
121 if !rows.is_empty() {
122 output.push('\n');
123 output.push_str(&separator);
124 }
125 output
126}
127
128fn measure_row(widths: &mut [usize], row: &[Cow<'_, str>]) {
129 for (width, value) in widths.iter_mut().zip(row) {
130 *width = (*width).max(display_width(value));
131 }
132}
133
134fn render_row(
135 row: &[Cow<'_, str>],
136 widths: &[usize],
137 alignments: &[Alignment],
138 gap: &str,
139 output: &mut String,
140) {
141 let start = output.len();
142 append_cells(row, widths, alignments, gap, output);
143 output.truncate(start + output[start..].trim_end().len());
145}
146
147fn append_cells(
148 row: &[Cow<'_, str>],
149 widths: &[usize],
150 alignments: &[Alignment],
151 gap: &str,
152 output: &mut String,
153) {
154 for (index, (width, alignment)) in widths.iter().zip(alignments).enumerate() {
155 if index != 0 {
156 output.push_str(gap);
157 }
158 let value = row.get(index).map_or("", |cell| cell.as_ref());
159 let padding = width.saturating_sub(display_width(value));
160 if *alignment == Alignment::Right {
161 output.extend(std::iter::repeat_n(' ', padding));
162 }
163 output.push_str(value);
164 if *alignment == Alignment::Left {
165 output.extend(std::iter::repeat_n(' ', padding));
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 const OPTIONS: Options = Options {
174 column_gap: 2,
175 header_separator: true,
176 };
177
178 #[test]
179 fn joined_sequences_share_clipping_and_progress_columns() {
180 let emoji = "👩💻";
181 let clipped = crate::width::fit_line(emoji, 4);
182 assert_eq!(clipped, emoji);
183 let mut progress = crate::progress::ProgressLine::new();
184 assert_eq!(progress.update(&clipped), "\r👩💻");
185 assert_eq!(progress.finish("x"), "\rx \n");
186 assert_eq!(
187 render(
188 &["X", "N"],
189 &[[clipped.as_str(), "1"], ["abcd", "2"]],
190 &[Alignment::Left, Alignment::Right],
191 OPTIONS,
192 ),
193 "X N\n---- -\n👩💻 1\nabcd 2"
194 );
195 }
196
197 #[test]
198 fn wide_and_combining_cells_align_in_display_columns() {
199 assert_eq!(
200 render(
201 &["NAME", "N"],
202 &[["界", "7"], ["e\u{301}", "123"]],
203 &[Alignment::Left, Alignment::Right],
204 OPTIONS
205 ),
206 "NAME N\n---- ---\n界 7\ne\u{301} 123"
207 );
208 }
209
210 #[test]
211 fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
212 let text = render(
213 &["H\n"],
214 &[["x\x1b[2J\t\u{2029}"]],
215 &[Alignment::Left],
216 OPTIONS,
217 );
218 assert_eq!(text.lines().count(), 3);
219 assert!(text.starts_with("H\\n\n"));
220 assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
221 assert!(!text.contains('\x1b'));
222 }
223
224 #[test]
225 fn whitespace_cells_preserve_empty_rows_and_separator_spacing() {
226 assert_eq!(
227 render(
228 &[" ", "B "],
229 &[["", ""], ["x ", "\u{2003}"], ["界", "7"]],
230 &[Alignment::Left, Alignment::Right],
231 OPTIONS,
232 ),
233 " B\n-- --\n\nx\n界 7"
234 );
235 assert_eq!(
236 render(&["H"], &[[""], [""]], &[Alignment::Left], OPTIONS),
237 "H\n-\n\n"
238 );
239 assert_eq!(
240 render(&["", ""], &[["", ""]], &[Alignment::Left; 2], OPTIONS),
241 "\n \n"
242 );
243 }
244
245 #[test]
246 fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
247 assert_eq!(
248 render(
249 &["A", "B"],
250 &[["1", "2"]],
251 &[Alignment::Left; 2],
252 Options {
253 column_gap: 0,
254 header_separator: false
255 }
256 ),
257 "AB\n12"
258 );
259 assert_eq!(
260 render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
261 "A\n-"
262 );
263 assert_eq!(
264 render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
265 ""
266 );
267 }
268
269 #[test]
270 fn bordered_dynamic_columns_preserve_layout_and_ragged_cells() {
271 assert_eq!(
273 render_bordered(&[String::from("A")], &[vec![String::from("x")]]),
274 "+---+\n| A |\n+---+\n| x |\n+---+"
275 );
276 assert_eq!(
277 render_bordered(&["A", "B"], &[vec!["x", "123"], vec!["long"]]),
278 "+------+-----+\n| A | B |\n+------+-----+\n| x | 123 |\n| long | |\n+------+-----+"
279 );
280 assert_eq!(
281 render_bordered(&["A"], &[vec!["x", "y"]]),
282 "+---+---+\n| A | |\n+---+---+\n| x | y |\n+---+---+"
283 );
284 assert_eq!(
285 render_bordered(&["A"], &[] as &[Vec<&str>]),
286 "+---+\n| A |\n+---+"
287 );
288 assert_eq!(render_bordered(&[] as &[&str], &[] as &[Vec<&str>]), "");
289 assert_eq!(render_bordered(&[] as &[&str], &[vec![] as Vec<&str>]), "");
290 assert_eq!(
291 render_bordered(&[] as &[&str], &[vec!["x"]]),
292 "+---+\n| |\n+---+\n| x |\n+---+"
293 );
294 assert_eq!(
295 render_bordered(&["A", "B"], &[vec![] as Vec<&str>, vec![" ", "x "]]),
296 "+----+----+\n| A | B |\n+----+----+\n| | |\n| | x |\n+----+----+"
297 );
298 }
299
300 #[test]
301 fn bordered_tables_escape_before_shared_unicode_alignment() {
302 let result = render_bordered(
303 &["界", "h\n"],
304 &[
305 vec!["e\u{301}", "\x1b]52;c;x\x07"],
306 vec!["x\ny", "\u{202e}\u{0085}\u{2028}"],
307 ],
308 );
309 let lines = result.lines().collect::<Vec<_>>();
310 assert_eq!(lines.len(), 6);
311 assert!(!result.contains('\x1b'));
312 assert!(!result.contains('\u{202e}'));
313 assert!(result.contains("e\u{301}"));
314 assert!(result.contains("x\\ny"));
315 assert!(result.contains("h\\n"));
316 let width = display_width(lines[0]);
317 for line in lines {
318 assert_eq!(display_width(line), width);
319 }
320 assert_eq!(
321 render_bordered(&["X"], &[vec!["👩💻"]]),
322 "+------+\n| X |\n+------+\n| 👩💻 |\n+------+"
323 );
324 }
325}