use std::io::{BufRead, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod batch;
mod batch_output;
mod batch_paths;
#[cfg(test)]
mod batch_races;
mod config;
mod demo;
mod doctor;
mod render_target;
mod structured_log;
use batch::run_batch;
use config::{config_args, ConfigRoots};
use rich::cells::cell_len;
use rich::markdown::Markdown;
use rich::measure::Measurement;
use rich::protocol::{LineRenderable, OwnedTableRows};
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::cli::CliExtensions;
use rich_ext::encoding::{has_utf16_bom, Encoding};
use rich_ext::sanitize_terminal_controls;
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,
Image,
Rule,
JsonLines,
Log,
}
impl Mode {
fn streams_records(self) -> bool {
matches!(self, Self::JsonLines | Self::Log)
}
fn draws_directly(self) -> bool {
matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
}
fn allows_extensions(self) -> bool {
!matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
&& !self.streams_records()
}
fn accepts_multiple_resources(self) -> bool {
matches!(self, Self::Gif | Self::Diff)
}
}
struct ModeSpec {
mode: Mode,
primary: &'static str,
aliases: &'static [&'static str],
}
const MODE_SPECS: &[ModeSpec] = &[
ModeSpec {
mode: Mode::Auto,
primary: "auto",
aliases: &[],
},
ModeSpec {
mode: Mode::Print,
primary: "print",
aliases: &["print"],
},
ModeSpec {
mode: Mode::Markdown,
primary: "markdown",
aliases: &["markdown", "md"],
},
ModeSpec {
mode: Mode::Json,
primary: "json",
aliases: &["json"],
},
ModeSpec {
mode: Mode::Syntax,
primary: "syntax",
aliases: &["syntax", "code"],
},
ModeSpec {
mode: Mode::Csv,
primary: "csv",
aliases: &["csv", "tsv"],
},
ModeSpec {
mode: Mode::Ipynb,
primary: "ipynb",
aliases: &["ipynb", "notebook"],
},
ModeSpec {
mode: Mode::Gif,
primary: "gif",
aliases: &["gif"],
},
ModeSpec {
mode: Mode::Diff,
primary: "diff",
aliases: &["diff"],
},
ModeSpec {
mode: Mode::Image,
primary: "image",
aliases: &["image"],
},
ModeSpec {
mode: Mode::Rule,
primary: "rule",
aliases: &["rule"],
},
ModeSpec {
mode: Mode::JsonLines,
primary: "jsonl",
aliases: &["jsonl", "ndjson"],
},
ModeSpec {
mode: Mode::Log,
primary: "log",
aliases: &["log", "logs"],
},
];
const RENDER_MODE_FLAGS: &str =
"--print/--markdown/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReportFormat {
Human,
Json,
}
impl ReportFormat {
fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
match option {
"--report" => {
*self = match value.ok_or("--report requires one of: human, json")? {
"human" => Self::Human,
"json" => Self::Json,
other => return Err(format!("unknown report format {other:?} (human, json)")),
};
}
"--machine-json" => *self = Self::Json,
_ => {}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExitClass {
Success,
Usage,
Input,
Data,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
Gate,
}
impl ExitClass {
fn code(self) -> u8 {
match self {
Self::Success => 0,
Self::Usage => 2,
Self::Input => 3,
Self::Data => 4,
Self::Gate => 5,
}
}
fn name(self) -> &'static str {
match self {
Self::Success => "success",
Self::Usage => "usage",
Self::Input => "input",
Self::Data => "data",
Self::Gate => "gate",
}
}
fn exit_code(self) -> ExitCode {
ExitCode::from(self.code())
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ImageMode {
Auto,
Sixel,
Blocks,
Braille,
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" | "half-block" | "half-blocks" => Ok(Self::Blocks),
"braille" => Ok(Self::Braille),
"ascii" | "art" => Ok(Self::Ascii),
"none" | "off" => Ok(Self::None),
other => Err(format!(
"unknown image mode {other:?} (auto, sixel, blocks, ascii, none)"
)),
}
}
}
#[derive(Clone)]
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,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_fit: Option<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_anchor: Option<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_background: Option<[u8; 3]>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_color: Option<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_dither: Option<String>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_rotate: Option<u16>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_flip_horizontal: bool,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_flip_vertical: bool,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
image_grayscale: bool,
log_presentation: String,
theme_styles: std::collections::BTreeMap<String, Style>,
#[cfg_attr(not(feature = "art"), allow(dead_code))]
height: Option<usize>,
extensions: CliExtensions,
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,
auto_pager: bool,
hyperlinks: bool,
sanitize: bool,
report_format: ReportFormat,
watch: bool,
watch_interval: f64,
watch_cache: bool,
batch: bool,
progress: bool,
continue_on_error: bool,
jobs: usize,
dry_run: bool,
batch_paths: batch_paths::BatchPathOptions,
worker_args: Vec<String>,
overwrite: bool,
collision: CollisionPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CollisionPolicy {
Error,
Overwrite,
Suffix,
}
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();
if demo::requested(&args) {
return demo::dispatch(&args);
}
if doctor::requested(&args) {
return doctor::dispatch(&args);
}
match parse(&args) {
Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
if cli.batch {
if let Err(error) = batch::install_interrupt_handler() {
return fail(
&cli,
ExitClass::Input,
format!("cannot install batch interruption handler: {error}"),
);
}
}
run(cli)
}
Err(message) => emit_error(
wants_json_report(&args),
ExitClass::Usage,
&format!("{message} (try --help)"),
),
}
}
const VALUE_OPTIONS: &[&str] = &[
"--batch-input-root",
"--batch-name-template",
"--log-presentation",
"-w",
"--width",
"-o",
"--export-html",
"--export-svg",
"--panel",
"--padding",
"--title",
"--caption",
"-s",
"--style",
"-S",
"--panel-style",
"--report",
"--image-mode",
"--image-fit",
"--image-anchor",
"--image-background",
"--height",
"--theme",
"--theme-style",
"--image-color",
"--image-dither",
"--image-rotate",
"--demo-section",
"--demo-delay",
"--watch-interval",
"--interval",
"--gif-mode",
"--encoding",
"--threshold",
"--loop",
"--jobs",
"--collision",
"--config",
"--profile",
];
fn selects_mode_explicitly(args: &[String]) -> bool {
let mut iter = args.iter();
let mut seen_positional = false;
while let Some(arg) = iter.next() {
if arg == "--" {
break;
}
if VALUE_OPTIONS.contains(&arg.as_str()) {
iter.next();
continue;
}
if arg.starts_with('-') && arg.len() > 1 {
if mode_flag_alias(arg).is_some() {
return true;
}
continue;
}
if !seen_positional {
seen_positional = true;
if command_mode(arg).is_some() {
return true;
}
}
}
false
}
fn mode_flag_alias(arg: &str) -> Option<&'static str> {
Some(match arg {
"--print" | "-p" => "--print",
"--markdown" | "-m" => "--markdown",
"--json" | "-j" => "--json",
"--syntax" | "-x" => "--syntax",
"--csv" => "--csv",
"--ipynb" => "--ipynb",
"--jsonl" | "--ndjson" => "--jsonl",
"--log" => "--log",
"--rule" => "--rule",
"--image" => "--image",
"--gif" => "--gif",
"--diff" => "--diff",
_ => return None,
})
}
fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
if json {
eprintln!(
"{}",
serde_json::json!({
"ok": false,
"code": class.name(),
"exit_code": class.code(),
"message": message,
"error": {
"message": message,
},
})
);
} else {
eprintln!("rich: {message}");
}
class.exit_code()
}
fn emit_success_report(format: ReportFormat) {
if format == ReportFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"ok": true,
"code": ExitClass::Success.name(),
"exit_code": ExitClass::Success.code(),
"result": {},
})
);
}
}
fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
emit_error(
cli.report_format == ReportFormat::Json,
class,
message.as_ref(),
)
}
fn success(cli: &Cli) -> ExitCode {
emit_success_report(cli.report_format);
ExitClass::Success.exit_code()
}
fn glob_match(pattern: &str, text: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let text: Vec<char> = text.chars().collect();
let (mut p, mut t) = (0usize, 0usize);
let (mut star, mut retry) = (None, 0usize);
while t < text.len() {
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
p += 1;
t += 1;
} else if p < pattern.len() && pattern[p] == '*' {
star = Some(p);
retry = t;
p += 1;
} else if let Some(open) = star {
p = open + 1;
retry += 1;
t = retry;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == '*' {
p += 1;
}
p == pattern.len()
}
fn has_glob_meta(value: &str) -> bool {
value.contains('*') || value.contains('?')
}
fn split_glob(resource: &str) -> (PathBuf, String) {
let meta = resource.find(['*', '?']).unwrap_or(0);
match resource[..meta].rfind(['/', '\\']) {
Some(cut) => (
PathBuf::from(&resource[..cut]),
resource[cut + 1..].to_string(),
),
None => (PathBuf::from("."), resource.to_string()),
}
}
fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
batch::check_interrupted()?;
let entries = std::fs::read_dir(¤t)
.map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
for entry in entries {
batch::check_interrupted()?;
let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
let path = entry.path();
let kind = std::fs::symlink_metadata(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?
.file_type();
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
stack.push(path);
} else if kind.is_file() {
out.push(path.to_string_lossy().into_owned());
}
}
}
Ok(())
}
fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
let mut out = Vec::new();
for resource in resources {
batch::check_interrupted()?;
if resource == "-" || is_url(resource) {
return Err("batch resources must be local files or directories".into());
}
if has_glob_meta(resource) {
let (dir, pattern) = split_glob(resource);
if pattern.contains('/') || pattern.contains('\\') {
return Err(format!(
"batch glob {resource:?} may only use * and ? in the final path segment"
));
}
let entries = std::fs::read_dir(&dir)
.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
for entry in entries {
batch::check_interrupted()?;
let entry =
entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
if glob_match(&pattern, &entry.file_name().to_string_lossy()) {
out.push(path.to_string_lossy().into_owned());
}
}
continue;
}
let path = Path::new(resource);
if path.is_file() {
out.push(resource.clone());
} else if path.is_dir() {
collect_files(path, &mut out)?;
} else {
return Err(format!("batch resource does not exist: {resource}"));
}
}
batch::check_interrupted()?;
out.sort();
out.dedup();
batch::check_interrupted()?;
if out.is_empty() {
return Err("batch plan contains no files".into());
}
Ok(out)
}
#[derive(Default, Clone)]
struct BatchOutputs {
html: Option<String>,
svg: Option<String>,
}
fn batch_export_path(
path: Option<&str>,
input: &str,
index: usize,
total: usize,
) -> Option<String> {
let path = path?;
let p = Path::new(path);
if total == 1 {
return Some(path.into());
}
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or_default();
let stem = Path::new(input)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let name = if ext.is_empty() {
format!("{stem}-{index}")
} else {
format!("{stem}.{ext}")
};
Some(if p.is_dir() {
p.join(name).to_string_lossy().into_owned()
} else {
p.with_file_name(name).to_string_lossy().into_owned()
})
}
fn suffixed_path(path: &Path, suffix: usize) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let name = match path.extension().and_then(|s| s.to_str()) {
Some(ext) => format!("{stem}-{suffix}.{ext}"),
None => format!("{stem}-{suffix}"),
};
path.with_file_name(name).to_string_lossy().into_owned()
}
fn resolve_destination(
cli: &Cli,
candidate: String,
taken: &mut std::collections::BTreeSet<String>,
) -> Result<String, (ExitClass, String)> {
let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
taken.contains(path) || (!cli.overwrite && Path::new(path).exists())
};
match cli.collision {
CollisionPolicy::Overwrite => {
taken.insert(candidate.clone());
Ok(candidate)
}
CollisionPolicy::Suffix => {
let mut chosen = candidate.clone();
let mut suffix = 2;
while occupied(&chosen, taken) {
batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
chosen = suffixed_path(Path::new(&candidate), suffix);
suffix += 1;
}
taken.insert(chosen.clone());
Ok(chosen)
}
CollisionPolicy::Error => {
if taken.contains(&candidate) {
return Err((
ExitClass::Usage,
format!("batch output collision: {candidate}"),
));
}
if !cli.overwrite && Path::new(&candidate).exists() {
return Err((
ExitClass::Input,
format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
));
}
taken.insert(candidate.clone());
Ok(candidate)
}
}
}
fn worker_args(args: &[String]) -> Vec<String> {
let mut result = vec!["--no-config".into()];
let mut iter = args.iter();
let mut seen_resource = false;
let mut selected_mode = false;
while let Some(arg) = iter.next() {
if arg == "--" {
break;
}
if VALUE_OPTIONS.contains(&arg.as_str()) {
let value = iter.next();
if !matches!(
arg.as_str(),
"--jobs"
| "--batch-input-root"
| "--batch-name-template"
| "--collision"
| "--report"
| "-o"
| "--export-html"
| "--export-svg"
| "--config"
| "--profile"
) {
result.push(arg.clone());
if let Some(value) = value {
result.push(value.clone());
}
}
} else if arg.starts_with('-') && arg != "-" {
selected_mode |= mode_flag_alias(arg).is_some();
if !matches!(
arg.as_str(),
"--batch"
| "--batch-preserve-dirs"
| "--no-batch-preserve-dirs"
| "--no-batch"
| "--dry-run"
| "--progress"
| "--no-progress"
| "--continue-on-error"
| "--no-continue-on-error"
| "--overwrite"
| "--no-overwrite"
| "--machine-json"
) {
result.push(arg.clone());
}
} else if !seen_resource {
if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
selected_mode = true;
result.push(format!("--{}", mode_name(mode)));
} else {
seen_resource = true;
}
}
}
result
}
fn wants_json_report(args: &[String]) -> bool {
let mut format = ReportFormat::Human;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--report" => {
if let Some(value) = iter.next() {
let _ = format.apply_option("--report", Some(value));
}
}
"--machine-json" => {
let _ = format.apply_option("--machine-json", None);
}
"--" => break,
_ => {}
}
}
format == ReportFormat::Json
}
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 mode_name(mode: Mode) -> &'static str {
MODE_SPECS
.iter()
.find_map(|spec| (spec.mode == mode).then_some(spec.primary))
.unwrap_or("unknown")
}
fn command_mode(command: &str) -> Option<Mode> {
MODE_SPECS
.iter()
.find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
}
fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
if *current != Mode::Auto && *current != mode {
return Err(format!(
"only one render mode ({RENDER_MODE_FLAGS}) may be given"
));
}
*current = mode;
Ok(())
}
fn parse(args: &[String]) -> Result<Option<Cli>, String> {
let roots = ConfigRoots::default();
if let Some(output) = config::inspect(args, &roots)? {
println!("{output}");
return Ok(None);
}
let merged = config_args(args, &roots)?;
parse_inner(&merged)
}
fn parse_inner(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 image_fit = None;
let mut image_anchor = None;
let mut image_background = None;
let mut image_color = None;
let mut image_dither = None;
let mut image_rotate = None;
let mut image_flip_horizontal = false;
let mut image_flip_vertical = false;
let mut image_grayscale = false;
let mut log_presentation = String::from("plain");
let mut theme_styles = std::collections::BTreeMap::new();
let mut height = None;
let mut extensions = CliExtensions::default();
let mut width = None;
let mut justify = None;
let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
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 auto_pager = false;
let mut hyperlinks = false;
let mut sanitize = false;
let mut report_format = ReportFormat::Human;
let mut watch = false;
let mut watch_interval = 1.0;
let mut watch_cache = false;
let mut batch = false;
let mut progress = true;
let mut continue_on_error = false;
let mut jobs = 1usize;
let mut dry_run = false;
let mut batch_paths = batch_paths::BatchPathOptions::default();
let mut overwrite = false;
let mut collision = CollisionPolicy::Error;
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;
}
if extensions.parse_option(arg, &mut iter)? {
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" => set_mode(&mut mode, Mode::Image)?,
"--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
"--log" => set_mode(&mut mode, Mode::Log)?,
"--log-presentation" => {
let value = iter
.next()
.ok_or("--log-presentation requires plain or rich")?;
if !matches!(value.as_str(), "plain" | "rich") {
return Err("--log-presentation requires plain or rich".into());
}
log_presentation = value.clone();
}
"--report" => {
let value = iter.next().map(String::as_str);
report_format.apply_option("--report", value)?;
}
"--machine-json" => {
report_format.apply_option("--machine-json", None)?;
}
"--progress" => progress = true,
"--no-progress" => progress = false,
"--theme-style" => {
let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
let (name, value) = binding
.split_once('=')
.ok_or("--theme-style requires NAME=STYLE")?;
config::validate_theme_binding(name, value)?;
let style = Style::parse(value)
.map_err(|error| format!("invalid theme style {name}: {error}"))?;
theme_styles.insert(name.to_owned(), style);
}
"--image-color" => {
let value = iter
.next()
.ok_or("--image-color requires truecolor or ansi256")?;
if !matches!(value.as_str(), "truecolor" | "ansi256") {
return Err("--image-color requires truecolor or ansi256".into());
}
image_color = Some(value.clone());
}
"--image-flip-horizontal" => image_flip_horizontal = true,
"--no-image-flip-horizontal" => image_flip_horizontal = false,
"--image-flip-vertical" => image_flip_vertical = true,
"--no-image-flip-vertical" => image_flip_vertical = false,
"--image-grayscale" => image_grayscale = true,
"--no-image-grayscale" => image_grayscale = false,
"--image-rotate" => {
let value = iter
.next()
.ok_or("--image-rotate requires 0, 90, 180 or 270")?;
image_rotate = Some(match value.as_str() {
"0" => 0,
"90" => 90,
"180" => 180,
"270" => 270,
_ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
});
}
"--image-dither" => {
let value = iter
.next()
.ok_or("--image-dither requires none, floyd-steinberg or bayer4x4")?;
if !matches!(value.as_str(), "none" | "floyd-steinberg" | "bayer4x4") {
return Err("--image-dither requires none, floyd-steinberg or bayer4x4".into());
}
image_dither = Some(value.clone());
}
"--image-mode" => {
let value = iter.next().ok_or(
"--image-mode requires one of: auto, sixel, blocks, braille, ascii, none",
)?;
image_mode = value.parse()?;
}
"--image-fit" => {
let value = iter.next().ok_or("--image-fit requires contain or cover")?;
if !matches!(value.as_str(), "contain" | "cover") {
return Err("--image-fit requires contain or cover".into());
}
image_fit = Some(value.clone());
}
"--image-anchor" => {
let value = iter.next().ok_or("--image-anchor requires a position")?;
if !matches!(
value.as_str(),
"center"
| "top"
| "bottom"
| "left"
| "right"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
) {
return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
}
image_anchor = Some(value.clone());
}
"--image-background" => {
let value = iter.next().ok_or("--image-background requires #RRGGBB")?;
let bytes = value.as_bytes();
if bytes.len() != 7
|| bytes[0] != b'#'
|| !bytes[1..].iter().all(u8::is_ascii_hexdigit)
{
return Err("--image-background requires #RRGGBB".into());
}
let mut rgb = [0; 3];
for (index, channel) in rgb.iter_mut().enumerate() {
*channel = u8::from_str_radix(&value[1 + index * 2..3 + index * 2], 16)
.map_err(|_| "--image-background requires #RRGGBB")?;
}
image_background = Some(rgb);
}
"--height" => {
let value = iter.next().ok_or("--height requires a number")?;
let rows: usize = value
.parse()
.map_err(|_| format!("invalid height '{value}'"))?;
if rows == 0 {
return Err("--height must be at least 1".into());
}
height = Some(rows);
}
"--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" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
"--right" => {}
"--center" if justify.is_none() => justify = Some(Justify::Center),
"--center" => {}
"--no-color" => no_color = true,
"--pager" => {
pager = true;
auto_pager = false;
}
"--auto-pager" => {
auto_pager = true;
pager = false;
}
"--no-pager" => {
pager = false;
auto_pager = false;
}
"--no-auto-pager" => auto_pager = false,
"--color" => no_color = false,
"--no-watch" => watch = false,
"--no-watch-cache" => watch_cache = false,
"--no-batch" => batch = false,
"--no-continue-on-error" => continue_on_error = false,
"--no-overwrite" => {
overwrite = false;
collision = CollisionPolicy::Error;
}
"--no-sanitize" => sanitize = false,
"--dry-run" => dry_run = true,
"--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
"--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
"--batch-input-root" => {
batch_paths.input_root = Some(PathBuf::from(
iter.next().ok_or("--batch-input-root requires a path")?,
))
}
"--batch-name-template" => {
batch_paths.template = Some(
batch_paths::FilenameTemplate::parse(
iter.next()
.ok_or("--batch-name-template requires a template")?,
)
.map_err(|e| e.to_string())?,
)
}
"--watch" => watch = true,
"--watch-cache" => watch_cache = true,
"--watch-interval" | "--interval" => {
let value = iter
.next()
.ok_or("--watch-interval requires seconds greater than zero")?;
watch_interval = value
.parse::<f64>()
.map_err(|_| format!("invalid watch interval {value:?}"))?;
if !watch_interval.is_finite() || watch_interval <= 0.0 {
return Err("--watch-interval must be greater than zero".into());
}
}
"--batch" => batch = true,
"--continue-on-error" => continue_on_error = true,
"--overwrite" => {
overwrite = true;
collision = CollisionPolicy::Overwrite;
}
"--collision" => {
collision = match iter
.next()
.ok_or("--collision requires error, overwrite, or suffix")?
.as_str()
{
"error" => CollisionPolicy::Error,
"overwrite" => CollisionPolicy::Overwrite,
"suffix" => CollisionPolicy::Suffix,
other => return Err(format!("unknown collision policy {other:?}")),
};
overwrite = collision == CollisionPolicy::Overwrite;
}
"--jobs" => {
jobs = iter
.next()
.ok_or("--jobs requires a positive number")?
.parse()
.map_err(|_| "--jobs requires a positive number".to_string())?;
if jobs == 0 {
return Err("--jobs must be at least 1".into());
}
}
"--sanitize" => sanitize = 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
if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
{
set_mode(&mut mode, command_mode(other).expect("checked above"))?;
}
other => resources.push(other.to_string()),
}
}
if watch && (batch || pager || auto_pager) {
return Err("--watch cannot be combined with --batch or paging".into());
}
if batch {
if mode.accepts_multiple_resources() {
return Err(format!(
"--batch cannot be combined with {}",
if mode == Mode::Diff {
"--diff"
} else {
"--gif"
}
));
}
if resources.is_empty() {
return Err("--batch needs at least one file, directory or glob".into());
}
if pager || auto_pager {
return Err("--pager cannot be combined with --batch".into());
}
}
if mode == Mode::Diff && resources.len() != 2 {
return Err("--diff needs exactly two images: --diff before.png after.png".into());
}
if mode == Mode::Image {
if resources.is_empty() {
return Err(
"--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
);
}
if image_mode == ImageMode::None {
return Err(
"--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
.into(),
);
}
}
if (image_color.as_deref() == Some("ansi256")
|| matches!(
image_dither.as_deref(),
Some("floyd-steinberg" | "bayer4x4")
))
&& !matches!(
image_mode,
ImageMode::Auto | ImageMode::Ascii | ImageMode::Blocks
)
{
return Err("image color processing supports only --image-mode ascii or blocks".into());
}
if matches!(
image_dither.as_deref(),
Some("floyd-steinberg" | "bayer4x4")
) && image_color.as_deref() != Some("ansi256")
{
return Err(format!(
"--image-dither {} requires --image-color ansi256",
image_dither.as_deref().unwrap()
));
}
if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
return Err("--image-anchor requires --image-fit cover".into());
}
if (batch_paths.preserve_dirs
|| batch_paths.template.is_some()
|| batch_paths.input_root.is_some())
&& !batch
{
return Err("batch path options require --batch".into());
}
if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
return Err("--batch-preserve-dirs requires --batch-input-root".into());
}
if dry_run && !batch {
return Err("--dry-run requires --batch".into());
}
if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
return Err("--jobs greater than 1 requires --batch with a file export".into());
}
if image_fit.is_some() && height.is_none() {
return Err("--image-fit requires --height to define the target rectangle".into());
}
let effective_mode = if mode == Mode::Auto {
detect_mode(resources.first().map(String::as_str))
} else {
mode
};
extensions.validate(
effective_mode == Mode::Gif,
effective_mode.allows_extensions()
&& !(mode == Mode::Auto && resources.is_empty())
&& !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
)?;
let exporting = export_html.is_some() || export_svg.is_some();
if exporting {
let unsupported = if effective_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/--image",
mode == Mode::Diff || mode == Mode::Image,
),
("--height", height.is_some(), "--image", mode == Mode::Image),
(
"--image-rotate",
image_rotate.is_some(),
"--image",
mode == Mode::Image,
),
(
"--image-flip-horizontal",
image_flip_horizontal,
"--image",
mode == Mode::Image,
),
(
"--image-flip-vertical",
image_flip_vertical,
"--image",
mode == Mode::Image,
),
(
"--image-grayscale",
image_grayscale,
"--image",
mode == Mode::Image,
),
(
"--image-color",
image_color.is_some(),
"--image",
mode == Mode::Image,
),
(
"--image-dither",
image_dither.is_some(),
"--image",
mode == Mode::Image,
),
(
"--image-fit",
image_fit.is_some(),
"--image",
mode == Mode::Image,
),
(
"--image-background",
image_background.is_some(),
"--image",
mode == Mode::Image,
),
(
"--loop",
loops.is_some(),
"--gif",
effective_mode == Mode::Gif,
),
(
"--panel-style",
panel_style.is_some(),
"--panel",
panel.is_some(),
),
(
"--expand",
expand,
"--panel/--padding",
panel.is_some() || padding.is_some(),
),
("--watch-interval", watch_interval != 1.0, "--watch", watch),
("--watch-cache", watch_cache, "--watch", watch),
];
if let Some((flag, _, needs, _)) = orphans
.iter()
.find(|(_, given, _, mode_present)| *given && !*mode_present)
{
return Err(format!("{flag} only has an effect with {needs}"));
}
let demo = mode == Mode::Auto && resources.is_empty();
if effective_mode.draws_directly() || demo {
let mode_name = if effective_mode == Mode::Gif {
"--gif"
} else if effective_mode == Mode::Image {
"--image"
} else if effective_mode.streams_records() {
mode_name(effective_mode)
} else if demo {
"the capability demo"
} else {
"--diff"
};
let unsupported = [
(
"--pager",
(pager || auto_pager)
&& (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
),
(
"--export-html/--export-svg",
(export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
),
("--width", demo && width.is_some()),
("--theme-style", demo && !theme_styles.is_empty()),
(
"--hyperlinks",
hyperlinks && (effective_mode.streams_records() || demo),
),
("--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 {mode_name}"));
}
}
if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch {
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,
image_fit,
image_anchor,
image_background,
image_color,
image_dither,
image_rotate,
image_flip_horizontal,
image_flip_vertical,
image_grayscale,
log_presentation,
theme_styles,
height,
extensions,
width,
justify,
no_color,
export_html,
export_svg,
panel,
padding,
title,
caption,
panel_style,
style,
expand,
pager,
auto_pager,
hyperlinks,
sanitize,
report_format,
watch,
watch_interval,
watch_cache,
batch,
progress,
continue_on_error,
jobs,
dry_run,
batch_paths,
worker_args: worker_args(args),
overwrite,
collision,
}))
}
fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> 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)?;
if let Some(encoding) = encoding {
return encoding.decode(&bytes).map(normalize_newlines);
}
if has_utf16_bom(&bytes) {
eprintln!("rich: {path} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)");
}
match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) if looks_like_image(path) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"looks like an image; use `rich --image <path>` to view it, or \
`rich --diff <before> <after>` to compare two images",
))
}
Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
}
}
_ => {
if std::io::stdin().is_terminal() {
let eof = if cfg!(windows) {
"Ctrl-Z then Enter"
} else {
"Ctrl-D"
};
eprintln!("rich: reading stdin; finish input with {eof}");
}
let mut buffer = Vec::new();
std::io::stdin().read_to_end(&mut buffer)?;
decode_strict(buffer, encoding)?
}
};
Ok(normalize_newlines(if encoding.is_some() {
content
} else {
strip_bom(content)
}))
}
fn sanitize_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
_ => {}
}
}
fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
let mut value = serde_json::from_str::<serde_json::Value>(content)?;
sanitize_json_value(&mut value);
serde_json::to_string(&value)
}
fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
if let Some(encoding) = encoding {
return encoding.decode(&bytes);
}
String::from_utf8(bytes).map_err(|err| std::io::Error::new(
std::io::ErrorKind::InvalidData,
if has_utf16_bom(err.as_bytes()) {
"input has a UTF-16 BOM; use --encoding utf-16"
} else {
"input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
},
))
}
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, encoding: Option<Encoding>) -> 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 =
decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
Ok((body, content_type))
}
#[cfg(not(feature = "fetch"))]
fn fetch_url(url: &str, _encoding: Option<Encoding>) -> 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 validate_labels(cli: &Cli) -> Result<(), String> {
for label in cli.title.iter().chain(cli.caption.iter()) {
Text::from_markup(label).map_err(|err| err.to_string())?;
}
Ok(())
}
fn decorate_and_emit(
cli: &Cli,
console: &Console,
export: &Export,
renderable: Box<dyn Renderable>,
fit: Option<usize>,
) -> ExitCode {
if cli.panel.is_some() {
if let Err(err) = validate_labels(cli) {
return fail(cli, ExitClass::Data, err);
}
}
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(|title| {
let mut title = Text::from_markup(&rich::emoji::replace(title).replace('\n', " "))
.expect("title markup validated before rendering");
title.expand_tabs(8);
cell_len(title.plain()) + 2
})
.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,
});
}
emit_exit_code(cli, emit(console, export, |c| c.print(renderable.as_ref())))
}
fn run(cli: Cli) -> ExitCode {
if cli.watch {
return run_watch(cli);
}
run_once(cli)
}
fn run_watch(mut cli: Cli) -> ExitCode {
let Some(resource) = cli.resource.as_deref() else {
return fail(
&cli,
ExitClass::Usage,
"--watch requires a file path or URL",
);
};
if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
return fail(
&cli,
ExitClass::Usage,
"--watch requires a local file or URL, not stdin or literal markup",
);
}
let effective_mode = if cli.mode == Mode::Auto {
detect_mode(Some(resource))
} else {
cli.mode
};
if effective_mode.draws_directly() || effective_mode == Mode::Rule {
return fail(
&cli,
ExitClass::Usage,
"--watch requires a resource-backed render mode",
);
}
if !std::io::stdout().is_terminal() {
cli.watch = false;
return run_once(cli);
}
if is_url(resource) {
#[cfg(not(feature = "fetch"))]
{
cli.watch = false;
return run_once(cli);
}
}
let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
let mut previous = None;
loop {
#[cfg(feature = "fetch")]
let cached = if is_url(resource) && cli.watch_cache {
Some(fetch_url(resource, cli.extensions.encoding))
} else {
None
};
#[cfg(not(feature = "fetch"))]
let cached: Option<Result<(String, Option<String>), String>> = None;
let fingerprint = match cached.as_ref() {
Some(Ok((body, content_type))) => {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
content_type.hash(&mut hasher);
format!("url:{:x}", hasher.finish())
}
Some(Err(error)) => format!("url-error:{error}"),
None => watch_fingerprint(resource, false, cli.extensions.encoding),
};
let changed = previous.as_ref() != Some(&fingerprint);
if changed || (is_url(resource) && !cli.watch_cache) {
previous = Some(fingerprint);
let console = Console::new();
console.clear();
console.control(&Control::home());
let _ = std::io::stdout().flush();
let mut iteration = cli.clone();
iteration.watch = false;
let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
if status != ExitClass::Success.exit_code() {
eprintln!("rich: watch will retry after the next change");
}
}
std::thread::sleep(interval);
}
}
fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
if is_url(resource) {
#[cfg(feature = "fetch")]
if cache_url {
return match fetch_url(resource, encoding) {
Ok((body, content_type)) => {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
content_type.hash(&mut hasher);
format!("url:{:x}", hasher.finish())
}
Err(error) => format!("url-error:{error}"),
};
}
#[cfg(not(feature = "fetch"))]
let _ = (cache_url, encoding);
return "url".to_string();
}
match std::fs::metadata(resource) {
Ok(metadata) if metadata.is_file() => {
use std::hash::Hasher;
let mut file = match std::fs::File::open(resource) {
Ok(file) => file,
Err(error) => return format!("file-error:{error}"),
};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut buffer = [0u8; 64 * 1024];
loop {
match file.read(&mut buffer) {
Ok(0) => break,
Ok(count) => hasher.write(&buffer[..count]),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return format!("file-error:{error}"),
}
}
format!("file:{:x}", hasher.finish())
}
Ok(_) => "not-file".to_string(),
Err(_) => "missing".to_string(),
}
}
fn run_once(cli: Cli) -> ExitCode {
run_once_with_fetch(cli, None)
}
fn cli_theme(cli: &Cli) -> rich::Theme {
let mut theme = rich::Theme::default_theme();
for (name, style) in &cli.theme_styles {
theme.insert(name.clone(), style.clone());
}
theme
}
fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
if cli.sanitize {
cli.title = cli
.title
.take()
.map(|title| sanitize_terminal_controls(&title));
cli.caption = cli
.caption
.take()
.map(|caption| sanitize_terminal_controls(&caption));
}
if cli.batch {
return run_batch(&cli);
}
if cli.mode == Mode::Auto && cli.resource.is_none() {
run_demo(cli.no_color, std::time::Duration::ZERO);
return success(&cli);
}
let mut mode = match cli.mode {
Mode::Auto => detect_mode(cli.resource.as_deref()),
other => other,
};
let mut builder = Console::builder().no_color(cli.no_color);
if !cli.theme_styles.is_empty() {
builder = builder.theme(cli_theme(&cli));
}
if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
builder = builder.force_terminal(terminal == "1");
}
if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
builder = builder.width(width);
}
let mut console = builder.build();
render_target::attach(&mut console);
console.install_extensions();
if mode == Mode::Rule {
if let Some(title) = &cli.resource {
if let Err(err) = Text::from_markup(title) {
return fail(&cli, ExitClass::Data, err.to_string());
}
}
}
let mut 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());
if cli.sanitize {
svg_title = sanitize_terminal_controls(&svg_title);
}
let export = Export {
html_path: cli.export_html.as_deref(),
svg_path: cli.export_svg.as_deref(),
svg_title: &svg_title,
pager: cli.pager,
auto_pager: cli.auto_pager,
};
if mode == Mode::Gif {
return play_gifs(&cli, &console);
}
#[cfg(feature = "art")]
if mode == Mode::Diff {
return run_diff(&cli, &console, &export);
}
#[cfg(feature = "art")]
if mode == Mode::Image {
return run_image(&cli, &console, &export);
}
#[cfg(not(feature = "art"))]
if mode == Mode::Image {
return fail(
&cli,
ExitClass::Usage,
"this build has no image support (rebuild with the `art` feature)",
);
}
if mode.streams_records() {
return run_json_lines(&cli, &console, mode == Mode::Log);
}
if mode == Mode::Rule {
let rule = match cli.resource.as_deref() {
Some(title) if title != "-" && cli.sanitize => {
Rule::new(sanitize_terminal_controls(title))
}
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 (mut content, content_type) = if let Some(fetched) = prefetched {
fetched
} else if resource_is_url {
match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
Ok(fetched) => fetched,
Err(err) => {
return fail(&cli, ExitClass::Input, err);
}
}
} 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(), cli.extensions.encoding) {
Ok(content) => (content, None),
Err(err) => {
let name = cli.resource.as_deref().unwrap_or("<stdin>");
return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
}
}
};
if cli.sanitize {
content = sanitize_terminal_controls(&content);
}
if resource_is_url && mode == Mode::Auto {
mode = content_type
.as_deref()
.and_then(content_type_mode)
.unwrap_or(Mode::Syntax);
}
let json_content = if mode == Mode::Json && cli.sanitize {
match sanitize_json_source(content.trim()) {
Ok(content) => Some(content),
Err(err) => {
return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
}
}
} else {
None
};
let json = if mode == Mode::Json {
let source = json_content.as_deref().unwrap_or_else(|| content.trim());
match Json::new(source) {
Ok(json) => Some(json),
Err(err) => {
return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
}
}
} else {
None
};
if mode == Mode::Ipynb {
let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
if cli.sanitize {
sanitize_json_value(&mut value);
}
value
}
Ok(_) => {
return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
}
Err(err) => {
return fail(
&cli,
ExitClass::Data,
format!("invalid notebook JSON: {err}"),
);
}
};
let renderable = build_ipynb(¬ebook, cli.hyperlinks, cli.justify);
let fit = renderable.measure(&console, &console.options()).maximum;
return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
}
if mode == Mode::Print {
if let Err(err) = console.try_build_text(&content) {
return fail(&cli, ExitClass::Data, err.to_string());
}
}
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
|| cli.auto_pager;
let json = json.expect("json parsed above").no_wrap(nested);
#[cfg(feature = "json-escape-safe")]
let json = json.escape_safe(true);
let fit = measure_rendered(&console, &json);
(Box::new(json), Some(fit))
}
Mode::Csv => {
if let Err(err) = validate_labels(&cli) {
return fail(&cli, ExitClass::Data, err);
}
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 => {
return fail(&cli, ExitClass::Data, "Could not determine delimiter");
}
};
if cli.panel.is_none()
&& cli.padding.is_none()
&& cli.style.is_none()
&& cli.justify.is_none()
&& !cli.pager
&& !cli.auto_pager
&& cli.export_html.is_none()
&& cli.export_svg.is_none()
{
let mut options = console.options();
options.max_width = cli.width.unwrap_or_else(|| {
table
.measure(&console, &options)
.maximum
.min(options.max_width)
.max(1)
});
let stdout = std::io::stdout();
let mut output = std::io::BufWriter::new(stdout.lock());
let result = table
.try_for_each_line(&console, &options, |line| {
let line = Segment::crop_lines(&line, console.width());
writeln!(output, "{}", console.segments_to_string(&line))
})
.and_then(|()| output.flush());
if let Err(error) = result {
if error.kind() == std::io::ErrorKind::BrokenPipe {
return success(&cli);
}
return fail(
&cli,
ExitClass::Input,
format!("could not write CSV output: {error}"),
);
}
return success(&cli);
}
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)
}
#[allow(dead_code)]
fn exit_code(ok: bool) -> ExitCode {
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
match result {
Ok(()) => success(cli),
Err(message) => fail(cli, ExitClass::Input, message),
}
}
fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
let output = console.render_export(renderable);
let stdout = std::io::stdout();
let mut lock = stdout.lock();
lock.write_all(output.as_bytes())
}
fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
if cli.extensions.encoding.is_some() {
return fail(
cli,
ExitClass::Usage,
"--encoding is not supported with streaming JSONL/log mode",
);
}
if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
return fail(
cli,
ExitClass::Usage,
"streaming JSONL/log mode reads files or stdin, not URLs",
);
}
let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
Some(path) if path != "-" => {
let file = std::path::Path::new(path);
if file.is_dir() {
return fail(
cli,
ExitClass::Input,
format!("cannot read {path}: is a directory, not a file"),
);
}
match std::fs::File::open(file) {
Ok(file) => Box::new(std::io::BufReader::new(file)),
Err(err) => {
return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
}
}
}
_ => {
if std::io::stdin().is_terminal() {
let eof = if cfg!(windows) {
"Ctrl-Z then Enter"
} else {
"Ctrl-D"
};
eprintln!("rich: reading stdin; finish input with {eof}");
}
Box::new(std::io::BufReader::new(std::io::stdin()))
}
};
let mut line = String::new();
let mut line_number = 0usize;
loop {
line.clear();
let read = match input.read_line(&mut line) {
Ok(read) => read,
Err(err) => {
return fail(
cli,
ExitClass::Input,
format!("cannot read JSONL stream: {err}"),
);
}
};
if read == 0 {
break;
}
line_number += 1;
let record = line.trim_end_matches(['\r', '\n']);
let mut value = match serde_json::from_str::<serde_json::Value>(record) {
Ok(value) => value,
Err(err) => {
return fail(
cli,
ExitClass::Data,
format!("invalid JSONL at line {line_number}: {err}"),
);
}
};
if cli.sanitize {
sanitize_json_value(&mut value);
}
let wrote = if log_mode {
if cli.log_presentation == "rich" {
write_rendered_stdout(console, &structured_log::event(&value))
} else {
let text = Text::new(format_log_record(&value));
write_rendered_stdout(console, &text)
}
} else {
let source = serde_json::to_string(&value).expect("JSON value serializes");
let json = Json::new(&source).expect("serialized JSON parses");
write_rendered_stdout(console, &json)
};
if let Err(error) = wrote {
if error.kind() == std::io::ErrorKind::BrokenPipe {
return success(cli);
}
return fail(
cli,
ExitClass::Input,
format!("could not write JSONL output: {error}"),
);
}
}
success(cli)
}
fn format_log_record(value: &serde_json::Value) -> String {
let Some(object) = value.as_object() else {
return serde_json::to_string(value).expect("JSON value serializes");
};
let take_value = |keys: &[&'static str]| {
keys.iter().find_map(|key| {
object.get(*key).map(|value| {
let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
serde_json::to_string(value).expect("JSON value serializes")
});
(*key, display, value.is_string())
})
})
};
let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
let level = take_value(&["level", "severity"]);
let message = take_value(&["message", "msg"]);
let mut rest = serde_json::Map::new();
for (key, value) in object {
let consumed = timestamp
.as_ref()
.is_some_and(|(consumed, _, _)| consumed == key)
|| level
.as_ref()
.is_some_and(|(consumed, _, _)| consumed == key)
|| message
.as_ref()
.is_some_and(|(consumed, _, _)| consumed == key);
if !consumed {
rest.insert(key.clone(), value.clone());
}
}
let mut parts = Vec::new();
if let Some((_, timestamp, _)) = timestamp {
parts.push(timestamp);
}
if let Some((_, level, is_string)) = level {
parts.push(if is_string {
level.to_ascii_uppercase()
} else {
level
});
}
if let Some((_, message, _)) = message {
parts.push(message);
}
if !rest.is_empty() {
parts.push(
serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
);
}
if parts.is_empty() {
serde_json::to_string(value).expect("JSON value serializes")
} else {
parts.join(" ")
}
}
#[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));
row.shrink_to_fit();
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));
row.shrink_to_fit();
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<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.as_slice())
};
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);
}
}
drop(data);
table.extend_owned_rows(
rows.into_iter()
.skip(usize::from(has_header))
.filter(|row| !row.is_empty())
.collect(),
);
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
}
struct Notebook {
items: Vec<Box<dyn Renderable>>,
justify: Option<Justify>,
}
impl Renderable for Notebook {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut options = options.clone();
if let Some(justify) = self.justify {
options.justify = justify;
}
let mut segments = Vec::new();
for item in &self.items {
let rendered = item.rich_render(console, &options);
if !rendered.is_empty() {
segments.extend(rendered);
segments.push(Segment::line());
}
}
if !segments.is_empty() {
segments.pop();
}
segments
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
self.items
.iter()
.map(|item| item.measure(console, options))
.fold(Measurement::new(0, 0), |width, item| {
Measurement::new(
width.minimum.max(item.minimum),
width.maximum.max(item.maximum),
)
})
}
}
fn notebook_ansi(source: &str) -> Text {
let mut result = Text::new("");
for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
if index > 0 {
result.append("\n", None);
}
result = result.append_text(line);
}
result
}
fn build_ipynb(
notebook: &serde_json::Value,
hyperlinks: bool,
justify: Option<Justify>,
) -> Notebook {
let language = notebook["metadata"]["kernelspec"]["language"]
.as_str()
.or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
.unwrap_or("python");
let empty = Vec::new();
let mut items: Vec<Box<dyn Renderable>> = Vec::new();
let mut new_line = true;
for cell in notebook["cells"].as_array().unwrap_or(&empty) {
if new_line {
items.push(text(""));
}
if cell.get("execution_count").is_some() {
items.push(Box::new(io_label(
"In ",
cell["execution_count"].as_i64().filter(|n| *n != 0),
"green",
"#66ff00",
)));
}
let source = join_source(&cell["source"]);
items.push(match cell["cell_type"].as_str().unwrap_or("") {
"markdown" => Box::new(build_markdown(&source, hyperlinks)),
"code" => Box::new(
Panel::new(Box::new(Syntax::new(&source, language)))
.border_style(Style::parse("dim").expect("valid style")),
),
_ => text(&source),
});
new_line = true;
for output in cell["outputs"].as_array().unwrap_or(&empty) {
let rendered = match output["output_type"].as_str().unwrap_or("") {
"stream" => {
new_line = false;
notebook_ansi(&join_source(&output["text"]))
}
"error" => {
new_line = true;
notebook_ansi(join_traceback(&output["traceback"]).trim_end())
}
"execute_result" => {
new_line = true;
let mut label = io_label(
"Out",
output["execution_count"].as_i64().filter(|n| *n != 0),
"red",
"#ee4b2b",
);
label.append("\n", None);
label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
}
_ => continue,
};
items.push(Box::new(rendered));
}
}
Notebook { items, justify }
}
#[cfg(feature = "art")]
fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
let displayed = |value: f32| {
format!("{value:.1}")
.parse::<f32>()
.expect("formatted percentage is a number")
};
displayed(changed) > displayed(limit)
}
#[cfg(feature = "art")]
fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
if std::path::Path::new(path).is_dir() {
return Err(format!("cannot read {path}: is a directory, not a file"));
}
match rich_art::image::open(path) {
Ok(image) => Ok(image),
Err(err) => {
use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
let hint = match &err {
rich_art::image::ImageError::Unsupported(error) => match error.kind() {
UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
_ => None,
},
_ => None,
};
Err(format!(
"cannot read {path}: {}",
hint.unwrap_or_else(|| err.to_string())
))
}
}
}
#[cfg(feature = "art")]
fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
match mode {
ImageMode::Auto => rich_art::ImageMode::Auto,
ImageMode::Sixel => rich_art::ImageMode::Sixel,
ImageMode::Blocks => rich_art::ImageMode::Blocks,
ImageMode::Braille => rich_art::ImageMode::Braille,
ImageMode::Ascii => rich_art::ImageMode::Ascii,
ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
}
}
#[cfg(feature = "art")]
fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
use rich_art::ImageArt;
let Some(resource) = cli.resource.as_deref() else {
return fail(
cli,
ExitClass::Usage,
"--image requires an image file: rich --image photo.png (or `-` for stdin)",
);
};
let image = if resource == "-" {
let mut buffer = Vec::new();
if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
}
match rich_art::image::load_from_memory(&buffer) {
Ok(image) => image,
Err(err) => {
return fail(
cli,
ExitClass::Input,
format!("cannot decode image from stdin: {err}"),
)
}
}
} else {
match open_image_path(resource) {
Ok(image) => image,
Err(message) => return fail(cli, ExitClass::Input, message),
}
};
let width = cli.width.unwrap_or_else(|| console.width());
let mut art = ImageArt::new(image)
.transforms(rich_art::ImageTransforms {
rotation: match cli.image_rotate {
Some(90) => rich_art::Rotation::Clockwise90,
Some(180) => rich_art::Rotation::Clockwise180,
Some(270) => rich_art::Rotation::Clockwise270,
_ => rich_art::Rotation::None,
},
flip_horizontal: cli.image_flip_horizontal,
flip_vertical: cli.image_flip_vertical,
grayscale: cli.image_grayscale,
})
.mode(to_art_image_mode(cli.image_mode))
.width(width)
.color(!cli.no_color);
if let Some(height) = cli.height {
art = art.height(height);
}
if let Some(fit) = cli.image_fit.as_deref() {
art = art.fit(match fit {
"cover" => rich_art::ImageFit::Cover,
_ => rich_art::ImageFit::Contain,
});
}
if let Some(anchor) = cli.image_anchor.as_deref() {
use rich_art::ImageAnchor;
art = art.anchor(match anchor {
"top" => ImageAnchor::Top,
"bottom" => ImageAnchor::Bottom,
"left" => ImageAnchor::Left,
"right" => ImageAnchor::Right,
"top-left" => ImageAnchor::TopLeft,
"top-right" => ImageAnchor::TopRight,
"bottom-left" => ImageAnchor::BottomLeft,
"bottom-right" => ImageAnchor::BottomRight,
_ => ImageAnchor::Center,
});
}
if let Some(background) = cli.image_background {
art = art.background(background);
}
if let Some(color) = cli.image_color.as_deref() {
art = art.color_mode(match color {
"ansi256" => rich_art::ImageColorMode::Ansi256,
_ => rich_art::ImageColorMode::TrueColor,
});
}
if let Some(dither) = cli.image_dither.as_deref() {
art = art.dither(match dither {
"floyd-steinberg" => rich_art::Dither::FloydSteinberg,
"bayer4x4" => rich_art::Dither::Bayer4x4,
_ => rich_art::Dither::None,
});
}
let options = console.options();
if let Err(err) = art.render(console, &options) {
return fail(cli, ExitClass::Input, err.to_string());
}
let terminal_only = Export {
html_path: None,
svg_path: None,
..*export
};
if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
return fail(cli, ExitClass::Input, message);
}
if export.html_path.is_some() || export.svg_path.is_some() {
use rich::protocol::RenderEnvironment;
use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
caps.color_system = if cli.no_color {
None
} else {
Some(ColorSystem::Truecolor)
};
let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
let export_console = target.console();
if let Err(err) =
art.render_with_environment(&export_console, &export_console.options(), &target)
{
return fail(cli, ExitClass::Input, err.to_string());
}
let segments = target.segments(&art);
debug_assert!(!target.capabilities().interactive);
if let Err(message) = save_exports(&export_console, export, &segments) {
return fail(cli, ExitClass::Input, message);
}
}
success(cli)
}
#[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, BrailleArt, SixelArt};
let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
let before = match open_image_path(before_path) {
Ok(image) => image,
Err(message) => return fail(cli, ExitClass::Input, message),
};
let after = match open_image_path(after_path) {
Ok(image) => image,
Err(message) => return fail(cli, ExitClass::Input, message),
};
let report = match diff(&before, &after, &DiffSettings::default()) {
Ok(report) => report,
Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
};
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 failed = cli
.diff_threshold
.is_some_and(|limit| diff_threshold_exceeded(changed, limit));
let render_report = |c: &Console, for_export: bool| {
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 {
if !has_color && !for_export && cli.report_format == ReportFormat::Human {
eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
}
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 {
if !for_export && cli.report_format == ReportFormat::Human {
eprintln!("rich: no colour available, drawing the diff as ASCII art");
}
mode = ImageMode::Ascii;
}
if mode == ImageMode::Sixel && !is_terminal {
if cli.image_mode != ImageMode::Sixel
&& !for_export
&& cli.report_format == ReportFormat::Human
{
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::Braille => c.print(
&BrailleArt::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 {
if cli.report_format == ReportFormat::Human {
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}%"
));
}
}
};
let terminal_only = Export {
html_path: None,
svg_path: None,
..*export
};
let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
if export.html_path.is_some() || export.svg_path.is_some() {
let mut export_console = Console::builder()
.force_terminal(false)
.color_system(Some(ColorSystem::Truecolor))
.no_color(cli.no_color)
.width(console.width())
.height(console.height())
.theme(console.theme().clone())
.build();
export_console.install_extensions();
let segments = export_console.record_output(|c| render_report(c, true));
wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
}
if let Err(message) = wrote {
return fail(cli, ExitClass::Input, message);
}
if failed {
return fail(cli, ExitClass::Gate, "diff threshold exceeded");
}
success(cli)
}
#[cfg(feature = "art")]
fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
use rich_art::{AnimatedArt, Repeat, Stage};
if cli.resources.is_empty() {
return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
}
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)
.blocks(cli.extensions.gif_blocks())
.color(!cli.no_color)
.repeat(repeat)
.max_fps(30.0),
);
}
Err(err) => {
return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
}
}
}
if !console.is_terminal() && cli.report_format == ReportFormat::Human {
eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
}
let mut builder = Console::builder().no_color(cli.no_color);
if !cli.theme_styles.is_empty() {
builder = builder.theme(cli_theme(cli));
}
if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
builder = builder.force_terminal(terminal == "1");
}
if let Some(width) = cli.width {
builder = builder.width(width);
}
match stage.play_stdout(builder.build()) {
Ok(()) => success(cli),
Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
}
}
#[cfg(not(feature = "art"))]
fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
fail(
_cli,
ExitClass::Usage,
"this build has no GIF support (rebuild with the `art` feature)",
)
}
struct Export<'a> {
html_path: Option<&'a str>,
svg_path: Option<&'a str>,
svg_title: &'a str,
pager: bool,
auto_pager: bool,
}
fn should_page(
explicit: bool,
automatic: bool,
terminal: bool,
lines: usize,
height: usize,
) -> bool {
terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
}
fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
if export.html_path.is_none()
&& export.svg_path.is_none()
&& !export.pager
&& !export.auto_pager
{
render(console);
return Ok(());
}
let segments = console.record_output(render);
let mut first_error = None;
let terminal = console.segments_to_string(&segments);
if should_page(
export.pager,
export.auto_pager,
std::io::stdout().is_terminal(),
terminal.lines().count(),
console.height(),
) {
if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
first_error = Some(format!("cannot page output: {err}"));
}
} else {
let stdout = std::io::stdout();
let mut lock = stdout.lock();
if let Err(err) = lock.write_all(terminal.as_bytes()) {
first_error = Some(format!("could not write output: {err}"));
}
}
save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
}
fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
let segments = filtered.as_slice();
let mut first_error = None;
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) {
first_error = Some(format!("failed to save HTML: {err}"));
}
}
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) {
first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
}
}
first_error.map_or(Ok(()), Err)
}
fn print_help() {
let extension_help = rich_ext::cli::HELP;
println!(
r#"rich {VERSION} — Rust port of the rich-cli terminal toolbox
USAGE:
rich [OPTIONS] [RESOURCE]
rich [OPTIONS] <COMMAND> [RESOURCE]
rich --batch [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. Input modes with
no RESOURCE read stdin until EOF; `-p -` reads markup from stdin too. Terminal
stdin shows an input hint. Repeated scalar options use their last value.
COMMANDS:
config show Show configured settings with CLI overrides as JSON
config validate Validate TOML, all profiles and explicit setting values
print Treat RESOURCE as literal markup TEXT (`--print`)
markdown Render Markdown (`--markdown`)
syntax Syntax-highlight source (`--syntax`)
json Pretty-print JSON (`--json`)
csv Render CSV/TSV as a table (`--csv`)
ipynb Render a Jupyter notebook (`--ipynb`)
jsonl Stream JSON Lines / NDJSON records
log Stream common structured-log JSONL records
gif Animate GIFs (`--gif`)
diff Perceptually compare two images (`--diff`)
image Render a still image as ASCII/Braille/blocks/Sixel (`--image`)
rule Draw a horizontal rule (`--rule`)
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
--jsonl Stream JSON Lines / NDJSON records
--log Stream common structured-log JSONL records
--log-presentation plain|rich Select log presentation (default: plain)
--gif Animate GIFs side by side; pipes receive the first frame
--loop N With --gif, repeat N times (default 1; 0 = forever)
--rule Draw a horizontal rule (RESOURCE is its title)
--diff Perceptually compare two images (needs exactly two)
--image Render RESOURCE as a still image (ASCII/Braille/blocks/Sixel)
OPTIONS:
-w, --width N Render the output N columns wide (the console keeps its
own width, so --left/--center/--right still use it)
--height N With --image, render this many rows instead of the
backend's default
--image-anchor A Cover crop anchor: center (default), top, bottom, left,
right, top-left, top-right, bottom-left, bottom-right
--image-fit M With --image and --height: contain (letterbox) or cover
(crop at --image-anchor); preserves aspect ratio in terminal cells
--image-background #RRGGBB
With --image: flatten transparency onto this RGB colour
(also colours contain padding; quote the # in your shell)
--image-color M Truecolor (default) or ansi256, with ASCII/blocks images
--image-dither M none (default), floyd-steinberg, or bayer4x4 (ansi256)
--image-rotate N Rotate still images clockwise: 0, 90, 180, 270
--image-flip-horizontal / --image-flip-vertical Flip after rotation
--image-grayscale Composite and convert still images to grayscale
--image-mode M
With --diff/--image, how to draw the picture: auto
(default), sixel (real pixels), blocks, braille, ascii, none
(--image rejects none: there would be nothing to draw)
{extension_help}
--threshold PCT
With --diff, exit non-zero above PCT% changed.
Also sets the exit code: 0 within, 5 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 terminal output via MANPAGER, PAGER, then less/more.com
--no-pager Disable explicit and automatic paging
--auto-pager Page only terminal output taller than the viewport
--no-auto-pager Disable automatic paging
--watch Re-render a changing file or URL while stdout is a terminal
--watch-interval SEC
Poll interval in seconds (default 1)
--watch-cache With URLs, render only when the response body changes
--batch Convert explicit files, directories, or globs deterministically
--batch-preserve-dirs Preserve paths under --batch-input-root PATH
--batch-name-template TEMPLATE Name export leaves; export paths become directories
--jobs N Parallel file-export workers (default 1); requires --batch
Terminal output stays in input order; active jobs finish on error.
--progress, --no-progress
Enable/disable batch counts on terminal stderr (default on);
hidden for redirected stderr, JSON reports and dry runs
--dry-run Validate and show the batch plan without writing files
--continue-on-error
Process all planned inputs and aggregate failures
--overwrite Allow existing batch export destinations
--collision P
Batch policy: error (default), overwrite, or suffix
--config PATH
Read versioned TOML defaults from PATH
--profile NAME
Select a config profile (default: default)
--theme NAME Select a named theme from config
--theme-style NAME=STYLE
Override a theme binding; repeatable and worker-safe
--no-config Disable config discovery
--sanitize Replace input terminal controls, JSON/notebook strings,
titles and captions with visible inert text
--report F Emit a result/error envelope on stderr: human (default) or json.
--machine-json
Alias for --report json
--no-color Disable colored output (as does a non-empty NO_COLOR)
--color Override a config no_color setting (pipes remain plain)
--no-batch, --no-continue-on-error, --no-overwrite
--no-watch, --no-watch-cache, --no-sanitize
Disable the corresponding config/default boolean
--demo Guided suite tour; pauses 3 seconds between sections on a TTY
--demo-list List stable tour sections: core, workflows, art
--demo-section NAME
With --demo, play one section only
--demo-delay SECONDS
Tour pause (0–60); no pauses when redirected; Ctrl+C stops
Self-contained examples; ignores config; accepts --no-color
doctor Read-only build, terminal, config and pager diagnostics;
--report json writes diagnostic data to stdout
-h, --help Show this help
-V, --version Show the rs-rich-cli package version
ENVIRONMENT:
NO_COLOR Any non-empty value disables colour
COLUMNS Console width (default 80 when unavailable)
MANPAGER, PAGER Pager command; fallback is less (Unix), more.com (Windows)
FORCE_COLOR Not supported; redirected stdout stays plain
RICH_SIXEL 0/1 overrides Sixel detection for --image-mode auto
With no RESOURCE and no mode flag, a capability demo is shown. Layout, style,
paging, hyperlinks and export options require a resource or render mode.
EXIT CODES:
0 success
2 usage/config error
3 input/read/write error
4 parse/render data error
5 threshold/gate failure
"#
);
}
fn build_demo_console(no_color: bool) -> Console {
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
}
fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
let to_terminal = std::io::stdout().is_terminal() && !no_color;
let console = build_demo_console(no_color);
demo::section(&console, delay, "rs-rich-cli");
console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
console.print_str(
"Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
);
demo::section(&console, delay, "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:");
demo::section(&console, delay, "rule");
console.print(&Rule::line());
demo::section(&console, delay, "centered");
console.print(&Rule::new("left").align(HorizontalAlign::Left));
console.print(&Rule::new("right").align(HorizontalAlign::Right));
demo::section(&console, delay, "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(to_terminal)
.no_color(no_color)
.color_system(if to_terminal {
Some(ColorSystem::Truecolor)
} else {
None
})
.width(console.width())
.legacy_windows(true)
.build();
legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
demo::section(&console, delay, "padding");
console.print(
&Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
);
demo::section(&console, delay, "styled");
console.print(&Styled::new(
Box::new(Panel::new(text("green under the whole panel")).title("styled")),
Style::parse("green").unwrap(),
));
demo::section(&console, delay, "align");
console.print(&Align::left(text("← left")));
console.print(&Align::center(text("center")));
console.print(&Align::right(text("right →")));
demo::section(&console, delay, "justify");
console.print_justified("left justified", Justify::Left);
console.print_justified("centered", Justify::Center);
console.print_justified("right justified", Justify::Right);
demo::section(&console, delay, "constrain (width 24)");
console.print(&Constrain::new(
Box::new(Panel::new(text("constrained")).title("≤24")),
Some(24),
));
demo::section(&console, delay, "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);
demo::section(&console, delay, "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(),
));
demo::section(&console, delay, "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);
demo::section(&console, delay, "progress bar");
for pct in [0.0, 33.0, 66.0, 100.0] {
console.print(&ProgressBar::new(100.0, pct).width(48));
}
demo::section(&console, delay, "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);
demo::section(&console, delay, "json");
if let Ok(json) = Json::new(
r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
) {
console.print(&json);
}
demo::section(&console, delay, "pretty");
let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
console.print(&Pretty::new(&value));
demo::section(&console, delay, "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"),
);
}
demo::section(&console, delay, "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));
demo::section(&console, delay, "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));
}
demo::section(&console, delay, "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---",
));
demo::section(&console, delay, "syntax");
console.print(&Syntax::new(
"fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
"rust",
));
demo::section(&console, delay, "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")));
demo::section(&console, delay, "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));
}
demo::section(&console, delay, "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}\">"
)));
demo::section(&console, delay, "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));
}
demo::section(&console, delay, "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));
demo::section(&console, delay, "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);
}
demo::section(&console, delay, "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());
console
}
#[cfg(test)]
mod tests {
use super::*;
use rich::ColorSystem;
#[cfg(feature = "art")]
#[test]
fn diff_threshold_uses_the_reports_tie_rounding() {
assert!(!diff_threshold_exceeded(0.25, 0.24));
assert!(diff_threshold_exceeded(0.75, 0.74));
assert!(!diff_threshold_exceeded(0.75, 0.76));
}
#[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 paging_policy_respects_viewport_and_redirection() {
assert!(!should_page(true, true, false, 100, 24));
assert!(!should_page(false, true, true, 23, 24));
assert!(should_page(false, true, true, 24, 24));
assert!(should_page(true, false, true, 1, 24));
assert!(!should_page(false, false, true, 100, 24));
}
#[test]
fn worker_arguments_preserve_resources_named_like_commands() {
for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
let args: Vec<String> = source.into_iter().map(str::to_string).collect();
assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
}
}
#[test]
fn worker_arguments_keep_option_values_and_remove_batch_operands() {
let args: Vec<String> = [
"--batch",
"--title",
"--batch",
"--jobs",
"2",
"json",
"--export-html",
"out.html",
"--",
"--pager",
]
.into_iter()
.map(str::to_string)
.collect();
assert_eq!(
worker_args(&args),
["--no-config", "--title", "--batch", "--json"]
);
}
#[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_watch_options_and_rejects_orphans() {
let s = |v: &str| v.to_string();
let cli = parse(&[
s("--watch"),
s("--watch-interval"),
s("0.25"),
s("--watch-cache"),
s("file.md"),
])
.unwrap()
.unwrap();
assert!(cli.watch);
assert_eq!(cli.watch_interval, 0.25);
assert!(cli.watch_cache);
assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
}
#[test]
fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
let _ = std::fs::remove_file(&path);
let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
std::fs::write(&path, "first").unwrap();
let first = watch_fingerprint(path.to_str().unwrap(), false, None);
std::fs::write(&path, "second content").unwrap();
let second = watch_fingerprint(path.to_str().unwrap(), false, None);
let _ = std::fs::remove_file(&path);
assert_eq!(missing, "missing");
assert_ne!(first, missing);
assert_ne!(first, second);
}
#[test]
fn parses_batch_controls_and_cli_values_override_config_defaults() {
let s = |v: &str| v.to_string();
let cli = parse(&[
s("--no-config"),
s("--batch"),
s("--jobs"),
s("3"),
s("-o"),
s("out.html"),
s("--continue-on-error"),
s("--collision"),
s("suffix"),
s("input"),
])
.unwrap()
.unwrap();
assert!(cli.batch);
assert_eq!(cli.jobs, 3);
assert!(cli.continue_on_error);
assert_eq!(cli.collision, CollisionPolicy::Suffix);
}
#[test]
fn batch_expansion_is_recursive_sorted_and_deduplicated() {
let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let nested = root.join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
let root_arg = root.to_string_lossy().into_owned();
let glob_arg = root
.join("*richbatchonly.md")
.to_string_lossy()
.into_owned();
let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
assert_eq!(inputs.len(), 2);
assert!(inputs[0].ends_with("richbatchnested.md"));
assert!(inputs[1].ends_with("richbatchonly.md"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn batch_output_suffixes_are_deterministic_for_collisions() {
let inputs = ["one.md", "two.md"];
let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
assert_eq!(first, "one.html");
assert_eq!(second, "two.html");
assert_eq!(
Path::new("one.html")
.with_file_name("one-2.html")
.to_string_lossy(),
"one-2.html"
);
}
#[test]
fn config_profiles_support_profiles_section_and_source_errors() {
let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let config = root.join("config.toml");
std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
let args = vec![
"--config".to_string(),
config.to_string_lossy().into_owned(),
"--profile".to_string(),
"ci".to_string(),
];
let merged = config_args(
&args,
&ConfigRoots {
home: None,
cwd: root.clone(),
},
)
.unwrap();
assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
assert!(merged
.windows(2)
.any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
let bad = root.join("bad.toml");
std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
let error = config_args(
&["--config".to_string(), bad.to_string_lossy().into_owned()],
&ConfigRoots {
home: None,
cwd: root.clone(),
},
)
.unwrap_err();
assert!(error.contains("config "));
assert!(error.contains("unknown key"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn batch_rejects_modes_that_consume_their_whole_resource_list() {
let s = |v: &str| v.to_string();
let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
.err()
.unwrap();
assert!(error.contains("--batch cannot be combined with --diff"));
let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
.err()
.unwrap();
assert!(error.contains("--batch cannot be combined with --gif"));
let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
.err()
.unwrap();
assert!(error.contains("--pager cannot be combined with --batch"));
}
#[test]
fn glob_matches_metacharacters_rather_than_substrings() {
assert!(glob_match("*.md", "notes.md"));
assert!(!glob_match("*.md", "notes.md.bak"));
assert!(glob_match("a?c.md", "abc.md"));
assert!(!glob_match("a?c.md", "ac.md"));
assert!(glob_match("*", "anything"));
assert!(glob_match("a*b*c", "axxbyyc"));
assert!(!glob_match("a*b*c", "axxbyy"));
}
#[test]
fn batch_expansion_skips_symlinked_directories() {
let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.md"), "# a").unwrap();
#[cfg(unix)]
let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
#[cfg(windows)]
let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
if linked {
assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
}
assert!(inputs[0].ends_with("a.md"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn subcommand_mode_wins_over_config_mode_default() {
let s = |v: &str| v.to_string();
assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
assert!(!selects_mode_explicitly(&[
s("--title"),
s("json"),
s("file")
]));
assert!(!selects_mode_explicitly(&[s("file.md")]));
let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let config = root.join("config.toml");
std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
let merged = config_args(
&[
"--config".to_string(),
config.to_string_lossy().into_owned(),
"json".to_string(),
"file.json".to_string(),
],
&ConfigRoots {
home: None,
cwd: root.clone(),
},
)
.unwrap();
assert!(
!merged.iter().any(|arg| arg == "--markdown"),
"config mode must not override an explicit subcommand: {merged:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn suffix_collisions_step_past_files_already_on_disk() {
let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let target = root.join("out.html");
std::fs::write(&target, "existing").unwrap();
let target = target.to_string_lossy().into_owned();
let cli = parse(&[
"--no-config".to_string(),
"--batch".to_string(),
"--collision".to_string(),
"suffix".to_string(),
"x".to_string(),
])
.unwrap()
.unwrap();
let mut taken = std::collections::BTreeSet::new();
let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
assert!(
first.ends_with("out-2.html"),
"existing file must be kept: {first}"
);
let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
assert!(second.ends_with("out-3.html"));
let mut cli_error = cli.clone();
cli_error.collision = CollisionPolicy::Error;
let mut taken = std::collections::BTreeSet::new();
let (class, message) =
resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
assert_eq!(class, ExitClass::Input);
assert!(message.contains("--overwrite"));
let mut cli_overwrite = cli.clone();
cli_overwrite.overwrite = true;
let mut taken = std::collections::BTreeSet::new();
assert_eq!(
resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
target
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn batch_outputs_keep_their_export_kind() {
let outputs = BatchOutputs {
html: batch_export_path(Some("result.out"), "a.md", 0, 1),
..BatchOutputs::default()
};
assert_eq!(outputs.html.as_deref(), Some("result.out"));
assert!(outputs.svg.is_none());
}
#[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);
}
}