use std::fmt::Write as _;
use unicode_width::UnicodeWidthStr;
const COLUMN_GAP: &str = " ";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColumnAlign {
Left,
Right,
}
pub fn render_table(
headers: &[String],
rows: &[Vec<String>],
alignments: &[ColumnAlign],
) -> String {
debug_assert_eq!(headers.len(), alignments.len());
debug_assert!(rows.iter().all(|row| row.len() == headers.len()));
let widths = table_widths(headers, rows);
let mut lines = Vec::with_capacity(rows.len() + 2);
lines.push(render_row(headers, &widths, alignments));
lines.push(
widths
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<_>>()
.join(COLUMN_GAP),
);
lines.extend(rows.iter().map(|row| render_row(row, &widths, alignments)));
lines.join("\n")
}
pub fn sanitize_text(value: &str) -> String {
let mut sanitized = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\n' => sanitized.push_str("\\n"),
'\r' => sanitized.push_str("\\r"),
'\t' => sanitized.push_str("\\t"),
character if character.is_control() => {
write!(sanitized, "\\u{{{:x}}}", u32::from(character))
.expect("writing to a String cannot fail");
}
character => sanitized.push(character),
}
}
sanitized
}
fn table_widths(headers: &[String], rows: &[Vec<String>]) -> Vec<usize> {
let mut widths = headers
.iter()
.map(|header| UnicodeWidthStr::width(sanitize_text(header).as_str()))
.collect::<Vec<_>>();
for row in rows {
for (index, cell) in row.iter().enumerate() {
if let Some(width) = widths.get_mut(index) {
*width = (*width).max(UnicodeWidthStr::width(sanitize_text(cell).as_str()));
}
}
}
widths
}
fn render_row(row: &[String], widths: &[usize], alignments: &[ColumnAlign]) -> String {
widths
.iter()
.zip(alignments)
.enumerate()
.map(|(index, (width, alignment))| {
let value = sanitize_text(row.get(index).map_or("", String::as_str));
let padding = " ".repeat(width.saturating_sub(UnicodeWidthStr::width(value.as_str())));
match alignment {
ColumnAlign::Left => format!("{value}{padding}"),
ColumnAlign::Right => format!("{padding}{value}"),
}
})
.collect::<Vec<_>>()
.join(COLUMN_GAP)
.trim_end()
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tables_align_numbers_and_escape_control_characters() {
let headers = vec!["NAME".to_owned(), "COUNT".to_owned()];
let rows = vec![
vec!["Aquapolis".to_owned(), "545".to_owned()],
vec!["The\nDark".to_owned(), "2".to_owned()],
];
let rendered = render_table(&headers, &rows, &[ColumnAlign::Left, ColumnAlign::Right]);
let aquapolis = rendered
.lines()
.find(|line| line.starts_with("Aquapolis"))
.expect("Aquapolis row");
let dark = rendered
.lines()
.find(|line| line.starts_with("The\\nDark"))
.expect("escaped Dark row");
assert!(aquapolis.ends_with(" 545"));
assert!(dark.ends_with(" 2"));
assert!(!rendered.contains("The\nDark"));
}
}