Skip to main content

wowlab_common/markdown/
table.rs

1// #t(file: rust_alloc_in_loop) markdown table builder produces owned strings per element, unavoidable
2
3use std::fmt::Display;
4
5const DEFAULT_TABLE_PADDING: usize = 2;
6const HEADER_ROW_COUNT: usize = 2;
7
8/// Escape text for a markdown table cell (`|` becomes `\|`).
9#[must_use]
10pub fn escape_table_cell(text: &str) -> String {
11    text.replace('|', r"\|")
12}
13
14/// Markdown table builder with multiple render modes.
15#[derive(Debug)]
16pub struct Table {
17    headers: Vec<Box<str>>,
18    rows: Vec<Box<[Box<str>]>>,
19    padding: usize,
20}
21
22impl Table {
23    #[must_use]
24    pub fn new() -> Self {
25        Self {
26            headers: Vec::new(),
27            rows: Vec::new(),
28            padding: DEFAULT_TABLE_PADDING,
29        }
30    }
31
32    #[must_use]
33    pub fn headers<I, T>(mut self, headers: I) -> Self
34    where
35        I: IntoIterator<Item = T>,
36        T: Display,
37    {
38        self.headers = headers
39            .into_iter()
40            .map(|header| header.to_string().into_boxed_str())
41            .collect();
42
43        self
44    }
45
46    #[must_use]
47    pub fn row<I, T>(mut self, values: I) -> Self
48    where
49        I: IntoIterator<Item = T>,
50        T: Display,
51    {
52        self.rows.push(
53            values
54                .into_iter()
55                .map(|value| value.to_string().into_boxed_str())
56                .collect::<Vec<_>>()
57                .into_boxed_slice(),
58        );
59
60        self
61    }
62
63    #[must_use]
64    pub fn rows<I, R, T>(mut self, rows: I) -> Self
65    where
66        I: IntoIterator<Item = R>,
67        R: IntoIterator<Item = T>,
68        T: Display,
69    {
70        for row in rows {
71            self.rows.push(
72                row.into_iter()
73                    .map(|value| value.to_string().into_boxed_str())
74                    .collect::<Vec<_>>()
75                    .into_boxed_slice(),
76            );
77        }
78
79        self
80    }
81
82    #[must_use]
83    pub fn padding(mut self, padding: usize) -> Self {
84        self.padding = padding;
85
86        self
87    }
88
89    /// Render as a plain-text aligned table (no markdown separators).
90    #[must_use]
91    pub fn build_plain(self) -> String {
92        let widths = self.column_widths();
93        let mut lines = Vec::with_capacity(
94            self.rows.len() + usize::from(!self.headers.is_empty()) * HEADER_ROW_COUNT,
95        );
96        let pad = " ".repeat(self.padding);
97
98        if !self.headers.is_empty() {
99            let header_line: Vec<String> = self
100                .headers
101                .iter()
102                .enumerate()
103                .map(|(i, h)| format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(0)))
104                .collect();
105
106            lines.push(header_line.join(&pad).trim_end().to_string());
107
108            let sep: Vec<String> = widths.iter().map(|&w| "─".repeat(w)).collect();
109
110            lines.push(sep.join(&pad));
111        }
112
113        for row in &self.rows {
114            let row_line: Vec<String> = row
115                .iter()
116                .enumerate()
117                .map(|(i, cell)| {
118                    format!(
119                        "{:width$}",
120                        cell,
121                        width = widths.get(i).copied().unwrap_or(0)
122                    )
123                })
124                .collect();
125
126            lines.push(row_line.join(&pad).trim_end().to_string());
127        }
128
129        lines.join("\n")
130    }
131
132    /// Render as a proper markdown table with `|` delimiters and `---` separator.
133    #[must_use]
134    pub fn build_markdown(self) -> String {
135        let widths = self.column_widths();
136        let mut lines = Vec::with_capacity(
137            self.rows.len() + usize::from(!self.headers.is_empty()) * HEADER_ROW_COUNT,
138        );
139
140        if !self.headers.is_empty() {
141            let header_line: Vec<String> = self
142                .headers
143                .iter()
144                .enumerate()
145                .map(|(i, h)| format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(0)))
146                .collect();
147
148            lines.push(format!("| {} |", header_line.join(" | ")));
149
150            let sep: Vec<String> = widths.iter().map(|&w| "-".repeat(w)).collect();
151
152            lines.push(format!("| {} |", sep.join(" | ")));
153        }
154
155        for row in &self.rows {
156            let row_line: Vec<String> = row
157                .iter()
158                .enumerate()
159                .map(|(i, cell)| {
160                    let escaped = escape_table_cell(cell);
161
162                    format!(
163                        "{:width$}",
164                        escaped,
165                        width = widths.get(i).copied().unwrap_or(0)
166                    )
167                })
168                .collect();
169
170            lines.push(format!("| {} |", row_line.join(" | ")));
171        }
172
173        lines.join("\n")
174    }
175
176    /// Render as a fenced code block (for contexts that don't support markdown tables).
177    #[must_use]
178    pub fn build(self) -> String {
179        format!("```\n{}\n```", self.build_plain())
180    }
181
182    fn column_widths(&self) -> Vec<usize> {
183        let col_count = self
184            .headers
185            .len()
186            .max(self.rows.first().map_or(0, |r| r.len()));
187
188        let mut widths = vec![0; col_count];
189
190        for (i, header) in self.headers.iter().enumerate() {
191            // #t(rust_unchecked_indexing) i < headers.len() <= col_count == widths.len()
192            widths[i] = widths[i].max(header.chars().count());
193        }
194
195        for row in &self.rows {
196            for (i, cell) in row.iter().enumerate() {
197                if i < widths.len() {
198                    let pipe_extra = cell.chars().filter(|&c| c == '|').count();
199                    // BOUNDS: i < widths.len() is checked by the enclosing `if` guard on line above.
200
201                    widths[i] = widths[i].max(cell.chars().count() + pipe_extra);
202                }
203            }
204        }
205
206        widths
207    }
208}
209
210impl Default for Table {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// Render a single markdown table row with pipe-escaped cells.
217pub fn render_table_row(cells: &[impl AsRef<str>]) -> String {
218    let escaped: Vec<String> = cells
219        .iter()
220        .map(|cell| escape_table_cell(cell.as_ref()))
221        .collect();
222
223    format!("| {} |", escaped.join(" | "))
224}