use std::io::{IsTerminal, Read};
use std::process::ExitCode;
use rich::cells::cell_len;
use rich::markdown::Markdown;
use rich::measure::Measurement;
use rich::r#box::{Box as BoxSet, ASCII, ASCII2, DOUBLE, HEAVY, HEAVY_HEAD, ROUNDED, SQUARE};
use rich::text::Text;
use rich::{
filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
DEFAULT_TERMINAL_THEME,
};
use rich_ext::ConsoleExt;
fn text(content: &str) -> Box<dyn Renderable> {
Box::new(Text::new(content))
}
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Auto,
Print,
Markdown,
Json,
Syntax,
Csv,
Ipynb,
Gif,
Diff,
Rule,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ImageMode {
Auto,
Sixel,
Blocks,
Ascii,
None,
}
impl std::str::FromStr for ImageMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"sixel" => Ok(Self::Sixel),
"blocks" | "block" => Ok(Self::Blocks),
"ascii" | "art" => Ok(Self::Ascii),
"none" | "off" => Ok(Self::None),
other => Err(format!(
"unknown image mode {other:?} (auto, sixel, blocks, ascii, none)"
)),
}
}
}
struct Cli {
mode: Mode,
resource: Option<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
resources: Vec<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
loops: Option<usize>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
diff_threshold: Option<f32>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_mode: ImageMode,
width: Option<usize>,
justify: Option<Justify>,
no_color: bool,
export_html: Option<String>,
export_svg: Option<String>,
panel: Option<BoxSet>,
padding: Option<(usize, usize, usize, usize)>,
title: Option<String>,
caption: Option<String>,
panel_style: Option<Style>,
style: Option<Style>,
expand: bool,
pager: bool,
hyperlinks: bool,
}
fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
Markdown::new(source).hyperlinks(hyperlinks)
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match parse(&args) {
Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => run(cli),
Err(message) => {
eprintln!("rich: {message} (try --help)");
ExitCode::FAILURE
}
}
}
fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
Ok(Some(match name.to_ascii_lowercase().as_str() {
"none" => return Ok(None),
"ascii" => ASCII,
"ascii2" => ASCII2,
"square" => SQUARE,
"rounded" => ROUNDED,
"heavy" => HEAVY,
"double" => DOUBLE,
other => {
return Err(format!(
"unknown panel box {other:?} (use none/ascii/ascii2/square/rounded/heavy/double)"
))
}
}))
}
fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
let parts: Result<Vec<usize>, _> = value
.split(',')
.map(|p| p.trim().parse::<usize>())
.collect();
let parts = parts.map_err(|_| error())?;
match parts.as_slice() {
[p] => Ok((*p, *p, *p, *p)),
[v, h] => Ok((*v, *h, *v, *h)),
[t, r, b, l] => Ok((*t, *r, *b, *l)),
_ => Err(error()),
}
}
fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
if *current != Mode::Auto && *current != mode {
return Err(
"only one render mode (--print/--markdown/--json/--syntax/--csv/--ipynb/--rule) \
may be given"
.into(),
);
}
*current = mode;
Ok(())
}
fn parse(args: &[String]) -> Result<Option<Cli>, String> {
let mut mode = Mode::Auto;
let mut resources: Vec<String> = Vec::new();
let mut loops = None;
let mut diff_threshold = None;
let mut image_mode = ImageMode::Auto;
let mut width = None;
let mut justify = None;
let mut no_color = false;
let mut export_html: Option<String> = None;
let mut export_svg: Option<String> = None;
let mut panel = None;
let mut padding = None;
let mut title = None;
let mut caption = None;
let mut panel_style = None;
let mut style = None;
let mut expand = false;
let mut pager = false;
let mut hyperlinks = false;
let mut end_of_options = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if end_of_options {
resources.push(arg.to_string());
continue;
}
match arg.as_str() {
"--" => end_of_options = true,
"-h" | "--help" => {
print_help();
return Ok(None);
}
"-V" | "--version" => {
println!("rich (rs-rich-cli) {VERSION}");
return Ok(None);
}
"-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
"-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
"-j" | "--json" => set_mode(&mut mode, Mode::Json)?,
"-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
"--csv" => set_mode(&mut mode, Mode::Csv)?,
"--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
"--gif" => set_mode(&mut mode, Mode::Gif)?,
"--diff" => set_mode(&mut mode, Mode::Diff)?,
"--image-mode" => {
let value = iter
.next()
.ok_or("--image-mode requires one of: auto, sixel, blocks, ascii, none")?;
image_mode = value.parse()?;
}
"--threshold" => {
let value = iter
.next()
.ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
let percent: f32 = value
.parse()
.map_err(|_| format!("invalid threshold '{value}'"))?;
if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
return Err(format!(
"threshold must be a percentage between 0 and 100, got '{value}'"
));
}
diff_threshold = Some(percent);
}
"--loop" => {
let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
loops = Some(
value
.parse()
.map_err(|_| format!("invalid loop count {value:?}"))?,
);
}
"--rule" => set_mode(&mut mode, Mode::Rule)?,
"--left" => justify = Some(Justify::Left),
"--right" => justify = Some(Justify::Right),
"--center" => justify = Some(Justify::Center),
"--no-color" => no_color = true,
"--pager" => pager = true,
"-y" | "--hyperlinks" => hyperlinks = true,
"--export-html" | "-o" => {
export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
}
"--export-svg" => {
export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
}
"--panel" => {
let value = iter.next().ok_or("--panel requires a box name")?;
panel = parse_box(value)?;
}
"--padding" => {
let value = iter.next().ok_or("--padding requires a value")?;
padding = Some(parse_padding(value)?);
}
"-e" | "--expand" => expand = true,
"--title" => {
title = Some(iter.next().ok_or("--title requires a value")?.clone());
}
"--caption" => {
caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
}
"-s" | "--style" => {
let value = iter.next().ok_or("--style requires a value")?;
style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
}
"-S" | "--panel-style" => {
let value = iter.next().ok_or("--panel-style requires a value")?;
panel_style =
Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
}
"-w" | "--width" => {
let value = iter.next().ok_or("--width requires a number")?;
let columns: usize = value.parse().map_err(|_| {
if std::path::Path::new(value).exists() {
format!("--width needs a number, but got the file '{value}' — a value is missing")
} else {
format!("invalid width '{value}'")
}
})?;
if columns == 0 {
return Err("--width must be at least 1".into());
}
width = Some(columns);
}
other if other.starts_with('-') && other != "-" => {
return Err(format!("unknown option {other:?}"));
}
other => resources.push(other.to_string()),
}
}
if mode == Mode::Diff && resources.len() != 2 {
return Err("--diff needs exactly two images: --diff before.png after.png".into());
}
let exporting = export_html.is_some() || export_svg.is_some();
if exporting {
let unsupported = if mode == Mode::Gif {
Some("--gif")
} else if mode == Mode::Auto && resources.is_empty() {
Some("the capability demo")
} else {
None
};
if let Some(what) = unsupported {
return Err(format!("--export-html/--export-svg cannot capture {what}"));
}
}
let orphans = [
(
"--threshold",
diff_threshold.is_some(),
"--diff",
mode == Mode::Diff,
),
(
"--image-mode",
image_mode != ImageMode::Auto,
"--diff",
mode == Mode::Diff,
),
("--loop", loops.is_some(), "--gif", mode == Mode::Gif),
(
"--panel-style",
panel_style.is_some(),
"--panel",
panel.is_some(),
),
(
"--expand",
expand,
"--panel/--padding",
panel.is_some() || padding.is_some(),
),
];
if let Some((flag, _, needs, _)) = orphans
.iter()
.find(|(_, given, _, mode_present)| *given && !*mode_present)
{
return Err(format!("{flag} only has an effect with {needs}"));
}
if mode == Mode::Diff {
let unsupported = [
("--panel", panel.is_some()),
("--padding", padding.is_some()),
("--title", title.is_some()),
("--caption", caption.is_some()),
("--style", style.is_some()),
("--panel-style", panel_style.is_some()),
("--expand", expand),
("--left/--center/--right", justify.is_some()),
];
if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
return Err(format!("{flag} cannot be combined with --diff"));
}
}
if mode != Mode::Gif && mode != Mode::Diff && resources.len() > 1 {
return Err("only one resource may be given (except with --gif)".into());
}
let resource = resources.first().cloned();
Ok(Some(Cli {
mode,
resource,
resources,
loops,
diff_threshold,
image_mode,
width,
justify,
no_color,
export_html,
export_svg,
panel,
padding,
title,
caption,
panel_style,
style,
expand,
pager,
hyperlinks,
}))
}
fn read_resource(resource: Option<&str>) -> std::io::Result<String> {
let content = match resource {
Some(path) if path != "-" => {
let file = std::path::Path::new(path);
if file.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"is a directory, not a file",
));
}
let bytes = std::fs::read(file)?;
match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) if looks_like_image(path) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
))
}
Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
}
}
_ => {
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
buffer
}
};
Ok(normalize_newlines(strip_bom(content)))
}
fn normalize_newlines(text: String) -> String {
const CR: char = '\r';
const LF: char = '\n';
if !text.contains(CR) {
return text;
}
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == CR {
if chars.peek() == Some(&LF) {
chars.next();
}
out.push(LF);
} else {
out.push(c);
}
}
out
}
fn looks_like_image(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
)
})
}
fn strip_bom(text: String) -> String {
match text.strip_prefix('\u{feff}') {
Some(rest) => rest.to_string(),
None => text,
}
}
fn is_url(resource: &str) -> bool {
let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
head.starts_with("http://") || head.starts_with("https://")
}
fn resource_ext(resource: &str) -> Option<String> {
let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
let basename = basename.split(['?', '#']).next().unwrap_or(basename);
basename
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.filter(|ext| !ext.is_empty())
}
#[cfg(feature = "fetch")]
const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
#[cfg(feature = "fetch")]
fn fetch_url(url: &str) -> Result<(String, Option<String>), String> {
use std::time::Duration;
let mut response = ureq::get(url)
.config()
.timeout_global(Some(Duration::from_secs(30)))
.http_status_as_error(false)
.build()
.call()
.map_err(|err| format!("cannot fetch {url}: {err}"))?;
let content_type = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let mut buffer = Vec::new();
response
.body_mut()
.with_config()
.reader()
.take(MAX_BODY_BYTES + 1)
.read_to_end(&mut buffer)
.map_err(|err| format!("cannot read {url}: {err}"))?;
if buffer.len() as u64 > MAX_BODY_BYTES {
return Err(format!(
"{url} exceeds the {} MiB limit",
MAX_BODY_BYTES / (1024 * 1024)
));
}
let body = String::from_utf8(buffer)
.map_err(|_| format!("{url} is not valid UTF-8 text (binary content?)"))?;
Ok((body, content_type))
}
#[cfg(not(feature = "fetch"))]
fn fetch_url(url: &str) -> Result<(String, Option<String>), String> {
Err(format!(
"cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
))
}
fn content_type_mode(content_type: &str) -> Option<Mode> {
let mime = mime_of(content_type);
match mime.as_str() {
"text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
"application/json" | "text/json" => Some(Mode::Json),
"text/csv" => Some(Mode::Csv),
_ => None,
}
}
fn content_type_lexer(content_type: &str) -> Option<&'static str> {
let mime = mime_of(content_type);
Some(match mime.as_str() {
"text/html" | "application/xhtml+xml" => "html",
"text/css" => "css",
"application/javascript" | "text/javascript" => "javascript",
"application/xml" | "text/xml" => "xml",
"text/x-python" | "application/x-python" | "text/x-python3" => "python",
"text/x-rust" => "rust",
"text/x-c" | "text/x-csrc" => "c",
"application/x-sh" | "text/x-shellscript" => "bash",
"application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
"application/toml" | "text/x-toml" => "toml",
_ => return None,
})
}
fn is_uninformative_ext(ext: &str) -> bool {
matches!(ext, "txt" | "text" | "log" | "dat" | "out")
}
fn mime_of(content_type: &str) -> String {
content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
}
fn detect_mode(resource: Option<&str>) -> Mode {
match resource.and_then(resource_ext).as_deref() {
Some("md") | Some("markdown") => Mode::Markdown,
Some("json") => Mode::Json,
Some("csv") | Some("tsv") => Mode::Csv,
Some("ipynb") => Mode::Ipynb,
Some("gif") => Mode::Gif,
Some(_) => Mode::Syntax,
None => Mode::Auto,
}
}
struct ForceWidth {
child: Box<dyn Renderable>,
width: usize,
}
impl Renderable for ForceWidth {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.child
.rich_render(console, &options.update_width(self.width))
}
fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
Measurement::new(self.width, self.width)
}
}
struct Aligned {
child: Box<dyn Renderable>,
width: usize,
justify: Justify,
}
impl Renderable for Aligned {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = self.width.clamp(1, options.max_width);
let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
let widths: Vec<usize> = lines
.iter()
.map(|line| line.iter().map(Segment::cell_length).sum())
.collect();
let shape = widths.iter().copied().max().unwrap_or(0);
let excess = options.max_width.saturating_sub(shape);
let (left, right) = match self.justify {
Justify::Right => (excess, 0),
Justify::Center => (excess / 2, excess - excess / 2),
_ => (0, excess),
};
let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
let mut segments = Vec::new();
let last = lines.len().saturating_sub(1);
for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
if left > 0 {
segments.push(blank(left));
}
segments.extend(line);
let pad = shape - line_width + right;
if pad > 0 {
segments.push(blank(pad));
}
if index != last {
segments.push(Segment::line());
}
}
segments
}
}
fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
console
.render_lines(renderable, &console.options(), false)
.iter()
.map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
.max()
.unwrap_or(0)
}
fn decorate_and_emit(
cli: &Cli,
console: &Console,
export: &Export,
renderable: Box<dyn Renderable>,
fit: Option<usize>,
) -> ExitCode {
let max_width = console.width();
let expand = cli.expand || cli.width.is_some();
let mut renderable = renderable;
let mut fit = fit;
if let Some((top, right, bottom, left)) = cli.padding {
let padded: Box<dyn Renderable> =
Box::new(Padding::new(renderable, (top, right, bottom, left)));
fit = fit.map(|width| (width + left + right).min(max_width));
renderable = if expand {
padded
} else {
Box::new(Constrain::new(padded, fit))
};
}
if let Some(box_set) = cli.panel {
let mut panel = Panel::new(renderable).box_set(box_set);
if let Some(title) = cli.title.clone() {
panel = panel.title(title);
}
if let Some(caption) = cli.caption.clone() {
panel = panel.subtitle(caption);
}
if let Some(style) = cli.panel_style.clone() {
panel = panel.border_style(style);
}
let inner = max_width.saturating_sub(4);
let child = fit.unwrap_or(inner).min(inner);
let title_width = cli.title.as_deref().map(cell_len).unwrap_or(0);
fit = Some(child.max(title_width) + 4);
let panel: Box<dyn Renderable> = Box::new(panel);
renderable = if expand {
panel
} else {
Box::new(Constrain::new(panel, fit))
};
}
if let Some(style) = cli.style.clone() {
renderable = Box::new(Styled::new(renderable, style));
}
if let Some(width) = cli.width.filter(|_| !cli.pager) {
renderable = Box::new(ForceWidth {
child: renderable,
width,
});
fit = Some(width);
}
if let Some(justify) = cli.justify {
renderable = Box::new(Aligned {
child: renderable,
width: fit.unwrap_or(max_width),
justify,
});
}
exit_code(emit(console, export, |c| c.print(renderable.as_ref())))
}
fn run(cli: Cli) -> ExitCode {
if cli.mode == Mode::Auto && cli.resource.is_none() {
run_demo(cli.no_color);
return ExitCode::SUCCESS;
}
let mut mode = match cli.mode {
Mode::Auto => detect_mode(cli.resource.as_deref()),
other => other,
};
let width_on_console = matches!(mode, Mode::Gif | Mode::Diff | Mode::Ipynb);
let mut builder = Console::builder().no_color(cli.no_color);
if let Some(width) = cli.width.filter(|_| width_on_console) {
builder = builder.width(width);
}
let mut console = builder.build();
console.install_extensions();
let svg_title = cli
.resource
.as_deref()
.filter(|r| *r != "-")
.map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
.unwrap_or_else(|| "rich".to_string());
let export = Export {
html_path: cli.export_html.as_deref(),
svg_path: cli.export_svg.as_deref(),
svg_title: &svg_title,
pager: cli.pager,
};
if mode == Mode::Gif {
return play_gifs(&cli, &console);
}
#[cfg(feature = "art")]
if mode == Mode::Diff {
return run_diff(&cli, &console, &export);
}
if mode == Mode::Rule {
let rule = match cli.resource.as_deref() {
Some(title) if title != "-" => Rule::new(title),
_ => Rule::line(),
};
return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
}
let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
let (content, content_type) = if resource_is_url {
match fetch_url(cli.resource.as_deref().unwrap()) {
Ok(fetched) => fetched,
Err(err) => {
eprintln!("rich: {err}");
return ExitCode::FAILURE;
}
}
} else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
(cli.resource.clone().unwrap(), None)
} else {
match read_resource(cli.resource.as_deref()) {
Ok(content) => (content, None),
Err(err) => {
let name = cli.resource.as_deref().unwrap_or("<stdin>");
eprintln!("rich: cannot read {name}: {err}");
if err.kind() == std::io::ErrorKind::InvalidData && looks_like_image(name) {
eprintln!("rich: {name} looks like an image — did you mean `rich --diff <before> <after>`?");
}
return ExitCode::FAILURE;
}
}
};
if resource_is_url && mode == Mode::Auto {
mode = content_type
.as_deref()
.and_then(content_type_mode)
.unwrap_or(Mode::Syntax);
}
let json = if mode == Mode::Json {
match Json::new(content.trim()) {
Ok(json) => Some(json),
Err(err) => {
eprintln!("rich: invalid JSON: {err}");
return ExitCode::FAILURE;
}
}
} else {
None
};
if mode == Mode::Ipynb {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {}
Ok(_) => {
eprintln!("rich: not a notebook: no `cells` array");
return ExitCode::FAILURE;
}
Err(err) => {
eprintln!("rich: invalid notebook JSON: {err}");
return ExitCode::FAILURE;
}
}
return exit_code(emit(&console, &export, |c| {
render_ipynb(c, &content, cli.hyperlinks)
}));
}
if mode == Mode::Print {
if let Err(err) = console.try_build_text(&content) {
eprintln!("rich: {err}");
return ExitCode::FAILURE;
}
}
let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
.filter(|ext| !is_uninformative_ext(ext));
let language = extension
.or_else(|| {
content_type
.as_deref()
.and_then(content_type_lexer)
.map(str::to_string)
})
.unwrap_or_default();
let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
Mode::Markdown => (Box::new(build_markdown(&content, cli.hyperlinks)), None),
Mode::Json => {
let nested = cli.padding.is_some()
|| cli.panel.is_some()
|| cli.style.is_some()
|| cli.width.is_some()
|| cli.pager;
let json = json.expect("json parsed above").no_wrap(nested);
let fit = measure_rendered(&console, &json);
(Box::new(json), Some(fit))
}
Mode::Csv => {
let fallback = csv_fallback_delimiter(cli.resource.as_deref());
let table = match build_csv_table(
&content,
fallback,
cli.title.as_deref(),
cli.caption.as_deref(),
) {
Some(table) => table,
None => {
eprintln!("rich: Could not determine delimiter");
return ExitCode::FAILURE;
}
};
let fit = measure_rendered(&console, &table);
(Box::new(table), Some(fit))
}
Mode::Syntax => {
let fit = content.lines().map(cell_len).max().unwrap_or(0);
(
Box::new(Syntax::new(content.as_str(), language.as_str()).word_wrap(true)),
Some(fit),
)
}
_ => {
let text = if mode == Mode::Print {
console.build_text(&content)
} else {
Text::new(content.as_str())
};
let fit = text.measurement().1;
(Box::new(text), Some(fit))
}
};
decorate_and_emit(&cli, &console, &export, renderable, fit)
}
fn exit_code(ok: bool) -> ExitCode {
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[derive(Debug, Clone, Copy)]
struct Dialect {
delimiter: char,
quotechar: char,
doublequote: bool,
skipinitialspace: bool,
}
impl Dialect {
fn excel(delimiter: char) -> Self {
Dialect {
delimiter,
quotechar: '"',
doublequote: true,
skipinitialspace: false,
}
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_delimiter_char(c: char) -> bool {
!is_word_char(c) && c != '\n' && c != '"' && c != '\''
}
fn is_quote_char(c: char) -> bool {
c == '"' || c == '\''
}
struct QuoteHit {
quote: char,
delim: Option<char>,
space: bool,
}
fn sniff(sample: &str, delimiters: Option<&[char]>) -> Option<Dialect> {
let data: Vec<char> = sample.chars().collect();
let (quote, mut delimiter, mut skipinitialspace) = guess_quote_and_delimiter(&data, delimiters);
if delimiter.is_none() {
let (guessed, spaced) = guess_delimiter(sample, delimiters);
delimiter = guessed;
skipinitialspace = spaced;
}
Some(Dialect {
delimiter: delimiter?,
quotechar: quote.unwrap_or('"'),
doublequote: true,
skipinitialspace,
})
}
fn guess_quote_and_delimiter(
data: &[char],
delimiters: Option<&[char]>,
) -> (Option<char>, Option<char>, bool) {
let hits = quote_hits(data);
if hits.is_empty() {
return (None, None, false);
}
let mut quotes: Vec<(char, usize)> = Vec::new();
let mut delims: Vec<(char, usize)> = Vec::new();
let mut spaces = 0usize;
fn bump(table: &mut Vec<(char, usize)>, key: char) {
match table.iter_mut().find(|(existing, _)| *existing == key) {
Some(entry) => entry.1 += 1,
None => table.push((key, 1)),
}
}
for hit in &hits {
bump(&mut quotes, hit.quote);
let Some(delim) = hit.delim else { continue };
if delimiters.is_none_or(|allowed| allowed.contains(&delim)) {
bump(&mut delims, delim);
}
if hit.space {
spaces += 1;
}
}
let first_max = |table: &[(char, usize)]| -> Option<(char, usize)> {
let mut best: Option<(char, usize)> = None;
for &(key, count) in table {
if best.is_none_or(|(_, seen)| count > seen) {
best = Some((key, count));
}
}
best
};
let quotechar = first_max("es).map(|(key, _)| key);
match first_max(&delims) {
Some((delim, count)) => (quotechar, Some(delim), count == spaces),
None => (quotechar, None, false),
}
}
type QuoteScan = fn(&[char]) -> Vec<QuoteHit>;
fn quote_hits(data: &[char]) -> Vec<QuoteHit> {
let scans: [QuoteScan; 4] = [
scan_delim_quote_delim,
scan_line_quote_delim,
scan_delim_quote_line,
scan_line_quote_line,
];
for scan in scans {
let hits = scan(data);
if !hits.is_empty() {
return hits;
}
}
Vec::new()
}
fn scan_delim_quote_delim(data: &[char]) -> Vec<QuoteHit> {
scan_delim_quote(data, |data, quote, delim, from| {
let mut k = from;
while k + 1 < data.len() {
if data[k] == quote && data[k + 1] == delim {
return Some(k + 2);
}
k += 1;
}
None
})
}
fn scan_delim_quote_line(data: &[char]) -> Vec<QuoteHit> {
scan_delim_quote(data, |data, quote, _delim, from| {
let mut k = from;
while k < data.len() {
if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
return Some(k + 1);
}
k += 1;
}
None
})
}
fn scan_delim_quote(
data: &[char],
close: fn(&[char], char, char, usize) -> Option<usize>,
) -> Vec<QuoteHit> {
let mut hits = Vec::new();
let mut i = 0;
while i < data.len() {
if is_delimiter_char(data[i]) {
let delim = data[i];
let mut j = i + 1;
let mut space = false;
if j < data.len() && data[j] == ' ' {
space = true;
j += 1;
}
if j < data.len() && is_quote_char(data[j]) {
let quote = data[j];
if let Some(end) = close(data, quote, delim, j + 1) {
hits.push(QuoteHit {
quote,
delim: Some(delim),
space,
});
i = end;
continue;
}
}
}
i += 1;
}
hits
}
fn scan_line_quote_delim(data: &[char]) -> Vec<QuoteHit> {
scan_line_quote(data, |data, quote, from| {
let mut k = from;
while k + 1 < data.len() {
if data[k] == quote && is_delimiter_char(data[k + 1]) {
let space = k + 2 < data.len() && data[k + 2] == ' ';
return Some((Some(data[k + 1]), space, if space { k + 3 } else { k + 2 }));
}
k += 1;
}
None
})
}
fn scan_line_quote_line(data: &[char]) -> Vec<QuoteHit> {
scan_line_quote(data, |data, quote, from| {
let mut k = from;
while k < data.len() {
if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
return Some((None, false, k + 1));
}
k += 1;
}
None
})
}
#[allow(clippy::type_complexity)]
fn scan_line_quote(
data: &[char],
close: fn(&[char], char, usize) -> Option<(Option<char>, bool, usize)>,
) -> Vec<QuoteHit> {
let mut hits = Vec::new();
let mut i = 0;
while i < data.len() {
let mut starts: Vec<usize> = Vec::new();
if i == 0 || data[i - 1] == '\n' {
starts.push(i);
}
if data[i] == '\n' {
starts.push(i + 1);
}
let mut advanced = false;
for quote_at in starts {
if quote_at >= data.len() || !is_quote_char(data[quote_at]) {
continue;
}
let quote = data[quote_at];
if let Some((delim, space, end)) = close(data, quote, quote_at + 1) {
hits.push(QuoteHit {
quote,
delim,
space,
});
i = end;
advanced = true;
break;
}
}
if !advanced {
i += 1;
}
}
hits
}
fn guess_delimiter(sample: &str, delimiters: Option<&[char]>) -> (Option<char>, bool) {
let data: Vec<&str> = sample.split('\n').filter(|line| !line.is_empty()).collect();
if data.is_empty() {
return (None, false);
}
const ASCII: usize = 127;
let chunk_length = 10.min(data.len());
let mut iteration = 0usize;
let mut char_frequency: Vec<Vec<(usize, usize)>> = vec![Vec::new(); ASCII];
let mut modes: Vec<Option<(usize, isize)>> = vec![None; ASCII];
let mut delims: Vec<(char, (usize, isize))> = Vec::new();
let (mut start, mut end) = (0usize, chunk_length);
while start < data.len() {
iteration += 1;
for line in &data[start..end.min(data.len())] {
for (code, table) in char_frequency.iter_mut().enumerate() {
let c = code as u8 as char;
let freq = line.matches(c).count();
match table.iter_mut().find(|(seen, _)| *seen == freq) {
Some(entry) => entry.1 += 1,
None => table.push((freq, 1)),
}
}
}
for (code, items) in char_frequency.iter().enumerate() {
if items.len() == 1 && items[0].0 == 0 {
continue;
}
if items.len() > 1 {
let mut best = 0usize;
for (index, item) in items.iter().enumerate() {
if item.1 > items[best].1 {
best = index;
}
}
let others: usize = items
.iter()
.enumerate()
.filter(|(index, _)| *index != best)
.map(|(_, item)| item.1)
.sum();
modes[code] = Some((items[best].0, items[best].1 as isize - others as isize));
} else if let Some(&(freq, count)) = items.first() {
modes[code] = Some((freq, count as isize));
}
}
let total = (chunk_length * iteration).min(data.len()) as f64;
let mut consistency = 1.0f64;
let threshold = 0.9f64;
while delims.is_empty() && consistency >= threshold {
for (code, mode) in modes.iter().enumerate() {
let Some((freq, count)) = *mode else { continue };
let c = code as u8 as char;
if freq > 0
&& count > 0
&& (count as f64 / total) >= consistency
&& delimiters.is_none_or(|allowed| allowed.contains(&c))
{
delims.push((c, (freq, count)));
}
}
consistency -= 0.01;
}
if delims.len() == 1 {
let delim = delims[0].0;
return (Some(delim), saw_space_after(data[0], delim));
}
start = end;
end += chunk_length;
}
if delims.is_empty() {
return (None, false);
}
if delims.len() > 1 {
for preferred in [',', '\t', ';', ' ', ':'] {
if delims.iter().any(|(c, _)| *c == preferred) {
return (Some(preferred), saw_space_after(data[0], preferred));
}
}
}
let best = delims
.iter()
.max_by_key(|(c, mode)| (*mode, *c))
.expect("delims is non-empty");
(Some(best.0), saw_space_after(data[0], best.0))
}
fn saw_space_after(line: &str, delimiter: char) -> bool {
line.matches(delimiter).count() == line.matches(&format!("{delimiter} ")).count()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ColumnType {
Number,
Length(usize),
}
#[derive(Debug, Clone, Copy)]
enum ColumnVote {
Untyped,
Typed(ColumnType),
Dropped,
}
fn has_header(sample: &str) -> Option<bool> {
let dialect = sniff(sample, None)?;
let mut rows = read_csv_rows(sample, &dialect).into_iter();
let header = rows.next()?;
let columns = header.len();
let mut votes = vec![ColumnVote::Untyped; columns];
for (checked, row) in rows.enumerate() {
if checked > 20 {
break;
}
if row.len() != columns {
continue;
}
for (col, vote) in votes.iter_mut().enumerate() {
if matches!(vote, ColumnVote::Dropped) {
continue;
}
let this = if parses_as_complex(&row[col]) {
ColumnType::Number
} else {
ColumnType::Length(row[col].chars().count())
};
match vote {
ColumnVote::Untyped => *vote = ColumnVote::Typed(this),
ColumnVote::Typed(known) if *known != this => *vote = ColumnVote::Dropped,
_ => {}
}
}
}
let mut tally = 0isize;
for (col, vote) in votes.iter().enumerate() {
match vote {
ColumnVote::Dropped => {}
ColumnVote::Untyped => tally += 1,
ColumnVote::Typed(ColumnType::Length(len)) => {
if header[col].chars().count() == *len {
tally -= 1;
} else {
tally += 1;
}
}
ColumnVote::Typed(ColumnType::Number) => {
if parses_as_complex(&header[col]) {
tally -= 1;
} else {
tally += 1;
}
}
}
}
Some(tally > 0)
}
fn parses_as_complex(value: &str) -> bool {
let trimmed = value.trim();
let body = match trimmed.strip_prefix('(') {
Some(inner) => match inner.strip_suffix(')') {
Some(inner) => inner.trim(),
None => return false,
},
None if trimmed.ends_with(')') => return false,
None => trimmed,
};
if body.is_empty() {
return false;
}
let chars: Vec<char> = body.chars().collect();
let split = (1..chars.len())
.find(|&i| matches!(chars[i], '+' | '-') && !matches!(chars[i - 1], 'e' | 'E'));
match split {
Some(index) => {
let at: usize = chars[..index].iter().map(|c| c.len_utf8()).sum();
let (real, imaginary) = body.split_at(at);
is_float_literal(real) && is_imaginary_literal(imaginary)
}
None => is_float_literal(body) || is_imaginary_literal(body),
}
}
fn is_float_literal(value: &str) -> bool {
let body = value.strip_prefix(['+', '-']).unwrap_or(value);
if matches!(
body.to_ascii_lowercase().as_str(),
"inf" | "infinity" | "nan"
) {
return true;
}
let chars: Vec<char> = body.chars().collect();
let mut index = 0usize;
let mut digits = 0usize;
let take_digits = |index: &mut usize, digits: &mut usize| {
while *index < chars.len() && (chars[*index].is_ascii_digit() || chars[*index] == '_') {
if chars[*index].is_ascii_digit() {
*digits += 1;
}
*index += 1;
}
};
take_digits(&mut index, &mut digits);
if index < chars.len() && chars[index] == '.' {
index += 1;
take_digits(&mut index, &mut digits);
}
if digits == 0 {
return false;
}
if index < chars.len() && (chars[index] == 'e' || chars[index] == 'E') {
index += 1;
if index < chars.len() && matches!(chars[index], '+' | '-') {
index += 1;
}
let mut exponent = 0usize;
take_digits(&mut index, &mut exponent);
if exponent == 0 {
return false;
}
}
index == chars.len()
}
fn is_imaginary_literal(value: &str) -> bool {
let Some(body) = value.strip_suffix(['j', 'J']) else {
return false;
};
matches!(body, "" | "+" | "-") || is_float_literal(body)
}
enum State {
StartRecord,
StartField,
InField,
InQuotedField,
QuoteInQuotedField,
}
fn read_csv_rows(content: &str, dialect: &Dialect) -> Vec<Vec<String>> {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut state = State::StartRecord;
for c in content.chars() {
match state {
State::StartRecord => {
if c == '\n' {
rows.push(Vec::new());
} else {
state = State::StartField;
read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows);
}
}
_ => read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows),
}
}
if !matches!(state, State::StartRecord) {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
}
rows
}
fn read_csv_char(
c: char,
dialect: &Dialect,
state: &mut State,
field: &mut String,
row: &mut Vec<String>,
rows: &mut Vec<Vec<String>>,
) {
let end_record = |field: &mut String, row: &mut Vec<String>, rows: &mut Vec<Vec<String>>| {
row.push(std::mem::take(field));
rows.push(std::mem::take(row));
};
match state {
State::StartRecord => unreachable!("handled by the caller"),
State::StartField => {
if c == '\n' {
end_record(field, row, rows);
*state = State::StartRecord;
} else if c == dialect.quotechar {
*state = State::InQuotedField;
} else if c == ' ' && dialect.skipinitialspace {
} else if c == dialect.delimiter {
row.push(std::mem::take(field));
} else {
field.push(c);
*state = State::InField;
}
}
State::InField => {
if c == '\n' {
end_record(field, row, rows);
*state = State::StartRecord;
} else if c == dialect.delimiter {
row.push(std::mem::take(field));
*state = State::StartField;
} else {
field.push(c);
}
}
State::InQuotedField => {
if c == dialect.quotechar {
*state = if dialect.doublequote {
State::QuoteInQuotedField
} else {
State::InField
};
} else {
field.push(c);
}
}
State::QuoteInQuotedField => {
if c == dialect.quotechar {
field.push(c);
*state = State::InQuotedField;
} else if c == dialect.delimiter {
row.push(std::mem::take(field));
*state = State::StartField;
} else if c == '\n' {
end_record(field, row, rows);
*state = State::StartRecord;
} else {
field.push(c);
*state = State::InField;
}
}
}
}
fn is_number(value: &str) -> bool {
let body = value.strip_prefix('-').unwrap_or(value);
let (integer, fraction) = match body.split_once('.') {
Some((integer, fraction)) => (integer, Some(fraction)),
None => (body, None),
};
let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
digits(integer) && fraction.is_none_or(digits)
}
fn build_csv_table(
content: &str,
fallback_delimiter: Option<char>,
title: Option<&str>,
caption: Option<&str>,
) -> Option<Table> {
let sample: String = content.chars().take(1024).collect();
let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
let (dialect, header) = match sniffed {
Some(sniffed) => sniffed,
None => (Dialect::excel(fallback_delimiter?), true),
};
Some(render_csv(
&read_csv_rows(content, &dialect),
header,
title,
caption,
))
}
fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
let name = resource.unwrap_or_default().to_lowercase();
if name.ends_with(".csv") {
Some(',')
} else if name.ends_with(".tsv") {
Some('\t')
} else {
None
}
}
fn render_csv(
rows: &[Vec<String>],
has_header: bool,
title: Option<&str>,
caption: Option<&str>,
) -> Table {
let mut table = Table::new()
.border_style(Style::parse("blue").expect("valid style"))
.show_header(has_header)
.box_set(if has_header { HEAVY_HEAD } else { SQUARE });
if let Some(title) = title {
table = table.title(title);
}
if let Some(caption) = caption {
table = table.caption(caption);
}
let empty: Vec<String> = Vec::new();
let (header, body) = if has_header {
match rows.split_first() {
Some((header, body)) => (header, body),
None => return table,
}
} else {
(&empty, rows)
};
let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
let columns = header.len().max(widest);
for index in 0..columns {
let numeric = data.iter().all(|row| match row.get(index) {
Some(value) => value.is_empty() || is_number(value),
None => false,
});
let name = header.get(index).map(String::as_str).unwrap_or("");
if numeric {
table.add_column_justify(name, Justify::Right);
table.column_style(Style::parse("bold green").expect("valid style"));
table.column_header_fill(Style::parse("bold green").expect("valid style"));
} else {
table.add_column(name);
}
}
for row in data {
let cells: Vec<&str> = row.iter().map(String::as_str).collect();
table.add_row(&cells);
}
table
}
fn join_source(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
_ => String::new(),
}
}
fn join_traceback(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(a) => a
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(
"
",
),
_ => String::new(),
}
}
fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
let base_style = Style::parse(base).ok();
let number_style = Style::parse(number).ok();
let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
let mut text = Text::new("");
text.append(&format!("{word}["), base_style.clone().map(Into::into));
text.append(&n, number_style.map(Into::into));
text.append("]:", base_style.map(Into::into));
text
}
fn print_ansi(console: &Console, text: &str) {
if text.is_empty() {
return;
}
let mut decoder = AnsiDecoder::new();
for line in decoder.decode(text) {
console.print(&line);
}
}
fn render_output(console: &Console, output: &serde_json::Value, count: Option<i64>) {
match output["output_type"].as_str().unwrap_or("") {
"stream" => print_ansi(console, &join_source(&output["text"])),
"error" => print_ansi(console, &join_traceback(&output["traceback"])),
"execute_result" | "display_data" => {
console.print(&io_label("Out", count, "red", "#ee4b2b"));
print_ansi(console, &join_source(&output["data"]["text/plain"]));
}
_ => {}
}
}
fn render_ipynb(console: &Console, content: &str, hyperlinks: bool) {
let notebook: serde_json::Value = match serde_json::from_str(content) {
Ok(value) => value,
Err(err) => {
eprintln!("rich: invalid notebook JSON: {err}");
return;
}
};
let language = notebook["metadata"]["kernelspec"]["language"]
.as_str()
.or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
.unwrap_or("python")
.to_string();
let empty = Vec::new();
let cells = notebook["cells"].as_array().unwrap_or(&empty);
for (index, cell) in cells.iter().enumerate() {
if index > 0 {
console.print(&Text::new("")); }
let source = join_source(&cell["source"]);
match cell["cell_type"].as_str().unwrap_or("") {
"markdown" => console.print(&build_markdown(&source, hyperlinks)),
"code" => {
let count = cell["execution_count"].as_i64();
console.print(&io_label("In ", count, "green", "#66ff00"));
let syntax = Syntax::new(source.as_str(), language.as_str());
let panel =
Panel::new(Box::new(syntax)).border_style(Style::parse("dim").expect("valid"));
console.print(&panel);
for output in cell["outputs"].as_array().unwrap_or(&empty) {
render_output(console, output, count);
}
}
_ => console.print(&Text::new(source.as_str())),
}
}
}
#[cfg(feature = "art")]
fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
use rich::Table;
use rich_art::imagediff::{diff, DiffSettings};
use rich_art::{AsciiArt, BlockArt, SixelArt};
let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
let open = |path: &String| {
if std::path::Path::new(path).is_dir() {
eprintln!("rich: cannot read {path}: is a directory, not a file");
return None;
}
match rich_art::image::open(path) {
Ok(image) => Some(image),
Err(err) => {
eprintln!("rich: cannot read {path}: {err}");
None
}
}
};
let (Some(before), Some(after)) = (open(before_path), open(after_path)) else {
return ExitCode::FAILURE;
};
let report = match diff(&before, &after, &DiffSettings::default()) {
Ok(report) => report,
Err(err) => {
eprintln!("rich: {err}");
return ExitCode::FAILURE;
}
};
let width = cli.width.unwrap_or_else(|| console.width());
let changed = report.changed_fraction * 100.0;
let naive = report.naive_changed_fraction * 100.0;
let shown = (changed * 10.0).round() / 10.0;
let failed = cli.diff_threshold.is_some_and(|limit| shown > limit);
let wrote = emit(console, export, |c| {
let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
let has_color = c.color_system().is_some() && !cli.no_color;
let is_terminal = c.is_terminal();
let mut mode = cli.image_mode;
if mode == ImageMode::Auto {
mode = if !has_color {
ImageMode::Ascii
} else if is_terminal && rich_art::sixel::is_probably_supported() {
ImageMode::Sixel
} else {
ImageMode::Blocks
};
}
if mode == ImageMode::Blocks && !has_color {
eprintln!("rich: no colour available, drawing the diff as ASCII art");
mode = ImageMode::Ascii;
}
if mode == ImageMode::Sixel && !is_terminal {
eprintln!(
"rich: Sixel graphics need a terminal, drawing the diff as {} instead",
if has_color { "blocks" } else { "ASCII art" }
);
mode = if has_color {
ImageMode::Blocks
} else {
ImageMode::Ascii
};
}
match mode {
ImageMode::None => {}
ImageMode::Ascii => c.print(
&AsciiArt::new(report.heatmap())
.width(width)
.color(has_color),
),
ImageMode::Blocks => c.print(
&BlockArt::new(report.heatmap())
.width(width)
.height(rows_cap),
),
ImageMode::Sixel => {
let art = SixelArt::new(report.heatmap())
.width(width)
.height(rows_cap);
if art.encode(width).is_some() {
c.print(&art);
} else {
eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
c.print(
&BlockArt::new(report.heatmap())
.width(width)
.height(rows_cap),
);
}
}
ImageMode::Auto => unreachable!("resolved above"),
}
if mode != ImageMode::None {
c.print_str("");
}
c.print_str(&format!(
"[bold]{changed:.1}%[/] of the canvas changed perceptibly \
[dim](a plain pixel diff would say {naive:.1}%)[/]"
));
if report.regions.is_empty() {
c.print_str("[dim]No region large enough to report.[/]");
} else {
let mut table = Table::new().title("Where it changed");
table.add_column("#");
table.add_column_justify("Share", rich::Justify::Right);
table.add_column_justify("Mean ΔE", rich::Justify::Right);
table.add_column_justify("Area px", rich::Justify::Right);
table.add_column("Box (x,y w×h)");
for (rank, r) in report.regions.iter().enumerate() {
table.add_row(&[
&format!("{}", rank + 1),
&format!("{:.0}%", r.share_of_change * 100.0),
&format!("{:.1}", r.mean_delta_e),
&format!("{}", r.area_px),
&format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
]);
}
c.print(&table);
}
if let Some(limit) = cli.diff_threshold {
if failed {
c.print_str(&format!(
"[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
));
} else {
c.print_str(&format!(
"[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
));
}
}
});
if !wrote || failed {
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
#[cfg(feature = "art")]
fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
use rich_art::{AnimatedArt, Repeat, Stage};
if cli.resources.is_empty() {
eprintln!("rich: --gif needs at least one GIF path");
return ExitCode::FAILURE;
}
let count = cli.resources.len();
const GAP: usize = 2;
let total = cli.width.unwrap_or_else(|| console.width());
let per_gif = total
.saturating_sub(GAP * count.saturating_sub(1))
.checked_div(count)
.unwrap_or(total)
.max(8);
let repeat = match cli.loops {
Some(0) => Repeat::Forever,
Some(n) => Repeat::Times(n),
None => Repeat::Times(1),
};
let mut stage = Stage::new().gap(GAP);
for path in &cli.resources {
match AnimatedArt::from_path(path) {
Ok(art) => {
stage = stage.with(
art.width(per_gif)
.color(!cli.no_color)
.repeat(repeat)
.max_fps(30.0),
);
}
Err(err) => {
eprintln!("rich: cannot read {path}: {err}");
return ExitCode::FAILURE;
}
}
}
let mut builder = Console::builder().no_color(cli.no_color);
if let Some(width) = cli.width {
builder = builder.width(width);
}
match stage.play_stdout(builder.build()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("rich: playback failed: {err}");
ExitCode::FAILURE
}
}
}
#[cfg(not(feature = "art"))]
fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
eprintln!("rich: this build has no GIF support (rebuild with the `art` feature)");
ExitCode::FAILURE
}
struct Export<'a> {
html_path: Option<&'a str>,
svg_path: Option<&'a str>,
svg_title: &'a str,
pager: bool,
}
fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> bool {
if export.html_path.is_none() && export.svg_path.is_none() {
if export.pager {
if let Err(err) = console.page(true, render) {
eprintln!("rich: cannot page output: {err}");
return false;
}
} else {
render(console);
}
return true;
}
let segments = console.record_output(render);
let mut ok = true;
let terminal = console.segments_to_string(&segments);
if export.pager {
if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
eprintln!("rich: cannot page output: {err}");
ok = false;
}
} else {
print!("{terminal}");
}
if let Some(path) = export.html_path {
let html = rich::export::export_html_classes(&segments, &DEFAULT_TERMINAL_THEME);
if let Err(err) = std::fs::write(path, html) {
eprintln!("rich: failed to save HTML: {err}");
ok = false;
}
}
if let Some(path) = export.svg_path {
let svg = rich::svg::export_svg(
&segments,
&rich::SVG_EXPORT_THEME,
export.svg_title,
"rich-cli",
console.width(),
);
if let Err(err) = std::fs::write(path, svg) {
eprintln!("rich: failed to save SVG: {err}");
ok = false;
}
}
ok
}
fn print_help() {
println!(
r#"rich {VERSION} — Rust port of the rich-cli terminal toolbox
USAGE:
rich [OPTIONS] [RESOURCE]
RESOURCE is a file path, an http(s) URL, or `-` for stdin. Everything after a
bare `--` is a RESOURCE, however much it looks like an option.
RENDER MODE (choose at most one; default auto-detects .md/.json/.csv/.tsv/.ipynb
by extension — anything else with a file extension is syntax-highlighted):
-p, --print Treat RESOURCE as literal markup TEXT, not a file path
-m, --markdown Render RESOURCE as Markdown
-j, --json Pretty-print RESOURCE as JSON
-x, --syntax Syntax-highlight RESOURCE (language from its extension)
--csv Render RESOURCE as a CSV/TSV table
--ipynb Render RESOURCE as a Jupyter notebook
--gif Animate one or more GIFs (several play side by side)
--loop N With --gif, repeat N times (0 = forever)
--rule Draw a horizontal rule (RESOURCE is its title)
--diff Perceptually compare two images (needs exactly two)
OPTIONS:
-w, --width N Render the output N columns wide (the console keeps its
own width, so --left/--center/--right still use it)
--image-mode M
With --diff, how to draw the picture: auto (default),
sixel (real pixels), blocks, ascii, none
--threshold PCT
With --diff, exit non-zero above PCT% changed.
Also sets the exit code: 0 within, 1 over.
--left Left-justify output
--center Center output
--right Right-justify output
-o, --export-html PATH
Also write a self-contained HTML document to PATH
--export-svg PATH
Also write an SVG document to PATH. Unlike the HTML,
it references its font from a CDN, so it is not
self-contained offline.
--panel BOX Wrap output in a panel, shrunk to fit its content
(ascii/ascii2/square/rounded/heavy/double; none = no panel)
--padding P Wrap output in padding (1, 2, or 4 comma-separated ints)
-e, --expand Make --panel/--padding fill the width instead of fitting
(implied by --width)
--title T Panel title; also the CSV table's title
--caption T Panel subtitle; also the CSV table's caption
-y, --hyperlinks Render a Markdown link as a clickable OSC 8 hyperlink.
Off by default, which shows the URL as `text (url)`
-s, --style S Style laid under the whole output, e.g. "bold red"
-S, --panel-style S
Panel border style, e.g. "dim" (with --panel)
--pager Page the output through $PAGER (no pager, no paging)
--no-color Disable colored output (as does a non-empty NO_COLOR)
-h, --help Show this help
-V, --version Show the version (mirrors upstream rich-cli)
ENVIRONMENT:
NO_COLOR Any non-empty value disables colour
RICH_SIXEL 0/1 overrides Sixel detection for --image-mode auto
With no RESOURCE and no mode flag, a capability demo is shown.
"#
);
}
fn run_demo(no_color: bool) {
let to_terminal = std::io::stdout().is_terminal() && !no_color;
let mut console = Console::builder()
.force_terminal(to_terminal)
.no_color(no_color)
.color_system(if to_terminal {
Some(ColorSystem::Truecolor)
} else {
None
})
.theme(rich_ext::extended_theme())
.build();
console.install_extensions();
console.print(&Rule::new("rs-rich-cli"));
console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
console.print_str("Everything below is byte-parity-tested against Python rich 15.0.0.");
console.print(&Rule::new("markup · color · theme · extension"));
console.print_str(
"Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
);
console.print_str(
"Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
);
console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
let hl = Console::builder()
.force_terminal(to_terminal)
.no_color(no_color)
.color_system(if to_terminal {
Some(ColorSystem::Truecolor)
} else {
None
})
.highlight(true)
.build();
hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
ISO8601Highlighter::new().highlight(&mut stamp);
console.print(&Text::new("ISO 8601: ").append_text(&stamp));
console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
console.print(&Rule::new("rule"));
console.print(&Rule::line());
console.print(&Rule::new("centered"));
console.print(&Rule::new("left").align(HorizontalAlign::Left));
console.print(&Rule::new("right").align(HorizontalAlign::Right));
console.print(&Rule::new("panel"));
console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
console.print(
&Panel::new(text("square box"))
.box_set(SQUARE)
.title("square"),
);
console.print(
&Panel::new(text("double box"))
.box_set(DOUBLE)
.title("double"),
);
console.print(
&Panel::new(text("title + subtitle"))
.title("top")
.subtitle("bottom"),
);
let legacy = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(console.width())
.legacy_windows(true)
.build();
legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
console.print(&Rule::new("padding"));
console.print(
&Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
);
console.print(&Rule::new("styled"));
console.print(&Styled::new(
Box::new(Panel::new(text("green under the whole panel")).title("styled")),
Style::parse("green").unwrap(),
));
console.print(&Rule::new("align"));
console.print(&Align::left(text("← left")));
console.print(&Align::center(text("center")));
console.print(&Align::right(text("right →")));
console.print(&Rule::new("justify"));
console.print_justified("left justified", Justify::Left);
console.print_justified("centered", Justify::Center);
console.print_justified("right justified", Justify::Right);
console.print(&Rule::new("constrain (width 24)"));
console.print(&Constrain::new(
Box::new(Panel::new(text("constrained")).title("≤24")),
Some(24),
));
console.print(&Rule::new("table"));
let mut table = Table::new().title("ported renderables");
table
.add_column("Renderable")
.column_style(Style::parse("cyan").unwrap());
table.add_column("Module").column_width(12);
table.add_column_justify("Parity", Justify::Center);
for (renderable, module, parity) in [
("Text / markup", "text, markup", "✓"),
("Rule", "rule", "✓"),
("Panel", "panel", "✓"),
("Padding", "padding", "✓"),
("Align", "align", "✓"),
("Constrain", "constrain", "✓"),
("Columns", "columns", "✓"),
("Table", "table", "✓"),
("Tree", "tree", "✓"),
("ProgressBar", "progress_bar", "✓"),
] {
table.add_row(&[renderable, module, parity]);
}
console.print(&table);
console.print(&Rule::new("columns"));
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
console.print(&Columns::new(
months.iter().map(|m| m.to_string()).collect(),
));
console.print(&Rule::new("tree"));
let mut tree = Tree::new("rs-rich-cli");
let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
core.add("color · style · text · console");
core.add("box · rule · panel · padding · align");
core.add("table · tree · wrap · filesize");
tree.add("crates/rich-ext (our extensions)");
tree.add("crates/rich-cli (this binary)");
console.print(&tree);
console.print(&Rule::new("progress bar"));
for pct in [0.0, 33.0, 66.0, 100.0] {
console.print(&ProgressBar::new(100.0, pct).width(48));
}
console.print(&Rule::new("progress"));
let mut progress = Progress::new();
progress.add_task("Downloading", 100.0, 50.0);
progress.add_task("Processing", 100.0, 100.0);
progress.add_task("Waiting", 100.0, 0.0);
console.print(&progress);
let mut mofn = Progress::new().columns(vec![
ProgressColumn::Description,
ProgressColumn::Bar,
ProgressColumn::MofN,
]);
mofn.add_task("Files", 8.0, 3.0);
mofn.add_task("Chunks", 128.0, 128.0);
console.print(&mofn);
let mut download = Progress::new().columns(vec![
ProgressColumn::Description,
ProgressColumn::Bar,
ProgressColumn::Download,
]);
download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
console.print(&download);
console.print(&Rule::new("json"));
if let Ok(json) = Json::new(
r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
) {
console.print(&json);
}
console.print(&Rule::new("pretty"));
let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
console.print(&Pretty::new(&value));
console.print(&Rule::new("log"));
for (level, message) in [
(LogLevel::Info, "server started on port 8080"),
(LogLevel::Debug, "cache warm: 128 entries"),
(LogLevel::Warn, "disk usage at 85%"),
(LogLevel::Error, "connection reset by peer"),
] {
console.print(
&LogRender::new(level, message)
.time("12:00:00")
.path("main.rs:42"),
);
}
console.print(&Rule::new("traceback"));
let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
let err = std::io::Error::other(cause);
console.print(&Traceback::new(&err));
console.print(&Rule::new("bar (range)"));
for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
console.print(&Bar::new(100.0, begin, end).width(48));
}
console.print(&Rule::new("markdown"));
console.print(&Markdown::new(
"# Heading\n\nA paragraph with **bold**, *italic*, `code`, and a [link](https://example.com).\n\n- bullet item\n- another\n\n1. first\n2. second\n\n> a block quote\n\n| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n---",
));
console.print(&Rule::new("syntax"));
console.print(&Syntax::new(
"fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
"rust",
));
console.print(&Rule::new("control codes"));
let show_escape = |label: &str, control: &Control| {
let escaped = control
.as_str()
.replace('\x1b', "\\x1b")
.replace('\x07', "\\a")
.replace('\r', "\\r");
console.print(&Text::new(format!(" {label:>14}: {escaped}")));
};
show_escape("clear screen", &Control::clear());
show_escape("home", &Control::home());
show_escape("move (2,-1)", &Control::move_(2, -1));
show_escape("move_to (3,4)", &Control::move_to(3, 4));
show_escape("hide cursor", &Control::show_cursor(false));
show_escape("bell", &Control::bell());
let live_render = LiveRender::new(text("a\nb\nc"));
let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
let live_console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(20)
.build();
let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
live.start();
live.update(text("frame two"));
live.stop();
let stream = String::from_utf8_lossy(live.writer())
.replace('\x1b', "\\x1b")
.replace('\r', "\\r")
.replace('\n', "\\n");
console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
console.print(&Rule::new("ansi decoder"));
let raw =
"\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
console.print(&Text::new(format!(
" input: {}",
raw.replace('\x1b', "\\x1b")
)));
let mut decoder = rich::AnsiDecoder::new();
for line in decoder.decode(raw) {
console.print(&Text::new(" output: ").append_text(&line));
}
console.print(&Rule::new("capture · export_text"));
let plain = console.export_text(|c| {
c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
});
console.print(&Text::new(" export_text (styles stripped):"));
for line in plain.lines() {
console.print(&Text::new(format!(" {line}")));
}
let html_rule = Style::parse("bold red")
.unwrap()
.get_html_style(&DEFAULT_TERMINAL_THEME);
console.print(&Text::new(format!(
" export_html: [bold red] → <span style=\"{html_rule}\">"
)));
console.print(&Rule::new("layout"));
let panel_leaf = |title: &str, body: &str| {
Layout::with_renderable(Box::new(
Panel::new(text(body))
.box_set(SQUARE)
.title(title.to_string()),
))
};
let mut header = Layout::new();
header.split_row(vec![
panel_leaf("left", "pane A"),
panel_leaf("right", "pane B"),
]);
let mut root = Layout::new();
root.split_column(vec![
header,
Layout::with_renderable(text("footer (fixed size 3)")).size(3),
]);
let grid = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(48)
.height(7)
.build();
for line in grid.export_text(|c| c.print(&root)).lines() {
console.print(&Text::new(line));
}
console.print(&Rule::new("spinner (frames)"));
for name in ["dots", "line", "arrow", "simpleDots"] {
let spinner = Spinner::new(name);
let frames: String = (0..6)
.map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
.collect::<Vec<_>>()
.join(" ");
console.print_str(&format!(" {name:>11}: {frames}"));
}
let status = Status::new("Loading…").renderable().render(0.0);
console.print(&Text::new(" status: ").append_text(&status));
console.print(&Rule::new("csv"));
let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
if let Some(table) = build_csv_table(csv, Some(','), None, None) {
console.print(&table);
}
console.print(&Rule::new("filesize"));
for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
}
console.print(&Rule::line());
}
#[cfg(test)]
mod tests {
use super::*;
use rich::ColorSystem;
#[test]
fn read_csv_rows_handles_quotes_and_delimiters() {
let comma = Dialect::excel(',');
let rows = read_csv_rows("a,\"b,c\",d\n\"he said \"\"hi\"\"\",2\n", &comma);
assert_eq!(
rows,
vec![
vec!["a".to_string(), "b,c".to_string(), "d".to_string()],
vec!["he said \"hi\"".to_string(), "2".to_string()],
]
);
assert_eq!(read_csv_rows("x\ny\n", &comma), vec![vec!["x"], vec!["y"]]);
assert_eq!(
read_csv_rows("a\tb", &Dialect::excel('\t')),
vec![vec!["a", "b"]]
);
assert_eq!(read_csv_rows("\u{feff}a,b", &comma), vec![vec!["a", "b"]]);
assert_eq!(
read_csv_rows("a,b\n\nc,d\n", &comma),
vec![vec!["a", "b"], vec![], vec!["c", "d"]]
);
let padded = Dialect {
skipinitialspace: true,
..Dialect::excel(',')
};
assert_eq!(
read_csv_rows("a, b , c", &padded),
vec![vec!["a", "b ", "c"]]
);
}
#[test]
fn the_sniffer_finds_the_delimiter_and_the_header() {
let candidates = [',', '\t', '|', ';'];
let sniffed =
|sample: &str| sniff(sample, Some(&candidates)).map(|dialect| dialect.delimiter);
assert_eq!(
sniffed("name;age;city\nAlice;30;Paris\nBob;25;Lyon\n"),
Some(';')
);
assert_eq!(sniffed("a|b|c\n1|2|3\n4|5|6\n"), Some('|'));
assert_eq!(sniffed("a\tb\tc\n1\t2\t3\n4\t5\t6\n"), Some('\t'));
assert_eq!(sniffed("a, b, c\n1, 2, 3\n4, 5, 6\n"), Some(','));
assert_eq!(
sniffed("name,bio\nAlice,\"line one\nline two\"\nBob,short\n"),
Some(',')
);
assert_eq!(sniffed("a,b,c\n1,2\n3,4,5,6\n"), None);
assert_eq!(sniffed("alpha\nbeta\ngamma\n"), None);
assert_eq!(has_header("name,age\nAlice,30\nBob,25\n"), Some(true));
assert_eq!(has_header("price,note\n10,aa\n9.5,bbb\n"), Some(true));
assert_eq!(has_header("1,2,3\n4,5,6\n7,8,9\n"), Some(false));
}
#[test]
fn detects_urls() {
assert!(is_url("http://example.com"));
assert!(is_url("https://example.com/x"));
assert!(is_url("HTTPS://example.com"));
assert!(is_url("Http://example.com"));
assert!(!is_url("example.com"));
assert!(!is_url("./file.md"));
assert!(!is_url("-"));
assert!(!is_url("")); assert!(!is_url("ht"));
assert!(!is_url("ftp://example.com"));
}
#[test]
fn uninformative_extensions_defer_to_content_type() {
assert!(is_uninformative_ext("txt"));
assert!(is_uninformative_ext("log"));
assert!(!is_uninformative_ext("py"));
assert!(!is_uninformative_ext("rs"));
}
#[test]
fn resource_ext_uses_basename_without_query() {
assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
assert_eq!(
resource_ext("https://x.com/c.py?raw=1").as_deref(),
Some("py")
);
assert_eq!(resource_ext("https://api.example.com/data"), None);
assert_eq!(resource_ext("https://x.com/"), None);
assert_eq!(resource_ext("noext"), None);
}
#[test]
fn content_type_maps_mode_and_lexer() {
assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
assert_eq!(
content_type_mode("application/json; charset=utf-8"),
Some(Mode::Json)
);
assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
assert_eq!(content_type_lexer("text/x-python"), Some("python"));
assert_eq!(content_type_lexer("text/plain"), None);
}
#[test]
fn join_source_handles_string_and_array() {
use serde_json::json;
assert_eq!(join_source(&json!("a\nb")), "a\nb");
assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
assert_eq!(join_source(&json!(null)), "");
}
#[test]
fn ipynb_extension_auto_detects() {
assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
}
#[test]
fn io_label_builds_execution_count() {
assert_eq!(
io_label("In ", Some(1), "green", "#66ff00").plain(),
"In [1]:"
);
assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
}
#[test]
fn parse_padding_unpacks_like_upstream() {
assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
}
#[test]
fn parse_box_maps_names() {
assert!(matches!(parse_box("rounded"), Ok(Some(_))));
assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
assert!(matches!(parse_box("none"), Ok(None)));
assert!(parse_box("bogus").is_err());
}
#[test]
fn parses_pager_flag() {
let s = |v: &str| v.to_string();
assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
}
#[test]
fn parses_export_flags() {
let s = |v: &str| v.to_string();
let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
.unwrap()
.unwrap();
assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
assert!(cli.export_html.is_none());
assert_eq!(cli.resource.as_deref(), Some("x"));
let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
assert_eq!(cli.export_html.as_deref(), Some("out.html"));
let cli = parse(&[
s("--export-html"),
s("a.html"),
s("--export-svg"),
s("b.svg"),
s("x"),
])
.unwrap()
.unwrap();
assert_eq!(cli.export_html.as_deref(), Some("a.html"));
assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
assert!(parse(&[s("--export-html")]).is_err());
}
#[test]
fn is_number_matches_pattern() {
for ok in [
"0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
] {
assert!(is_number(ok), "{ok:?} should be numeric");
}
for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
assert!(!is_number(no), "{no:?} should not be numeric");
}
}
#[test]
fn render_csv_matches_upstream() {
let table = build_csv_table(
"Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
Some(','),
None,
None,
)
.expect("the sniffer reads this one");
let out = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(40)
.no_color(false)
.build()
.render_to_string(&table);
let expected = concat!(
"\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
"\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
"\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
"\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
"\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
"\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
"\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
"\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
"\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
"\x1b[34m└───────┴─────┴──────┘\x1b[0m",
);
assert_eq!(out, expected);
}
}