|
1 | 1 | use std::fmt::Write; |
2 | 2 |
|
| 3 | +/// Prints a markdown table to stdout. |
| 4 | +/// See `write_markdown_table` for details. |
3 | 5 | pub fn print_markdown_table<T, const N: usize>( |
4 | 6 | headers: [&str; N], |
5 | 7 | data: impl IntoIterator<Item = T> + Clone, |
6 | 8 | get_fields: impl Fn(&T) -> [String; N], |
7 | 9 | ) { |
8 | | - let mut sizes = headers.map(|h| h.len()); |
| 10 | + write_markdown_table(&mut std::io::stdout(), headers, data, get_fields); |
| 11 | +} |
| 12 | + |
| 13 | +/// Writes a markdown table to the given writer. |
| 14 | +/// The headers and fields can specify alignment by starting or ending with a space: |
| 15 | +/// - " Text" - right aligned |
| 16 | +/// - " Text " - center aligned |
| 17 | +/// - "Text" - left aligned |
| 18 | +/// Also, if multiple consecutive headers are identical, they will be form a merged header cell. |
| 19 | +pub fn write_markdown_table<T, const N: usize>( |
| 20 | + write: &mut impl std::io::Write, |
| 21 | + headers: [&str; N], |
| 22 | + data: impl IntoIterator<Item = T> + Clone, |
| 23 | + get_fields: impl Fn(&T) -> [String; N], |
| 24 | +) { |
| 25 | + let mut merged = headers.map(|_| false); |
| 26 | + for i in 1..N { |
| 27 | + if headers[i].trim() == headers[i - 1].trim() { |
| 28 | + merged[i] = true; |
| 29 | + } |
| 30 | + } |
9 | 31 | // Measure max field size |
| 32 | + let mut sizes = headers.map(|_| 1); |
10 | 33 | for item in data.clone() { |
11 | 34 | let fields = get_fields(&item); |
12 | 35 | for (i, field) in fields.iter().enumerate() { |
13 | | - let field_size = field.len(); |
| 36 | + let field_size = field.trim().len(); |
14 | 37 | if field_size > sizes[i] { |
15 | 38 | sizes[i] = field_size; |
16 | 39 | } |
17 | 40 | } |
18 | 41 | } |
| 42 | + // Add header size |
| 43 | + let mut headers_sizes = sizes; |
| 44 | + for (i, header) in headers.iter().enumerate() { |
| 45 | + if merged[i] { |
| 46 | + headers_sizes[i] = 0; |
| 47 | + continue; |
| 48 | + } |
| 49 | + let header_size = header.trim().len(); |
| 50 | + let current_size = sizes[i] |
| 51 | + + (i + 1..N) |
| 52 | + .take_while(|&j| merged[j]) |
| 53 | + .map(|j| sizes[j] + 1) |
| 54 | + .sum::<usize>(); |
| 55 | + if header_size > current_size { |
| 56 | + sizes[i] += header_size - current_size; |
| 57 | + headers_sizes[i] = header_size; |
| 58 | + } else { |
| 59 | + headers_sizes[i] = current_size; |
| 60 | + } |
| 61 | + } |
19 | 62 | // Print headers |
20 | 63 | { |
21 | 64 | let mut line = String::new(); |
22 | 65 | for (i, header) in headers.iter().enumerate() { |
23 | | - let size = sizes[i]; |
24 | | - let escaped_header = escape_markdown_cell(header); |
25 | | - write!(line, "| {:<width$} ", escaped_header, width = size).unwrap(); |
| 66 | + let size = headers_sizes[i]; |
| 67 | + if size == 0 { |
| 68 | + continue; |
| 69 | + } |
| 70 | + let right = header.starts_with(' '); |
| 71 | + let center = header.ends_with(' ') && right; |
| 72 | + let escaped_header = escape_markdown_cell(header.trim()); |
| 73 | + if center { |
| 74 | + write!(line, "| {:^width$} ", escaped_header, width = size).unwrap(); |
| 75 | + } else if right { |
| 76 | + write!(line, "| {:>width$} ", escaped_header, width = size).unwrap(); |
| 77 | + } else { |
| 78 | + write!(line, "| {:<width$} ", escaped_header, width = size).unwrap(); |
| 79 | + } |
26 | 80 | } |
27 | | - println!("{} |", line); |
| 81 | + writeln!(write, "{}|", line).unwrap(); |
28 | 82 | } |
29 | 83 | // Print separator |
30 | 84 | { |
31 | 85 | let mut line = String::new(); |
32 | | - for size in sizes.iter() { |
33 | | - write!(line, "| {:-<width$} ", "", width = *size + 2).unwrap(); |
| 86 | + for size in headers_sizes.iter() { |
| 87 | + if *size == 0 { |
| 88 | + continue; |
| 89 | + } |
| 90 | + write!(line, "| {:-<width$} ", "", width = *size).unwrap(); |
34 | 91 | } |
35 | | - println!("{} |", line); |
| 92 | + writeln!(write, "{}|", line).unwrap(); |
36 | 93 | } |
37 | 94 | // Print rows |
38 | 95 | for item in data { |
39 | 96 | let row = get_fields(&item); |
40 | 97 | let mut line = String::new(); |
41 | 98 | for (i, field) in row.iter().enumerate() { |
42 | 99 | let size = sizes[i]; |
43 | | - let escaped_field = escape_markdown_cell(field); |
44 | | - write!(line, "| {:<width$} ", escaped_field, width = size).unwrap(); |
| 100 | + let right = field.starts_with(' '); |
| 101 | + let center = field.ends_with(' ') && right; |
| 102 | + let escaped_field = escape_markdown_cell(field.trim()); |
| 103 | + let separator = if merged[i] { "" } else { "| " }; |
| 104 | + if center { |
| 105 | + write!(line, "{separator}{:^width$} ", escaped_field, width = size).unwrap(); |
| 106 | + } else if right { |
| 107 | + write!(line, "{separator}{:>width$} ", escaped_field, width = size).unwrap(); |
| 108 | + } else { |
| 109 | + write!(line, "{separator}{:<width$} ", escaped_field, width = size).unwrap(); |
| 110 | + } |
45 | 111 | } |
46 | | - println!("{} |", line); |
| 112 | + writeln!(write, "{}|", line).unwrap(); |
47 | 113 | } |
48 | 114 | } |
49 | 115 |
|
50 | 116 | fn escape_markdown_cell(content: &str) -> String { |
51 | 117 | content.replace('|', "\\|").replace('\n', " ") |
52 | 118 | } |
| 119 | + |
| 120 | +#[cfg(test)] |
| 121 | +mod tests { |
| 122 | + use super::write_markdown_table; |
| 123 | + |
| 124 | + #[test] |
| 125 | + fn test_write_markdown_table() { |
| 126 | + let headers = [" Name ", " Age", " Birthday (Age)", " Birthday (Age)"]; |
| 127 | + let data = vec![ |
| 128 | + (" Alice ", " 30", " 1990-01-01", "(30)"), |
| 129 | + (" Bob ", " 25", " 2024", "(9)"), |
| 130 | + (" Charlie ", " 35", " 1985-08-20", "(35)"), |
| 131 | + (" N/A ", " ?", " N/A", "(?)"), |
| 132 | + ]; |
| 133 | + let mut output = Vec::new(); |
| 134 | + write_markdown_table(&mut output, headers, data, |item| { |
| 135 | + [ |
| 136 | + item.0.to_string(), |
| 137 | + item.1.to_string(), |
| 138 | + item.2.to_string(), |
| 139 | + item.3.to_string(), |
| 140 | + ] |
| 141 | + }); |
| 142 | + let output = String::from_utf8(output).unwrap(); |
| 143 | + let expected = r#" |
| 144 | +| Name | Age | Birthday (Age) | |
| 145 | +| ------- | --- | --------------- | |
| 146 | +| Alice | 30 | 1990-01-01 (30) | |
| 147 | +| Bob | 25 | 2024 (9) | |
| 148 | +| Charlie | 35 | 1985-08-20 (35) | |
| 149 | +| N/A | ? | N/A (?) | |
| 150 | +"#; |
| 151 | + assert_eq!( |
| 152 | + output.trim().lines().collect::<Vec<_>>(), |
| 153 | + expected.trim().lines().collect::<Vec<_>>() |
| 154 | + ); |
| 155 | + } |
| 156 | +} |
0 commit comments