use serde::Serialize;
use unicode_width::UnicodeWidthStr;
use super::{Renderer, Writer, role_glyph};
use crate::output::{Role, Verbosity};
#[derive(Debug, Clone, Serialize)]
pub struct Table {
pub(crate) headers: Vec<String>,
pub(crate) rows: Vec<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) row_roles: Vec<Vec<Option<Role>>>,
}
impl Table {
pub fn new(headers: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
headers: headers.into_iter().map(Into::into).collect(),
rows: Vec::new(),
row_roles: Vec::new(),
}
}
pub fn row(mut self, row: impl IntoIterator<Item = impl Into<String>>) -> Self {
let cells: Vec<String> = row.into_iter().map(Into::into).collect();
self.row_roles.push(vec![None; cells.len()]);
self.rows.push(cells);
self
}
pub fn row_styled<I, S>(mut self, row: I) -> Self
where
I: IntoIterator<Item = (S, Option<Role>)>,
S: Into<String>,
{
let mut cells = Vec::new();
let mut roles = Vec::new();
for (value, role) in row {
cells.push(value.into());
roles.push(role);
}
self.rows.push(cells);
self.row_roles.push(roles);
self
}
}
impl Renderer {
pub fn render_table(&self, w: &dyn Writer, depth: usize, t: &Table) {
if self.verbosity == Verbosity::Quiet || t.headers.is_empty() {
return;
}
self.flush_pending_section_headers(w);
let cols = t.headers.len();
let mut widths = vec![0usize; cols];
for (i, h) in t.headers.iter().enumerate() {
widths[i] = UnicodeWidthStr::width(h.as_str());
}
for row in &t.rows {
for (i, cell) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(UnicodeWidthStr::width(cell.as_str()));
}
}
let header_line: String = t
.headers
.iter()
.enumerate()
.map(|(i, h)| {
let pad = widths[i].saturating_sub(UnicodeWidthStr::width(h.as_str()));
format!("{h}{}", " ".repeat(pad))
})
.collect::<Vec<_>>()
.join(" ");
let styled = self.theme.header.apply_to(&header_line).to_string();
self.write_line(w, depth, &styled);
let sep: String = widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join("──");
let dim = self.theme.muted.apply_to(&sep).to_string();
self.write_line(w, depth, &dim);
let empty_roles: Vec<Option<Role>> = Vec::new();
for (row_idx, row) in t.rows.iter().enumerate() {
let roles_for_row = t.row_roles.get(row_idx).unwrap_or(&empty_roles);
let line: String = row
.iter()
.enumerate()
.take(cols)
.map(|(i, cell)| {
let pad = widths[i].saturating_sub(UnicodeWidthStr::width(cell.as_str()));
let padded = format!("{cell}{}", " ".repeat(pad));
match roles_for_row.get(i).and_then(|r| *r) {
Some(role) => {
let (_icon, style) = role_glyph(&self.theme, role);
style.apply_to(padded).to_string()
}
None => padded,
}
})
.collect::<Vec<_>>()
.join(" ");
self.write_line(w, depth, &line);
}
self.mark_top_level_blank_if_at_root();
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::super::StringSink;
use super::*;
use crate::output::Theme;
#[test]
fn empty_table_emits_nothing() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(std::iter::empty::<String>());
r.render_table(&sink, 0, &t);
assert!(buf.lock().unwrap().is_empty());
}
#[test]
fn header_then_separator_then_rows() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(["Name", "Age"])
.row(["alice", "30"])
.row(["bob", "25"]);
r.render_table(&sink, 0, &t);
let out = buf.lock().unwrap();
assert!(out.contains("Name"));
assert!(out.contains("Age"));
assert!(out.contains("─"));
assert!(out.contains("alice"));
assert!(out.contains("bob"));
}
#[test]
fn row_appends_keep_rows_and_roles_in_lockstep() {
let t = Table::new(["a", "b"]).row(["1", "2"]).row(["3", "4"]);
assert_eq!(t.rows.len(), t.row_roles.len());
assert_eq!(t.rows[0].len(), t.row_roles[0].len());
assert_eq!(t.rows[1].len(), t.row_roles[1].len());
}
#[test]
fn row_styled_appends_keep_rows_and_roles_in_lockstep() {
let t = Table::new(["a", "b"])
.row(["plain", "plain"])
.row_styled([("styled", Some(Role::Ok)), ("nostyle", None)]);
assert_eq!(t.rows.len(), t.row_roles.len());
assert_eq!(t.row_roles[1], vec![Some(Role::Ok), None]);
}
fn prefix_display_width(line: &str, needle: &str) -> usize {
let idx = line
.find(needle)
.unwrap_or_else(|| panic!("needle {needle:?} missing in line {line:?}"));
UnicodeWidthStr::width(&line[..idx])
}
#[test]
fn table_aligns_cjk_cells_by_display_width() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(["Name", "Score"])
.row(["京都", "100"])
.row(["Tokyo", "200"]);
r.render_table(&sink, 0, &t);
let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
let lines: Vec<&str> = out.lines().collect();
let kyoto = lines
.iter()
.find(|l| l.contains("京都"))
.expect("kyoto row missing");
let tokyo = lines
.iter()
.find(|l| l.contains("Tokyo"))
.expect("tokyo row missing");
assert_eq!(
prefix_display_width(kyoto, "100"),
prefix_display_width(tokyo, "200"),
"CJK and ASCII rows must align by display width.\nout:\n{out}"
);
}
#[test]
fn table_aligns_emoji_cells_by_display_width() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(["Status", "Note"])
.row(["✓", "ok"])
.row(["⚠️", "warn"])
.row(["complete", "yep"]);
r.render_table(&sink, 0, &t);
let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
let ok_line = lines
.iter()
.find(|l| l.contains(" ok"))
.expect("ok row missing");
let warn_line = lines
.iter()
.find(|l| l.contains("warn"))
.expect("warn row missing");
let yep_line = lines
.iter()
.find(|l| l.contains("yep"))
.expect("yep row missing");
let positions = [
prefix_display_width(ok_line, "ok"),
prefix_display_width(warn_line, "warn"),
prefix_display_width(yep_line, "yep"),
];
let first = positions[0];
for (i, p) in positions.iter().enumerate() {
assert_eq!(
*p, first,
"row {i} note column misaligned, got width {p}, expected {first}\nout:\n{out}"
);
}
}
#[test]
fn table_aligns_cyrillic_cells_by_display_width() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(["Name", "City"])
.row(["Москва", "ru"])
.row(["Paris", "fr"]);
r.render_table(&sink, 0, &t);
let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
let lines: Vec<&str> = out.lines().collect();
let ru = lines
.iter()
.find(|l| l.contains("Москва"))
.expect("ru row missing");
let fr = lines
.iter()
.find(|l| l.contains("Paris"))
.expect("fr row missing");
assert_eq!(
prefix_display_width(ru, "ru"),
prefix_display_width(fr, "fr"),
"Cyrillic and ASCII rows must align by display width.\nout:\n{out}"
);
}
#[test]
fn table_does_not_panic_on_tab_or_control_chars_in_cells() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let t = Table::new(["Key", "Value"])
.row(["a\tb\x0Bc", "ok"])
.row(["plain", "yep"]);
r.render_table(&sink, 0, &t);
let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
assert!(out.contains("ok"), "missing ok row: {out:?}");
assert!(out.contains("yep"), "missing yep row: {out:?}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
let ok_line = lines
.iter()
.find(|l| l.contains("ok"))
.expect("ok row missing");
let yep_line = lines
.iter()
.find(|l| l.contains("yep"))
.expect("yep row missing");
assert_eq!(
prefix_display_width(ok_line, "ok"),
prefix_display_width(yep_line, "yep"),
"tab/control-bearing row and plain row should align to the same display column"
);
}
}