use crate::tty::width::{ch_width, str_width};
use std::fmt::Write as _;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Align {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Debug, Default)]
pub struct Column {
pub header: String,
pub align: Align,
pub max_width: Option<usize>,
}
impl Column {
pub fn new(header: impl Into<String>) -> Self {
Self {
header: header.into(),
align: Align::Left,
max_width: None,
}
}
#[must_use]
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
#[must_use]
pub fn max_width(mut self, cells: usize) -> Self {
self.max_width = Some(cells);
self
}
}
const MIN_COL: usize = 3;
fn escape_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'|' => out.push_str("\\|"),
'\n' | '\r' => out.push(' '),
_ => out.push(c),
}
}
out
}
fn truncate_cells(s: &str, max: usize) -> String {
if str_width(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
let cap = max - 1;
let mut out = String::new();
let mut w = 0usize;
for c in s.chars() {
let cw = ch_width(c);
if w + cw > cap {
break;
}
out.push(c);
w += cw;
}
out.push('…');
out
}
fn prepare(s: &str, max: Option<usize>) -> String {
let escaped = escape_cell(s);
match max {
Some(m) => truncate_cells(&escaped, m),
None => escaped,
}
}
fn pad(cell: &str, width: usize, align: Align) -> String {
let slack = width.saturating_sub(str_width(cell));
let (l, r) = match align {
Align::Left => (0, slack),
Align::Right => (slack, 0),
Align::Center => (slack / 2, slack - slack / 2),
};
format!("{}{cell}{}", " ".repeat(l), " ".repeat(r))
}
fn row_line(cells: &[String], widths: &[usize], aligns: &[Align]) -> String {
let mut s = String::from("|");
for (i, w) in widths.iter().enumerate() {
let empty = String::new();
let cell = cells.get(i).unwrap_or(&empty);
let _ = write!(s, " {} |", pad(cell, *w, aligns[i]));
}
s
}
fn delimiter_line(widths: &[usize], aligns: &[Align]) -> String {
let mut s = String::from("|");
for (i, w) in widths.iter().enumerate() {
let bar = match aligns[i] {
Align::Left => "-".repeat(*w),
Align::Right => format!("{}:", "-".repeat(w - 1)),
Align::Center => format!(":{}:", "-".repeat(w - 2)),
};
let _ = write!(s, " {bar} |");
}
s
}
pub fn render_table(columns: &[Column], rows: &[Vec<String>]) -> String {
if columns.is_empty() {
return String::new();
}
let n = columns.len();
let header: Vec<String> = columns
.iter()
.map(|c| prepare(&c.header, c.max_width))
.collect();
let body: Vec<Vec<String>> = rows
.iter()
.map(|r| {
(0..n)
.map(|i| prepare(r.get(i).map_or("", String::as_str), columns[i].max_width))
.collect()
})
.collect();
let aligns: Vec<Align> = columns.iter().map(|c| c.align).collect();
let widths: Vec<usize> = (0..n)
.map(|i| {
body.iter()
.map(|r| str_width(&r[i]))
.chain(std::iter::once(str_width(&header[i])))
.max()
.unwrap_or(0)
.max(MIN_COL)
})
.collect();
let mut out = String::new();
let _ = writeln!(out, "{}", row_line(&header, &widths, &aligns));
let _ = writeln!(out, "{}", delimiter_line(&widths, &aligns));
for r in &body {
let _ = writeln!(out, "{}", row_line(r, &widths, &aligns));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(r: &[&[&str]]) -> Vec<Vec<String>> {
r.iter()
.map(|row| row.iter().map(|c| (*c).to_string()).collect())
.collect()
}
#[test]
fn a_plain_table_is_byte_exact_gfm() {
let cols = [
Column::new("case"),
Column::new("score").align(Align::Right),
];
let got = render_table(&cols, &rows(&[&["a", "1.00"], &["bbbb", "0.50"]]));
assert_eq!(
got,
"\
| case | score |
| ---- | ----: |
| a | 1.00 |
| bbbb | 0.50 |
"
);
}
#[test]
fn cjk_columns_align_in_cells_not_chars() {
let cols = [Column::new("名前")];
let got = render_table(&cols, &rows(&[&["日本"], &["a"]]));
assert_eq!(
got,
"\
| 名前 |
| ---- |
| 日本 |
| a |
"
);
}
#[test]
fn a_pipe_in_a_cell_cannot_break_the_row() {
let cols = [Column::new("cmd")];
let got = render_table(&cols, &rows(&[&["a | b"]]));
assert_eq!(
got,
"\
| cmd |
| ------ |
| a \\| b |
"
);
assert_eq!(got.lines().count(), 3);
}
#[test]
fn a_newline_in_a_cell_cannot_break_the_table() {
let cols = [Column::new("d")];
let got = render_table(&cols, &rows(&[&["one\ntwo"]]));
assert_eq!(got.lines().count(), 3, "still exactly three lines: {got:?}");
assert!(got.contains("| one two |"));
}
#[test]
fn a_capped_cell_truncates_on_a_cell_boundary() {
let cols = [Column::new("x").max_width(3)];
let got = render_table(&cols, &rows(&[&["日本語"]]));
assert_eq!(
got,
"\
| x |
| --- |
| 日… |
"
);
}
#[test]
fn alignment_travels_in_the_delimiter_row() {
let cols = [
Column::new("a"),
Column::new("b").align(Align::Center),
Column::new("c").align(Align::Right),
];
let got = render_table(&cols, &[]);
assert_eq!(
got,
"\
| a | b | c |
| --- | :-: | --: |
"
);
}
#[test]
fn a_ragged_row_cannot_emit_a_ragged_table() {
let cols = [Column::new("h1"), Column::new("h2")];
let got = render_table(&cols, &rows(&[&["a"], &["a", "b", "c"]]));
assert_eq!(
got,
"\
| h1 | h2 |
| --- | --- |
| a | |
| a | b |
"
);
}
#[test]
fn no_columns_renders_nothing() {
assert_eq!(render_table(&[], &rows(&[&["a"]])), "");
}
}