Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ anstyle = "1"
anstream = "1"
clap = { version = "4", features = ["derive"] }
csv = "1"
memchr = "2"
natord = "1"
num-format = "0.4"
rand = "0.8"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ We love CSV tools and use them all the time! Here are a few that we rely on:
- improve zebra, blend with terminal bg #64 (@RVC2020)
- store typed cells, honor locale #66 (@RVC2020)
- support col args by index #66 (@RVC2020)
- crude support for ansi in cells #72 (@RVC2020)
- merged unreleased crate back into app

#### 0.7.1 (Jul '26)
Expand Down
32 changes: 27 additions & 5 deletions src/cell.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
//! Table cell text and value.
//! Table cell text, style, and value.

use std::{borrow::Cow, ops::Deref};
use std::{borrow::Cow, ops::Deref, rc::Rc};

use crate::{Value, util};

/// Display text plus its normalized numeric value, when the column is numeric.
/// Display text plus an optional incoming style and normalized numeric value.
/// Formatting may change `text`; `value` remains stable for sorting and scales.
#[derive(Clone, Debug, PartialEq)]
pub struct Cell {
text: String,
style: Option<Rc<String>>,
value: Option<Value>,
}

impl Cell {
// Cells parse immediately; Grid later normalizes the column.
// Numeric values parse immediately; Grid later normalizes the column.
pub fn parse(text: String) -> Self {
Self::parse_styled(text, None)
}

pub fn parse_styled(text: String, style: Option<Rc<String>>) -> Self {
let value = Value::parse(&text);
Self { text, value }
Self { text, style, value }
}

pub fn as_str(&self) -> &str {
Expand All @@ -27,6 +32,10 @@ impl Cell {
self.value.as_ref()
}

pub fn style(&self) -> Option<&str> {
self.style.as_deref().map(String::as_str)
}

pub fn squish(&mut self) {
if let Cow::Owned(text) = util::squish(&self.text) {
self.value = Value::parse(&text);
Expand Down Expand Up @@ -109,3 +118,16 @@ impl PartialEq<Cell> for String {
self == &other.text
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_squish_preserves_style() {
let mut cell = Cell::parse_styled(" alice smith ".to_owned(), Some(Rc::new("\x1b[31m".to_owned())));
cell.squish();
assert_eq!("alice smith", cell.as_str());
assert_eq!(Some("\x1b[31m"), cell.style());
}
}
121 changes: 120 additions & 1 deletion src/input/csv.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
use std::rc::Rc;

use crate::{Cell, Error, Grid, Result};

const ESC: u8 = b'\x1b';
const RESET: &str = "\x1b[0m";
const SHORT_RESET: &str = "\x1b[m";

//
// CSV loading
//

/// Parse CSV bytes with the selected delimiter.
pub fn load(bytes: &[u8], delimiter: u8) -> Result<Grid> {
// read csv
// 20k rows × 12 cells, 10 release runs: main 26.62 ms; plain 28.38 ms;
// ANSI on 1% of rows 30.24 ms.
if memchr::memchr(ESC, bytes).is_some() { load_ansi(bytes, delimiter) } else { load_plain(bytes, delimiter) }
}

fn load_plain(bytes: &[u8], delimiter: u8) -> Result<Grid> {
let mut reader = csv::ReaderBuilder::new().has_headers(false).delimiter(delimiter).from_reader(bytes);
let mut records = reader.byte_records();
let Some(headers) = records.next() else {
Expand All @@ -20,7 +31,23 @@ pub fn load(bytes: &[u8], delimiter: u8) -> Result<Grid> {
let row = csv::StringRecord::from_byte_record_lossy(row.map_err(csv_error)?);
rows.push(row.iter().map(Cell::from).collect());
}
Ok(Grid::from_cells(headers, rows).expect("csv reader rejects jagged rows"))
}

fn load_ansi(bytes: &[u8], delimiter: u8) -> Result<Grid> {
let mut reader = csv::ReaderBuilder::new().has_headers(false).delimiter(delimiter).from_reader(bytes);
let mut records = reader.byte_records();
let Some(headers) = records.next() else {
return Ok(Grid::new(Vec::new(), Vec::new()).expect("empty grid is rectangular"));
};
let headers = csv::StringRecord::from_byte_record_lossy(headers.map_err(csv_error)?);
let headers = headers.iter().map(strip_ansi).collect();

let mut rows = Vec::new();
for row in records {
let row = csv::StringRecord::from_byte_record_lossy(row.map_err(csv_error)?);
rows.push(parse_ansi_row(&row));
}
Ok(Grid::from_cells(headers, rows).expect("csv reader rejects jagged rows"))
}

Expand All @@ -31,6 +58,62 @@ fn csv_error(error: csv::Error) -> Error {
}
}

//
// ansi helpers
//

fn parse_ansi_row(row: &csv::StringRecord) -> Vec<Cell> {
let mut cur = None;
row
.iter()
.map(|text| {
if !text.as_bytes().contains(&ESC) {
return Cell::parse_styled(text.to_owned(), cur.clone());
}

// check out start/end of cell
let opening_sgr = opening_sgr(text);
let closing_reset = ends_with_reset(text);
if let Some(style) = opening_sgr {
cur = Some(Rc::new(style.to_owned()));
}

// create cell
let cell = Cell::parse_styled(strip_ansi(text), cur.clone());

if closing_reset {
cur = None;
}
cell
})
.collect()
}

fn opening_sgr(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
if !bytes.starts_with(b"\x1b[") {
return None;
}
let end = bytes[2..].iter().position(|byte| *byte == b'm')? + 3;
let sgr = bytes[2..end - 1].iter().all(|byte| byte.is_ascii_digit() || *byte == b';').then(|| &text[..end])?;
(!is_reset(sgr)).then_some(sgr)
}

fn is_reset(code: &str) -> bool {
matches!(code, RESET | SHORT_RESET)
}

fn ends_with_reset(text: &str) -> bool {
text.ends_with(RESET) || text.ends_with(SHORT_RESET)
}

fn strip_ansi(text: &str) -> String {
if !text.as_bytes().contains(&ESC) {
return text.to_owned();
}
anstream::adapter::strip_str(text).to_string()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -57,4 +140,40 @@ mod tests {
assert_eq!("\u{fffd}", input.rows()[0][0]);
assert_eq!("2", input.rows()[0][1]);
}

#[test]
fn test_load_ansi_styles() {
let input = load(
b"\x1b[1mname\x1b[0m,status,detail,note\nAlice,\x1b[31mfailed,bo\x1b[32mom\x1b[0m,fine\nBob,\x1b[31mbad,ok,done\x1b[m\n",
b',',
)
.unwrap();
assert_eq!(["name", "status", "detail", "note"], input.headers());
assert_eq!("failed", input.rows()[0][1].as_str());
assert_eq!("boom", input.rows()[0][2].as_str());
assert_eq!(Some("\x1b[31m"), input.rows()[0][1].style());
assert_eq!(Some("\x1b[31m"), input.rows()[0][2].style());
assert_eq!(None, input.rows()[0][3].style());
assert_eq!(Some("\x1b[31m"), input.rows()[1][1].style());
assert_eq!(Some("\x1b[31m"), input.rows()[1][2].style());
assert_eq!(Some("\x1b[31m"), input.rows()[1][3].style());
}

#[test]
fn test_opening_sgr() {
assert_eq!(Some("\x1b[31m"), opening_sgr("\x1b[31mred\x1b[0m"));
assert_eq!(Some("\x1b[38;2;1;2;3m"), opening_sgr("\x1b[38;2;1;2;3mred"));
assert_eq!(None, opening_sgr("\x1b[0mplain"));
assert_eq!(None, opening_sgr("plain\x1b[31mred"));
assert_eq!(None, opening_sgr("\x1b[31xred"));
}

#[test]
fn test_reset() {
assert!(is_reset("\x1b[0m"));
assert!(is_reset("\x1b[m"));
assert!(!is_reset("\x1b[31m"));
assert!(ends_with_reset("red\x1b[0m"));
assert!(!ends_with_reset("red\x1b[0m "));
}
}
10 changes: 10 additions & 0 deletions src/middleware/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ fn format_cell(ctx: &mut Context<'_>, r: usize, c: usize, digits: usize) -> Opti

#[cfg(test)]
mod tests {
use std::rc::Rc;

use super::*;
use crate::{
Cell, Context, Grid, Resolved, Value,
Expand Down Expand Up @@ -85,6 +87,14 @@ mod tests {
assert_eq!(NumLocale::current().format_float(1234.567, 2), rows[0][0]);
}

#[test]
fn test_format_preserves_style() {
let cell = Cell::parse_styled("1234".to_owned(), Some(Rc::new("\x1b[31m".to_owned())));
let grid = Grid::from_cells(vec!["a".to_owned()], vec![vec![cell]]).unwrap();
let (rows, _) = formatted(grid, |_| {});
assert_eq!(Some("\x1b[31m"), rows[0][0].style());
}

#[test]
fn test_format_extracts_markdown_links() {
let (rows, links) = formatted(test_grid(["site"], [["[ search \t](https://google.com)"]]), |_| {});
Expand Down
67 changes: 67 additions & 0 deletions src/middleware/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ impl<'w, 'ctx> Render<'w, 'ctx> {
self.ctx.paint.cells.get(&(row, col)).or_else(|| self.ctx.paint.columns.get(col).filter(|c| !c.is_empty()))
{
custom
} else if let Some(style) = text.style().filter(|_| !self.ctx.options.zebra) {
style
} else if self.row_style.is_empty() {
self.ctx.theme.cell.as_str()
} else {
Expand Down Expand Up @@ -368,6 +370,7 @@ mod tests {
use super::*;
use crate::{
Border, ColorMode, ColorScale, ColumnBig, Grid, Resolved, ResolvedTheme, ResolvedWidth,
input::csv,
middleware::MIDDLEWARE,
num_locale::NumLocale,
render::{self, test_grid, test_options},
Expand Down Expand Up @@ -471,6 +474,70 @@ mod tests {
assert!(out.contains("\x1b[38;2;"), "{out:?}");
}

#[test]
fn test_render_string_scale_replaces_incoming_style() {
let out = rendered(csv::load(b"status\n\x1b[31mdown\x1b[0m\n\x1b[32mok\x1b[0m\n", b',').unwrap(), |options| {
options.color = ColorMode::On;
options.theme = ResolvedTheme::Dark;
options.width = ResolvedWidth::Fixed(80);
options.color_scales.push(("status".to_owned(), ColorScale::GreenRed));
});

assert!(!out.contains("\x1b[31m"), "{out:?}");
assert!(!out.contains("\x1b[32m"), "{out:?}");
assert!(out.contains("\x1b[38;2;"), "{out:?}");
}

#[test]
fn test_render_incoming_style() {
let out = rendered(csv::load(b"a,b,c\n\x1b[31mone,two\x1b[0m,three\n", b',').unwrap(), |options| {
options.color = ColorMode::On;
options.theme = ResolvedTheme::Dark;
options.width = ResolvedWidth::Fixed(80);
});

assert_eq!(2, out.matches("\x1b[31m").count(), "{out:?}");
assert!(out.contains("one"));
assert!(out.contains("two"));
assert!(out.contains("three"));
}

#[test]
fn test_render_zebra_replaces_incoming_style() {
let out = rendered(csv::load(b"name\n\x1b[31malice\x1b[0m\n\x1b[32mbob\x1b[0m\n", b',').unwrap(), |options| {
options.color = ColorMode::On;
options.theme = ResolvedTheme::Dark;
options.width = ResolvedWidth::Fixed(80);
options.zebra = true;
});

assert!(!out.contains("\x1b[31m"), "{out:?}");
assert!(!out.contains("\x1b[32m"), "{out:?}");
}

#[test]
fn test_render_numeric_paint_replaces_incoming_style() {
let out = rendered(csv::load(b"count\n\x1b[31m1234\x1b[0m\n", b',').unwrap(), |options| {
options.color = ColorMode::On;
options.theme = ResolvedTheme::Dark;
options.width = ResolvedWidth::Fixed(80);
});

assert!(!out.contains("\x1b[31m"), "{out:?}");
assert!(out.contains(&NumLocale::current().format_int(1234)));
}

#[test]
fn test_render_truncation_preserves_incoming_style() {
let out = rendered(csv::load(b"name\n\x1b[31mabcdef\x1b[0m\n", b',').unwrap(), |options| {
options.color = ColorMode::On;
options.theme = ResolvedTheme::Dark;
options.width = ResolvedWidth::Fixed(8);
});

assert!(out.contains("\x1b[31mabc…"), "{out:?}");
}

#[test]
fn test_render_footer() {
let out = rendered(table(["name"], [["alice"]]), |options| {
Expand Down
16 changes: 16 additions & 0 deletions tests/smoke.bats
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ run_tty() {
[[ "$output" == *"344"* ]]
}

@test "incoming ANSI cell style" {
run bash -lc "printf 'name,status,detail\nAlice,\\033[31mfailed,boom\\033[0m\n' | '$BIN' --color=on --theme dark --width 80"
[ "$status" -eq 0 ]
[[ "$output" == *$'\033[31mfailed'* ]]
[[ "$output" == *$'\033[31mboom'* ]]

local plain
plain="$(sed $'s/\033\\[[0-9;]*m//g' <<<"$output")"
[[ "$plain" == *"│ Alice │ failed │ boom │"* ]]

run bash -lc "printf 'name,status\nAlice,\\033[31mfailed\\033[0m\n' | '$BIN' --color=off --width 80"
[ "$status" -eq 0 ]
[[ "$output" == *"failed"* ]]
[[ "$output" != *$'\033['* ]]
}

# bats test_tags=skipwin
@test "numeric locale" {
run bash -lc "printf 'n\n1234.5\n' | LC_ALL=de_DE.UTF-8 '$BIN' --color=off --width 80"
Expand Down