460 lines
14 KiB
Rust
460 lines
14 KiB
Rust
//! Generic Nushell-style tables: render a boxed table, filter rows with
|
|
//! where-like predicates and select a column with get.
|
|
|
|
use anyhow::{bail, Result};
|
|
use colored::Colorize;
|
|
|
|
/// One cell: the text to display plus a typed value for filtering
|
|
/// (number, byte size, or plain string).
|
|
#[derive(Debug, Clone)]
|
|
pub struct Cell {
|
|
pub text: String,
|
|
pub num: Option<f64>,
|
|
pub bytes: Option<u64>,
|
|
}
|
|
|
|
impl Cell {
|
|
pub fn str(s: impl Into<String>) -> Self {
|
|
Cell {
|
|
text: s.into(),
|
|
num: None,
|
|
bytes: None,
|
|
}
|
|
}
|
|
|
|
pub fn num(n: f64, text: String) -> Self {
|
|
Cell {
|
|
text,
|
|
num: Some(n),
|
|
bytes: None,
|
|
}
|
|
}
|
|
|
|
pub fn bytes(b: u64, text: String) -> Self {
|
|
Cell {
|
|
text,
|
|
num: None,
|
|
bytes: Some(b),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tabular result produced by ls, ps, where or get.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct DataTable {
|
|
pub columns: Vec<String>,
|
|
pub rows: Vec<Vec<Cell>>,
|
|
}
|
|
|
|
impl DataTable {
|
|
pub fn column_index(&self, name: &str) -> Option<usize> {
|
|
self.columns
|
|
.iter()
|
|
.position(|c| c.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
/// Render the boxed Nushell-style table, fitting max_width columns.
|
|
/// When the natural layout is too wide, the first (name) column is
|
|
/// truncated with an ellipsis and the trailing columns are dropped if
|
|
/// that is still not enough — every row stays on a single line.
|
|
pub fn render(&self, color: bool, max_width: usize) -> String {
|
|
self.render_with(None, color, max_width)
|
|
}
|
|
|
|
/// Render with the colors of a theme (borders dimmed, header row in the
|
|
/// theme's header style instead of plain bold).
|
|
pub fn render_themed(
|
|
&self,
|
|
theme: &crate::theme::Theme,
|
|
color: bool,
|
|
max_width: usize,
|
|
) -> String {
|
|
self.render_with(Some(theme), color, max_width)
|
|
}
|
|
|
|
fn render_with(
|
|
&self,
|
|
theme: Option<&crate::theme::Theme>,
|
|
color: bool,
|
|
max_width: usize,
|
|
) -> String {
|
|
let max_width = max_width.max(20);
|
|
let headers = self.columns.clone();
|
|
if headers.is_empty() {
|
|
return String::new();
|
|
}
|
|
// Column 0 is the row index; the data columns follow.
|
|
let index_width = self.rows.len().to_string().len().max(1);
|
|
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
|
let mut cells: Vec<Vec<String>> = Vec::new();
|
|
for (i, row) in self.rows.iter().enumerate() {
|
|
let mut line = vec![i.to_string()];
|
|
for cell in row {
|
|
line.push(cell.text.clone());
|
|
}
|
|
for (c, cell) in line.iter().enumerate().skip(1) {
|
|
natural[c - 1] = natural[c - 1].max(visible_len(cell));
|
|
}
|
|
cells.push(line);
|
|
}
|
|
let line_width = |ncols: usize, widths: &[usize]| -> usize {
|
|
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
|
|
};
|
|
|
|
// Choose the final column set and widths.
|
|
let mut data_cols = headers.len();
|
|
let mut widths: Vec<usize> = natural.clone();
|
|
loop {
|
|
if data_cols == 0 {
|
|
break;
|
|
}
|
|
widths.truncate(data_cols);
|
|
if line_width(data_cols, &widths) <= max_width {
|
|
// Expand the first column into the remaining slack.
|
|
widths[0] += max_width - line_width(data_cols, &widths);
|
|
break;
|
|
}
|
|
// Cap the non-first columns, then give the first what remains.
|
|
let mut capped = widths.clone();
|
|
for w in capped.iter_mut().skip(1) {
|
|
*w = (*w).min(16);
|
|
}
|
|
if line_width(data_cols, &capped) <= max_width {
|
|
capped[0] += max_width - line_width(data_cols, &capped);
|
|
widths = capped;
|
|
break;
|
|
}
|
|
if data_cols == 1 {
|
|
// Last resort: shrink the single column to whatever remains
|
|
// after the borders and the index column.
|
|
let fixed = 1 + 3 * (data_cols + 1) + index_width;
|
|
capped[0] = max_width.saturating_sub(fixed).max(1);
|
|
widths = capped;
|
|
break;
|
|
}
|
|
data_cols -= 1;
|
|
}
|
|
|
|
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
|
.chain(headers.iter().take(data_cols).cloned())
|
|
.collect();
|
|
let mut all_widths = vec![index_width];
|
|
all_widths.extend(widths.iter().cloned());
|
|
|
|
let border = |left: char, mid: char, right: char| -> String {
|
|
let mut line = String::new();
|
|
line.push(left);
|
|
for (i, w) in all_widths.iter().enumerate() {
|
|
if i > 0 {
|
|
line.push(mid);
|
|
}
|
|
line.push_str(&"─".repeat(w + 2));
|
|
}
|
|
line.push(right);
|
|
line
|
|
};
|
|
let bar = |s: &str| -> String {
|
|
if !color {
|
|
return s.to_string();
|
|
}
|
|
match theme {
|
|
Some(t) => t.frame(s),
|
|
None => s.to_string(),
|
|
}
|
|
};
|
|
let row_line = |row: &[String], header: bool| -> String {
|
|
let mut line = bar("│");
|
|
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
|
|
line.push(' ');
|
|
let cell = fit_cell(c, all_widths[i]);
|
|
let rendered = if header {
|
|
match theme {
|
|
Some(t) if color => t.hdr(&cell),
|
|
_ => cell.bold().to_string(),
|
|
}
|
|
} else {
|
|
cell
|
|
};
|
|
line.push_str(&rendered);
|
|
line.push(' ');
|
|
line.push_str(&bar("│"));
|
|
}
|
|
line
|
|
};
|
|
let edge = |s: &str| -> String {
|
|
if !color {
|
|
return s.to_string();
|
|
}
|
|
match theme {
|
|
Some(t) => t.frame(s),
|
|
None => s.dimmed().to_string(),
|
|
}
|
|
};
|
|
|
|
let mut out = String::new();
|
|
out.push_str(&edge(&border('╭', '┬', '╮')));
|
|
out.push('\n');
|
|
out.push_str(&row_line(&all_headers, true));
|
|
out.push('\n');
|
|
out.push_str(&edge(&border('├', '┼', '┤')));
|
|
out.push('\n');
|
|
for line in &cells {
|
|
out.push_str(&row_line(line, false));
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&edge(&border('╰', '┴', '╯')));
|
|
out.push('\n');
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
|
|
/// become numbers, everything else is a string.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum WhereValue {
|
|
Num(f64),
|
|
Bytes(u64),
|
|
Str(String),
|
|
}
|
|
|
|
fn parse_value(raw: &str) -> WhereValue {
|
|
let lower = raw.trim().to_lowercase();
|
|
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
|
|
if let Some(n) = lower.strip_suffix(suffix) {
|
|
if let Ok(v) = n.trim().parse::<f64>() {
|
|
return WhereValue::Bytes((v * mult as f64) as u64);
|
|
}
|
|
}
|
|
}
|
|
if let Ok(v) = raw.trim().parse::<f64>() {
|
|
return WhereValue::Num(v);
|
|
}
|
|
WhereValue::Str(raw.trim().to_string())
|
|
}
|
|
|
|
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
|
|
match op {
|
|
">" => a > b,
|
|
"<" => a < b,
|
|
">=" => a >= b,
|
|
"<=" => a <= b,
|
|
"==" => (a - b).abs() < f64::EPSILON,
|
|
"!=" => (a - b).abs() >= f64::EPSILON,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Filter the rows of a table: where <field> <op> <value>.
|
|
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
|
|
let Some(idx) = table.column_index(field) else {
|
|
bail!(
|
|
"unknown column '{field}' — available: {}",
|
|
table.columns.join(", ")
|
|
);
|
|
};
|
|
let val = parse_value(value);
|
|
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
|
|
if !supported {
|
|
bail!("unknown operator '{op}' — use > < >= <= == != =~");
|
|
}
|
|
let mut out = table.clone();
|
|
out.rows.retain(|row| match row.get(idx) {
|
|
None => false,
|
|
Some(cell) => match &val {
|
|
WhereValue::Num(n) => match cell.num {
|
|
Some(v) => num_cmp(v, *n, op),
|
|
None => false,
|
|
},
|
|
WhereValue::Bytes(b) => match cell.bytes {
|
|
Some(v) => num_cmp(v as f64, *b as f64, op),
|
|
None => false,
|
|
},
|
|
WhereValue::Str(s) => {
|
|
let text = strip_ansi(&cell.text);
|
|
match op {
|
|
"==" => text.eq_ignore_ascii_case(s),
|
|
"!=" => !text.eq_ignore_ascii_case(s),
|
|
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
|
|
_ => false,
|
|
}
|
|
}
|
|
},
|
|
});
|
|
Ok(out)
|
|
}
|
|
|
|
/// Select one column: get <column>.
|
|
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
|
|
let Some(idx) = table.column_index(field) else {
|
|
bail!(
|
|
"unknown column '{field}' — available: {}",
|
|
table.columns.join(", ")
|
|
);
|
|
};
|
|
Ok(DataTable {
|
|
columns: vec![field.to_string()],
|
|
rows: table
|
|
.rows
|
|
.iter()
|
|
.map(|row| vec![row[idx].clone()])
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// Pad a cell to its column width, or truncate it (with an ellipsis,
|
|
/// preserving any leading color codes) when it is too wide.
|
|
fn fit_cell(s: &str, w: usize) -> String {
|
|
let n = visible_len(s);
|
|
if n <= w {
|
|
return format!("{s}{}", " ".repeat(w - n));
|
|
}
|
|
let plain = strip_ansi(s);
|
|
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
|
|
cut.push('…');
|
|
if s.contains('') {
|
|
let mut prefix = String::new();
|
|
let mut chars = s.chars().peekable();
|
|
while chars.peek() == Some(&'') {
|
|
for ch in chars.by_ref() {
|
|
prefix.push(ch);
|
|
if ch == 'm' {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
format!("{prefix}{cut}[0m")
|
|
} else {
|
|
cut
|
|
}
|
|
}
|
|
|
|
fn strip_ansi(s: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut chars = s.chars();
|
|
while let Some(c) = chars.next() {
|
|
if c == '\u{1b}' {
|
|
for n in chars.by_ref() {
|
|
if n == 'm' {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn visible_len(s: &str) -> usize {
|
|
strip_ansi(s).chars().count()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample() -> DataTable {
|
|
DataTable {
|
|
columns: vec!["name".into(), "type".into(), "size".into()],
|
|
rows: vec![
|
|
vec![
|
|
Cell::str("src"),
|
|
Cell::str("dir"),
|
|
Cell::bytes(2048, "2.0 KB".into()),
|
|
],
|
|
vec![
|
|
Cell::str("am.exe"),
|
|
Cell::str("file"),
|
|
Cell::bytes(5_000_000, "4.8 MB".into()),
|
|
],
|
|
vec![
|
|
Cell::str("readme.md"),
|
|
Cell::str("file"),
|
|
Cell::bytes(512, "512 B".into()),
|
|
],
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn filters_numeric_and_size() {
|
|
let t = sample();
|
|
let big = filter_where(&t, "size", ">", "1mb").unwrap();
|
|
assert_eq!(big.rows.len(), 1);
|
|
assert_eq!(big.rows[0][0].text, "am.exe");
|
|
let eq = filter_where(&t, "type", "==", "dir").unwrap();
|
|
assert_eq!(eq.rows.len(), 1);
|
|
let contains = filter_where(&t, "name", "=~", "read").unwrap();
|
|
assert_eq!(contains.rows.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn get_selects_column() {
|
|
let t = sample();
|
|
let names = get_column(&t, "name").unwrap();
|
|
assert_eq!(names.columns, vec!["name".to_string()]);
|
|
assert_eq!(names.rows.len(), 3);
|
|
assert_eq!(names.rows[1][0].text, "am.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_column_errors() {
|
|
let t = sample();
|
|
assert!(filter_where(&t, "nope", "==", "x").is_err());
|
|
assert!(get_column(&t, "nope").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_where_values() {
|
|
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
|
|
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
|
|
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
|
|
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn render_is_aligned() {
|
|
let t = sample();
|
|
let text = t.render(false, 200);
|
|
assert!(text.contains("╭"));
|
|
assert!(text.contains("name"));
|
|
assert!(text.contains("am.exe"));
|
|
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
|
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_fits_narrow_width() {
|
|
// A table whose first column is too long to fit naturally.
|
|
let t = DataTable {
|
|
columns: vec!["name".into(), "type".into(), "size".into()],
|
|
rows: vec![vec![
|
|
Cell::str("a-very-long-file-name-that-overflows.txt"),
|
|
Cell::str("file"),
|
|
Cell::bytes(512, "512 B".into()),
|
|
]],
|
|
};
|
|
// Trailing columns are dropped when too narrow.
|
|
let text = t.render(false, 45);
|
|
for line in text.lines() {
|
|
assert!(line.chars().count() <= 45, "too wide: {line}");
|
|
}
|
|
assert!(!text.contains("size"));
|
|
// The name column is truncated with an ellipsis at the very end.
|
|
let text = t.render(false, 30);
|
|
for line in text.lines() {
|
|
assert!(line.chars().count() <= 30, "too wide: {line}");
|
|
}
|
|
assert!(text.contains("…"), "name should be truncated:
|
|
{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn fit_cell_truncates_and_keeps_color() {
|
|
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
|
|
let out = fit_cell(&colored, 8);
|
|
assert_eq!(strip_ansi(&out).chars().count(), 8);
|
|
assert!(out.starts_with("\u{1b}["));
|
|
}
|
|
}
|