use rich::{Console, Renderable};
use rich_ext::cli_doc::{
ArgSpec, Choice, CommandSpec, ConfigEntry, ConfigReference, HelpView, Layer, ValueHint,
};
const ARGUMENTS: &str = "Arguments";
const RENDER_MODE: &str = "Render mode";
const INPUT: &str = "Input";
const LAYOUT: &str = "Layout";
const MODE_OPTIONS: &str = "Mode options";
const SOURCE: &str = "Source";
const IMAGE: &str = "Image";
const INSPECT: &str = "Inspect";
const DIFF: &str = "Diff & ANSI";
const VIEWERS: &str = "Viewers";
const EXPORT: &str = "Export";
const PAGING: &str = "Paging";
const WATCH: &str = "Watch";
const BATCH: &str = "Batch";
const CONFIG: &str = "Config & theme";
const OUTPUT: &str = "Output & reports";
const DEMO: &str = "Demo";
const GENERAL: &str = "General";
pub(crate) const MODES: &[&str] = &[
"print", "markdown", "rst", "json", "syntax", "csv", "ipynb", "jsonl", "log", "rule", "image",
"gif", "diff", "inspect", "ansi",
];
fn flag(long: &str, heading: &str, help: &str) -> ArgSpec {
ArgSpec::flag(long).heading(heading).help(help)
}
fn option(long: &str, metavar: &str, heading: &str, help: &str) -> ArgSpec {
ArgSpec::option(long)
.value_name(metavar)
.heading(heading)
.help(help)
}
fn switch(long: &str, key: &str, heading: &str, help: &str) -> ArgSpec {
flag(long, heading, help)
.alias(format!("no-{long}"))
.config_key(key)
}
fn mode_command(name: &str, aliases: &[&str], about: &str) -> CommandSpec {
let mut command =
CommandSpec::new(name)
.about(about)
.usage(format!("{name} [OPTIONS] [RESOURCE]"))
.arg(ArgSpec::positional("resource").value(ValueHint::Path).help(
"A file path, an http(s) URL, or `-` for stdin; every `rich` option applies",
));
for alias in aliases {
command = command.alias(*alias);
}
command.args(command_options(name))
}
fn command_options(name: &str) -> Vec<ArgSpec> {
let names: &[&str] = match name {
"view" => &[
"search",
"no-line-numbers",
"sanitize",
"pager",
"highlighter",
"code-theme",
"width",
],
"hex" => &[
"search",
"offset",
"length",
"bytes-per-line",
"group",
"width",
],
"unicode" => &["limit", "width"],
"mermaid" => &["mermaid-backend", "width"],
"env" => &["show-secrets", "width"],
"capture" => &["cast", "redact", "redact-pattern", "sanitize", "width"],
"inspect" => &[
"format",
"select",
"filter",
"highlight",
"transform",
"find",
"flatten",
"table",
"max-depth",
"max-length",
"show-paths",
"redact",
"compare",
],
"ansi" => &["ansi-inline", "escapes-only"],
"diff" => &[
"side-by-side",
"context",
"language",
"threshold",
"sanitize",
],
_ => &[],
};
let all = root_args();
names
.iter()
.map(|long| {
let mut arg = all
.iter()
.find(|arg| arg.long.as_deref() == Some(*long))
.unwrap_or_else(|| panic!("no --{long} option"))
.clone();
arg.config_key = None;
arg
})
.collect()
}
fn render_modes() -> Vec<ArgSpec> {
let mode = |long: &str, short: Option<char>, help: &str| {
let arg = flag(long, RENDER_MODE, help);
match short {
Some(short) => arg.short(short),
None => arg,
}
};
vec![
mode(
"print",
Some('p'),
"Treat RESOURCE as literal markup TEXT, not a file path",
),
mode("markdown", Some('m'), "Render RESOURCE as Markdown"),
mode("rst", None, "Render RESOURCE as reStructuredText"),
mode("json", Some('j'), "Pretty-print RESOURCE as JSON").short_alias('J'),
mode(
"syntax",
Some('x'),
"Syntax-highlight RESOURCE (language from its extension)",
),
mode("csv", None, "Render RESOURCE as a CSV/TSV table"),
mode("ipynb", None, "Render RESOURCE as a Jupyter notebook"),
mode("jsonl", None, "Stream JSON Lines / NDJSON records").alias("ndjson"),
mode("log", None, "Stream common structured-log JSONL records"),
mode(
"gif",
None,
"Animate GIFs side by side; pipes receive the first frame",
),
mode(
"rule",
Some('u'),
"Draw a horizontal rule (RESOURCE is its title)",
),
mode(
"diff",
None,
"Compare two images perceptually, two text files as a diff (syntax-aware; ANSI \
captures by visible text and style), or render one patch such as `git diff` output",
),
mode(
"image",
None,
"Render RESOURCE as a still image (ASCII/Braille/blocks/quadrants/Sixel)",
),
mode(
"inspect",
None,
"Explore structured data (JSON, YAML, TOML, XML, INI, dotenv) as a tree",
),
mode(
"ansi-explain",
None,
"Decode every escape sequence in RESOURCE and show the visible text",
),
]
}
fn diff_options() -> Vec<ArgSpec> {
vec![
flag(
"side-by-side",
DIFF,
"With a text --diff, show old and new in two columns",
),
option(
"context",
"N",
DIFF,
"With a text --diff, unchanged lines around each change",
)
.default_value("3"),
option(
"language",
"NAME",
DIFF,
"With a text --diff, highlight as this language instead of guessing from the \
file name",
),
flag(
"ansi-inline",
DIFF,
"With --ansi-explain, mark escapes inline in the text instead of a table",
),
flag(
"escapes-only",
DIFF,
"With --ansi-explain, list only escape sequences, not text runs",
),
]
}
fn viewer_options() -> Vec<ArgSpec> {
vec![
option(
"search",
"TEXT",
VIEWERS,
"With `rich view`, highlight every case-insensitive match and mark its line; with \
`rich hex`, highlight a byte string: hex such as `de ad` or `0xDEAD`, or quoted \
text such as '\"PNG\"'",
),
flag(
"no-line-numbers",
VIEWERS,
"With `rich view`, leave out the line-number gutter",
),
option(
"offset",
"N",
VIEWERS,
"With `rich hex`, start N bytes in (decimal or 0x hex)",
),
option(
"length",
"N",
VIEWERS,
"With `rich hex`, show at most N bytes",
),
option(
"bytes-per-line",
"N",
VIEWERS,
"With `rich hex`, bytes on each line (default: fit the width, up to 16)",
),
option("group", "N", VIEWERS, "With `rich hex`, bytes per group").default_value("8"),
option(
"limit",
"N",
VIEWERS,
"With `rich unicode`, show at most N graphemes",
),
flag(
"show-secrets",
VIEWERS,
"With `rich env`, show values whose names look secret instead of masking them",
),
option(
"cast",
"FILE",
VIEWERS,
"With `rich capture`, also write an asciicast v2 recording (`asciinema play FILE`)",
),
option(
"redact-pattern",
"REGEX",
VIEWERS,
"With `rich capture`, also mask matches of REGEX (only its `secret` group when it \
has one); repeatable. Experimental: check the output before sharing it",
)
.multiple(true),
]
}
fn input_options() -> Vec<ArgSpec> {
let mut args = vec![option(
"format",
"F",
INPUT,
"Input format. With --inspect, the parser (default auto-detect); otherwise `auto` \
detects piped or extensionless input and routes it (JSON to --json, other formats \
to highlighting, anything else to plain text), and a named format overrides the \
extension",
)
.choices(["auto", "json", "yaml", "toml", "xml", "ini", "env"])
.default_value("auto")
.config_key("format")];
args.extend(
rich_ext::cli::arg_specs()
.into_iter()
.filter(|arg| arg.id == "encoding")
.map(|arg| arg.heading(INPUT)),
);
args
}
fn layout_options() -> Vec<ArgSpec> {
vec![
option(
"width",
"N",
LAYOUT,
"Render the output N columns wide (the console keeps its own width, so \
--left/--center/--right still use it)",
)
.short('w')
.config_key("width"),
option(
"max-width",
"SIZE",
LAYOUT,
"Print at most SIZE columns wide (upstream's `console.print(width=…)`)",
)
.short('W'),
flag("left", LAYOUT, "Left-justify output").short('l'),
flag("center", LAYOUT, "Center output").short('c'),
flag("right", LAYOUT, "Right-justify output").short('r'),
flag(
"text-left",
LAYOUT,
"Justify --print and --rule text to the left",
)
.short('L'),
flag(
"text-center",
LAYOUT,
"Justify --print and --rule text to the center",
)
.short('C'),
flag(
"text-right",
LAYOUT,
"Justify --print and --rule text to the right",
)
.short('R'),
flag(
"text-full",
LAYOUT,
"Justify --print text to both edges (a rule centres its title)",
)
.short('F'),
flag(
"soft",
LAYOUT,
"Soft wrap: no line is wrapped or cut, so the terminal wraps it",
),
flag(
"emoji",
LAYOUT,
"Replace :emoji: codes, e.g. :sparkles: (off by default, as upstream)",
),
option(
"panel",
"BOX",
LAYOUT,
"Wrap output in a panel, shrunk to fit its content (none = no panel)",
)
.choices(
super::PANEL_BOXES
.iter()
.map(|(name, _)| *name)
.chain(["none"]),
)
.short('a')
.config_key("panel"),
option(
"padding",
"P",
LAYOUT,
"Wrap output in padding (1, 2, or 4 comma-separated ints)",
)
.short('d')
.config_key("padding"),
flag(
"expand",
LAYOUT,
"Make --panel/--padding fill the width instead of fitting (implied by --width)",
)
.short('e'),
option(
"title",
"T",
LAYOUT,
"Panel title; also the CSV table's title",
),
option(
"caption",
"T",
LAYOUT,
"Panel subtitle; also the CSV table's caption",
),
option(
"style",
"S",
LAYOUT,
"Style laid under the whole output, e.g. \"bold red\"",
)
.short('s'),
option(
"panel-style",
"S",
LAYOUT,
"Panel border style, e.g. \"dim\" (with --panel)",
)
.short('S'),
flag(
"hyperlinks",
LAYOUT,
"Render a Markdown link as a clickable OSC 8 hyperlink. Off by default, which \
shows the URL as `text (url)`",
)
.short('y'),
]
}
fn source_options() -> Vec<ArgSpec> {
vec![
option(
"head",
"LINES",
SOURCE,
"Show the first LINES of a source file or CSV (as upstream, `-h` is --head, \
not help)",
)
.short('h'),
option(
"tail",
"LINES",
SOURCE,
"Show the end of a source file or CSV (as upstream, --tail N shows the last N-1 \
source lines)",
)
.short('t'),
flag("line-numbers", SOURCE, "Number the lines of source").short('n'),
flag("guides", SOURCE, "Draw indentation guides in source").short('g'),
option(
"lexer",
"LEXER",
SOURCE,
"Highlight source as LEXER instead of guessing from the file name; with --rst, \
highlight code blocks that name no language as LEXER",
),
flag(
"no-wrap",
SOURCE,
"Crop long lines of source instead of wrapping them; with --print, don't wrap text",
),
]
}
fn mode_options() -> Vec<ArgSpec> {
let mut args = vec![
option(
"rule-style",
"STYLE",
MODE_OPTIONS,
"With --rule, the line's style",
)
.default_value("bright_green"),
option(
"rule-char",
"CHARACTER",
MODE_OPTIONS,
"With --rule, the character(s) the line is drawn with",
)
.default_value("─"),
option(
"log-presentation",
"MODE",
MODE_OPTIONS,
"With --log, select log presentation",
)
.choices(["plain", "rich"])
.default_value("plain")
.config_key("log_presentation"),
option(
"mermaid-backend",
"BACKEND",
MODE_OPTIONS,
"How Mermaid diagrams (`rich mermaid`, ```mermaid fences in Markdown) are drawn: text \
draws flowcharts as text, mmdc uses Mermaid's own CLI (a build with the mmdc \
feature; `rich mermaid` tries it first there), off leaves fences as code",
)
.choices(["text", "mmdc", "off"])
.config_key("mermaid_backend"),
option(
"highlighter",
"NAME",
MODE_OPTIONS,
"The code highlighter for source, Markdown code, view and diff: syntect (the \
default), or lumis in a build with the lumis feature",
)
.config_key("highlighter"),
option(
"code-theme",
"NAME",
MODE_OPTIONS,
"A theme of the chosen code highlighter, e.g. ansi_dark (`rich doctor --report json` \
lists them)",
)
.config_key("code_theme"),
option(
"loop",
"N",
MODE_OPTIONS,
"With --gif, repeat N times (default 1; 0 = forever)",
),
];
args.extend(
rich_ext::cli::arg_specs()
.into_iter()
.filter(|arg| arg.id == "gif-mode")
.map(|arg| arg.heading(MODE_OPTIONS)),
);
args
}
fn image_options() -> Vec<ArgSpec> {
let tone = "Tone adjustment (1.0 = unchanged)";
vec![
option(
"image-mode",
"M",
IMAGE,
"With --diff/--image, how to draw the picture: sixel draws real pixels; blocks, \
quadrants, braille and ascii draw characters (--image rejects none: there would \
be nothing to draw)",
)
.choices([
"auto",
"sixel",
"blocks",
"quadrants",
"braille",
"ascii",
"none",
])
.default_value("auto"),
option(
"height",
"N",
IMAGE,
"With --image, render this many rows instead of the backend's default",
)
.config_key("height"),
option(
"image-fit",
"M",
IMAGE,
"With --image and --height: contain letterboxes, cover crops at --image-anchor, \
stretch fills ignoring aspect. native needs no --height: it renders the image at \
its own pixel size (never enlarged), capped by --width, the terminal and \
--image-max-width/--image-max-height",
)
.choices(["contain", "cover", "stretch", "native"])
.config_key("image_fit"),
option("image-anchor", "A", IMAGE, "Cover crop anchor")
.choices([
"center",
"top",
"bottom",
"left",
"right",
"top-left",
"top-right",
"bottom-left",
"bottom-right",
])
.default_value("center")
.config_key("image_anchor"),
option(
"image-max-width",
"N",
IMAGE,
"With --image, never exceed N columns (aspect kept)",
)
.config_key("image_max_width"),
option(
"image-max-height",
"N",
IMAGE,
"With --image, never exceed N rows (aspect kept)",
)
.config_key("image_max_height"),
option(
"image-background",
"BG",
IMAGE,
"With --image: flatten transparency onto #RRGGBB (also colours contain padding; \
quote the # in your shell), leave it to the terminal's background (default), or \
show it on a gray checkerboard (checkerboard)",
)
.config_key("image_background"),
option(
"image-color",
"M",
IMAGE,
"Colour depth for --image (not Braille, which is monochrome) and --gif frames",
)
.choices(["truecolor", "ansi256", "ansi16", "grayscale"])
.default_value("truecolor")
.config_key("image_color"),
option(
"image-dither",
"M",
IMAGE,
"Dithering (needs a non-truecolor --image-color)",
)
.choices(["none", "floyd-steinberg", "bayer4x4", "atkinson"])
.default_value("none")
.config_key("image_dither"),
option(
"image-color-distance",
"M",
IMAGE,
"How the nearest palette colour is measured: encoded RGB, or perceptual OKLab \
(needs a non-truecolor --image-color)",
)
.choices(["rgb", "oklab"])
.default_value("rgb")
.config_key("image_color_distance"),
option(
"image-brightness",
"F",
IMAGE,
"Tone adjustment (1.0 = unchanged). Brightness, contrast and gamma apply in that \
order, after rotation/flips and before grayscale and colour",
)
.default_value("1.0")
.config_key("image_brightness"),
option("image-contrast", "F", IMAGE, tone)
.default_value("1.0")
.config_key("image_contrast"),
option("image-gamma", "F", IMAGE, tone)
.default_value("1.0")
.config_key("image_gamma"),
option("image-rotate", "N", IMAGE, "Rotate still images clockwise")
.choices(["0", "90", "180", "270"])
.default_value("0")
.config_key("image_rotate"),
switch(
"image-flip-horizontal",
"image_flip_horizontal",
IMAGE,
"Flip still images horizontally, after rotation",
),
switch(
"image-flip-vertical",
"image_flip_vertical",
IMAGE,
"Flip still images vertically, after rotation",
),
switch(
"image-grayscale",
"image_grayscale",
IMAGE,
"Composite and convert still images to grayscale",
),
option(
"threshold",
"PCT",
IMAGE,
"With --diff, exit non-zero above PCT% changed (pixels for images, lines for \
text). Also sets the exit code: 0 within, 5 over.",
),
]
}
fn inspect_options() -> Vec<ArgSpec> {
vec![
option(
"select",
"EXPR",
INSPECT,
"Show only what a JSONPath expression selects, e.g. `$.servers[*].name`",
),
option(
"filter",
"PATTERN",
INSPECT,
"Keep only what matches: with --inspect, the values a JSONPath selects and the \
containers above them; for text, --print and --syntax, the lines a regular \
expression matches",
),
option(
"highlight",
"PATTERN",
INSPECT,
"Highlight what matches, in reverse video: with --inspect, the tree lines a \
JSONPath selects; for text, --print and --syntax, the matches of a regular \
expression",
),
option(
"transform",
"NAME",
INSPECT,
"Apply a registered text transform, such as one a --plugin adds, to text, \
--print and --syntax; repeatable, applied in the order given, after --filter and \
before --highlight. `rich plugins list` shows the names",
)
.multiple(true),
option(
"find",
"TEXT",
INSPECT,
"Search keys and values (case-insensitive), highlighting matches",
),
flag(
"flatten",
INSPECT,
"Show `path = value` rows instead of a tree",
),
flag(
"table",
INSPECT,
"Show records, or a path/value table, instead of a tree",
),
option(
"max-depth",
"N",
INSPECT,
"Fold containers deeper than N levels",
),
option(
"max-length",
"N",
INSPECT,
"Show at most N items per container",
),
flag("show-paths", INSPECT, "Append each value's path"),
flag(
"redact",
INSPECT,
"Mask secret-looking keys such as password, token and api_key. With `rich \
capture`, mask secrets in the output before it is shown, exported or recorded: \
secret-named `key=value` values, bearer tokens, GitHub/GitLab/Slack/Stripe/npm/`sk-` \
tokens, AWS key ids, JWTs and URL passwords. Capture redaction is experimental \
and best effort: check the output before sharing it",
),
option(
"compare",
"PATH",
INSPECT,
"Show added, removed and changed values against another document",
)
.value(ValueHint::File),
]
}
fn export_options() -> Vec<ArgSpec> {
vec![
option(
"export-html",
"PATH",
EXPORT,
"Also write a self-contained HTML document to PATH",
)
.short('o')
.value(ValueHint::File)
.config_key("export_html"),
option(
"export-svg",
"PATH",
EXPORT,
"Also write an SVG document to PATH. Unlike the HTML, it references its font \
from a CDN, so it is not self-contained offline.",
)
.value(ValueHint::File)
.config_key("export_svg"),
]
}
fn paging_options() -> Vec<ArgSpec> {
vec![
flag(
"pager",
PAGING,
"Page terminal output via MANPAGER, PAGER, then less/more.com",
)
.config_key("pager"),
flag("no-pager", PAGING, "Disable explicit and automatic paging"),
flag(
"auto-pager",
PAGING,
"Page only terminal output taller than the viewport",
)
.config_key("auto_pager"),
flag("no-auto-pager", PAGING, "Disable automatic paging"),
]
}
fn watch_options() -> Vec<ArgSpec> {
vec![
switch(
"watch",
"watch",
WATCH,
"Re-render changing files (several allowed) or one URL while stdout is a \
terminal; each file gets its own live region",
),
option("watch-interval", "SEC", WATCH, "Poll interval in seconds")
.alias("interval")
.default_value("1")
.config_key("watch_interval"),
option(
"watch-debounce",
"SEC",
WATCH,
"Quiet period collapsing a burst of file events",
)
.default_value("0.1")
.config_key("watch_debounce"),
switch(
"watch-poll",
"watch_poll",
WATCH,
"Poll local files at --watch-interval instead of file events",
),
switch(
"watch-exit-on-error",
"watch_exit_on_error",
WATCH,
"End the watch with a non-zero exit when a render fails",
),
switch(
"watch-cache",
"watch_cache",
WATCH,
"With URLs, render only when the response body changes",
),
]
}
fn batch_options() -> Vec<ArgSpec> {
vec![
switch(
"batch",
"batch",
BATCH,
"Convert explicit files, directories, or globs deterministically",
),
option(
"batch-input-root",
"PATH",
BATCH,
"The directory --batch-preserve-dirs keeps paths relative to",
)
.value(ValueHint::Dir)
.config_key("batch_input_root"),
switch(
"batch-preserve-dirs",
"batch_preserve_dirs",
BATCH,
"Preserve paths under --batch-input-root PATH",
),
option(
"batch-name-template",
"TEMPLATE",
BATCH,
"Name export leaves; export paths become directories",
)
.config_key("batch_name_template"),
option(
"jobs",
"N",
BATCH,
"Parallel file-export workers; requires --batch. Terminal output stays in input \
order; active jobs finish on error.",
)
.default_value("1")
.config_key("jobs"),
switch(
"progress",
"progress",
BATCH,
"Enable/disable batch counts on terminal stderr; hidden for redirected stderr, \
JSON reports and dry runs",
)
.default_value("true"),
flag(
"dry-run",
BATCH,
"Validate and show the batch plan without writing files",
),
switch(
"continue-on-error",
"continue_on_error",
BATCH,
"Process all planned inputs and aggregate failures",
),
switch(
"overwrite",
"overwrite",
BATCH,
"Allow existing batch export destinations",
),
option(
"collision",
"P",
BATCH,
"Batch policy for existing destinations",
)
.choices(["error", "overwrite", "suffix"])
.default_value("error")
.config_key("collision"),
]
}
fn config_options() -> Vec<ArgSpec> {
vec![
option(
"config",
"PATH",
CONFIG,
"Read versioned TOML defaults from PATH",
)
.value(ValueHint::File),
option("profile", "NAME", CONFIG, "Select a config profile").default_value("default"),
flag("no-config", CONFIG, "Disable config discovery"),
option("theme", "NAME", CONFIG, "Select a named theme from config").config_key("theme"),
option(
"theme-style",
"NAME=STYLE",
CONFIG,
"Override a theme binding; repeatable and worker-safe",
)
.multiple(true),
option(
"theme-file",
"PATH",
CONFIG,
"Load styles from an upstream rich theme file ([styles] section); --theme and \
--theme-style override it",
)
.config_key("theme_file"),
option(
"plugin",
"PATH",
CONFIG,
"Load a runtime plugin: a WASM module (.wasm, sandboxed) or a native library \
(.so, .dylib, .dll, which runs its own code), in a build with the wasm-plugins \
or dylib-plugins feature; repeatable. `rich plugins list` shows what is loaded",
)
.value(ValueHint::File)
.multiple(true)
.config_key("plugins"),
switch(
"micro-project",
"micro_project",
CONFIG,
"Load micro assets from the project's .rich/micro/ (off by default: a project's \
assets load only when you trust it; ./rich.toml cannot turn this on)",
),
]
}
fn output_options() -> Vec<ArgSpec> {
vec![
flag(
"no-color",
OUTPUT,
"Disable colored output (as does a non-empty NO_COLOR)",
)
.env("NO_COLOR")
.config_key("no_color"),
flag(
"color",
OUTPUT,
"Override a config no_color setting (pipes remain plain)",
),
switch(
"sanitize",
"sanitize",
OUTPUT,
"Replace input terminal controls, JSON/notebook strings, titles and captions \
with visible inert text. On by default for `rich view` and text `rich diff`; \
--no-sanitize turns it off there",
),
option(
"report",
"F",
OUTPUT,
"Emit a result/error envelope on stderr",
)
.choices(["human", "json"])
.default_value("human"),
flag("machine-json", OUTPUT, "Alias for --report json"),
]
}
fn demo_options() -> Vec<ArgSpec> {
vec![
flag(
"demo",
DEMO,
"Guided suite tour; pauses 3 seconds between sections on a TTY",
),
flag(
"demo-list",
DEMO,
"List stable tour sections: core, workflows, art",
),
option(
"demo-section",
"NAME",
DEMO,
"With --demo, play one section only",
)
.choices(["core", "workflows", "art"]),
option(
"demo-delay",
"SECONDS",
DEMO,
"Tour pause (0–60); no pauses when redirected; Ctrl+C stops",
)
.default_value("3"),
]
}
fn general_options() -> Vec<ArgSpec> {
vec![
flag("help", GENERAL, "Show this help"),
flag("version", GENERAL, "Show the rs-rich-cli package version")
.short('V')
.short_alias('v'),
flag(
"force-terminal",
GENERAL,
"Write colour and styles even when output is not a terminal",
),
]
}
fn config_keys() -> Vec<String> {
let mut keys = vec!["mode".to_string()];
keys.extend(
root_args()
.into_iter()
.filter_map(|arg| arg.config_key)
.filter(|key| key != "mode"),
);
keys
}
fn root_args() -> Vec<ArgSpec> {
let mut args = vec![ArgSpec::positional("resource")
.value(ValueHint::Path)
.multiple(true)
.heading(ARGUMENTS)
.help(
"A file path, an http(s) URL, or `-` for stdin (several with --batch, --watch \
or --gif)",
)];
for group in [
render_modes(),
input_options(),
layout_options(),
source_options(),
mode_options(),
image_options(),
inspect_options(),
diff_options(),
viewer_options(),
export_options(),
paging_options(),
watch_options(),
batch_options(),
config_options(),
output_options(),
demo_options(),
general_options(),
] {
args.extend(group);
}
args
}
fn config_command() -> CommandSpec {
let same = "[OPTIONS]";
CommandSpec::new("config")
.about("Show, validate, explain or document configuration")
.long_about(
"Show, validate, explain or document configuration.\n\n\
Each subcommand accepts --config PATH, --profile NAME, --no-config, --theme NAME \
and the setting flags (--width, --pager, ...) that `rich` itself accepts, as \
command-line overrides.",
)
.subcommand_required(true)
.subcommand(
CommandSpec::new("show")
.about("Show configured settings with CLI overrides as JSON")
.usage(format!("show {same}")),
)
.subcommand(
CommandSpec::new("validate")
.about("Validate TOML, all profiles and explicit setting values")
.usage(format!("validate {same}")),
)
.subcommand(
CommandSpec::new("explain")
.about(
"Show which layer sets each setting — defaults, environment, config file, \
profile, command line — and what it overrides",
)
.usage(format!("explain {same} [KEY]"))
.arg(
ArgSpec::positional("key")
.help("Explain one setting instead of all of them")
.value(ValueHint::Choices(
config_keys().into_iter().map(Choice::new).collect(),
)),
),
)
.subcommand(
CommandSpec::new("reference")
.about("List every configuration source and key")
.usage(format!("reference {same}")),
)
}
fn authoring_commands() -> Vec<CommandSpec> {
vec![
CommandSpec::new("completions")
.about("Print a shell completion script")
.arg(
ArgSpec::positional("shell")
.required(true)
.help("The shell to complete for (pwsh is accepted for powershell)")
.choices(["bash", "zsh", "fish", "powershell"]),
)
.example(
"rich completions bash > ~/.local/share/bash-completion/completions/rich",
"Bash",
)
.example("rich completions zsh > \"${fpath[1]}/_rich\"", "Zsh")
.example(
"rich completions fish > ~/.config/fish/completions/rich.fish",
"Fish",
)
.example(
"rich completions powershell | Out-String | Invoke-Expression",
"PowerShell",
),
CommandSpec::new("docs")
.about("Print reference documentation generated from this help")
.subcommand_required(true)
.subcommand(
CommandSpec::new("markdown").about("Print the command reference as Markdown"),
)
.subcommand(
CommandSpec::new("man")
.about("Print the rich(1) man page, or write every page to a directory")
.arg(
ArgSpec::option("output")
.value_name("DIR")
.value(ValueHint::Dir)
.help(
"Write rich.1 and one page per subcommand into DIR (created \
if missing) and list them",
),
),
)
.subcommand(
CommandSpec::new("config").about("Print the configuration reference as Markdown"),
),
CommandSpec::new("bench")
.about("Compare benchmark runs")
.subcommand_required(true)
.subcommand(
CommandSpec::new("compare")
.about(
"Compare a candidate benchmark run with a baseline; exits 5 when any \
benchmark regressed",
)
.arg(
ArgSpec::positional("baseline")
.required(true)
.value(ValueHint::Path)
.help("A rich_ext::qa::bench JSON run, or a criterion directory"),
)
.arg(
ArgSpec::positional("candidate")
.required(true)
.value(ValueHint::Path)
.help("The run to judge, in the same form"),
)
.arg(
ArgSpec::option("threshold")
.value_name("PCT")
.default_value("5")
.help("Changes within ±PCT% (beyond noise) count as unchanged"),
)
.example(
"rich bench compare baseline.json candidate.json --threshold 10",
"Gate CI on a 10% slowdown",
),
),
]
}
pub(crate) fn spec() -> CommandSpec {
let mut spec = CommandSpec::new("rich")
.version(super::VERSION)
.about(format!(
"rich {} — Rust port of the rich-cli terminal toolbox\n\n\
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.\n\n\
A `--no-…` spelling (such as --no-watch) disables the corresponding \
config/default boolean.",
super::VERSION
))
.usage("rich [OPTIONS] [RESOURCE]")
.usage("rich [OPTIONS] <COMMAND> [RESOURCE]")
.usage("rich --batch [OPTIONS] RESOURCE...")
.usage("rich --watch [OPTIONS] FILE...")
.args(root_args())
.heading_note(
RENDER_MODE,
"Choose at most one; the default auto-detects .md/.rst/.json/.csv/.tsv/.ipynb by \
extension — anything else with a file extension is syntax-highlighted.",
)
.heading_note(
DEMO,
"Self-contained examples; ignores config; accepts --no-color.",
);
for (name, aliases, about) in [
(
"print",
&[][..],
"Treat RESOURCE as literal markup TEXT (`--print`)",
),
("markdown", &["md"][..], "Render Markdown (`--markdown`)"),
("rst", &[][..], "Render reStructuredText (`--rst`)"),
(
"syntax",
&["code"][..],
"Syntax-highlight source (`--syntax`)",
),
("json", &[][..], "Pretty-print JSON (`--json`)"),
("csv", &["tsv"][..], "Render CSV/TSV as a table (`--csv`)"),
(
"ipynb",
&["notebook"][..],
"Render a Jupyter notebook (`--ipynb`)",
),
(
"jsonl",
&["ndjson"][..],
"Stream JSON Lines / NDJSON records (`--jsonl`)",
),
(
"log",
&["logs"][..],
"Stream common structured-log JSONL records (`--log`)",
),
("gif", &[][..], "Animate GIFs (`--gif`)"),
(
"diff",
&[][..],
"Compare two images, two text files, or render one patch (`--diff`)",
),
(
"image",
&[][..],
"Render a still image as ASCII/Braille/blocks/quadrants/Sixel (`--image`)",
),
("rule", &[][..], "Draw a horizontal rule (`--rule`)"),
(
"inspect",
&[][..],
"Explore structured data (JSON, YAML, TOML, XML, INI, dotenv) as a tree \
(`--inspect`)",
),
(
"ansi",
&["ansi-explain"][..],
"Decode escape sequences: `rich ansi explain FILE` (`--ansi-explain`)",
),
(
"view",
&[][..],
"Show any file: Markdown, CSV, notebooks, images and patches rendered, source and \
data numbered and highlighted, binary as hex; paged, searchable with --search",
),
(
"hex",
&["hexdump"][..],
"Hex dump with offsets, byte groups and an ASCII panel (--search, --offset, --length)",
),
(
"unicode",
&[][..],
"Show graphemes, code points, UTF-8 bytes, widths and invalid sequences",
),
(
"env",
&[][..],
"List environment variables, secrets masked; `rich env PATH` checks each PATH entry",
),
(
"capture",
&[][..],
"Run `rich capture -- COMMAND ARGS…` and show or export its output (--cast FILE \
records it, --redact masks secrets), then exit with the command's status",
),
(
"mermaid",
&["mmd"][..],
"Draw a Mermaid diagram: flowcharts as text, every type through mmdc where built in \
(.mmd and .mermaid files are detected)",
),
] {
spec = spec.subcommand(mode_command(name, aliases, about));
}
spec = spec.subcommand(config_command());
#[cfg(feature = "interact")]
for command in super::interactive::commands() {
spec = spec.subcommand(command);
}
for command in authoring_commands() {
spec = spec.subcommand(command);
}
#[cfg(feature = "art")]
{
spec = spec.subcommand(super::micro::command());
}
spec.subcommand(
CommandSpec::new("record")
.about(
"Run tapes (scripted terminal sessions: type, press keys, wait for screen text, \
take screenshots) in a real terminal and write PNG, SVG and text screenshots, an \
asciinema cast, a GIF, an MP4 (with FFmpeg) and an HTML page with a player; \
--check compares screenshots with committed ones instead",
)
.usage(
"record [--check] [--output DIR] [--bin-dir DIR] \
[--format png,svg,cast,gif,mp4,html] [--no-video] [--font FILE] \
[--window-frame on|off] [--caption TEXT] [--key-overlay on|off] TAPE...",
),
)
.subcommand(
CommandSpec::new("plugins")
.about(
"List the plugins this build has — built in, linked, and any --plugin PATH \
loads — with their source, version, ABI and capabilities",
)
.usage(
"plugins [list | info NAME] [--plugin PATH]... [--report json] [--config PATH] \
[--profile NAME] [--no-config] [--no-color]",
)
.subcommand(
CommandSpec::new("list")
.about("A table of every plugin (the default)")
.usage("list [--plugin PATH]... [--report json]"),
)
.subcommand(
CommandSpec::new("info")
.about("One plugin: its path, ABI, capabilities and description")
.usage("info NAME [--plugin PATH]... [--report json]")
.arg(ArgSpec::positional("name").help("The plugin's id, as `list` shows it")),
),
)
.subcommand(
CommandSpec::new("doctor")
.about(
"Read-only build, terminal, config and pager diagnostics; --report json writes \
diagnostic data to stdout",
)
.usage(
"doctor [--report json] [--config PATH] [--profile NAME] [--no-config] \
[--no-color]",
),
)
.section(
"Environment",
"- NO_COLOR: any non-empty value disables colour\n\
- COLUMNS: console width (default 80 when unavailable)\n\
- MANPAGER, PAGER: pager command; fallback is less (Unix), more.com (Windows)\n\
- FORCE_COLOR: not supported; redirected stdout stays plain\n\
- RICH_GRAPHICS: sixel forces Sixel on; none (or kitty, iterm) rules it out\n\
- RICH_SIXEL: 0/1 overrides Sixel detection when RICH_GRAPHICS is unset\n\
- RICH_MICRO: kitty, iterm, sixel, blocks or text: how micro assets are drawn on a \
terminal (never off one)\n\
- RICH_CELL_PIXELS: WxH, the cell size in pixels micro assets are fitted to",
)
.section(
"",
"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.",
)
.section(
"Exit codes",
"- 0: success\n\
- 1: cancelled, or answered no (choose, filter, input, confirm, write, file, color, \
asset)\n\
- 2: usage/config error\n\
- 3: input/read/write error\n\
- 4: parse/render data error\n\
- 5: threshold/gate failure\n\
- 130: interrupted with Ctrl+C (choose, filter, input, confirm, pager, write, file, \
color, asset)",
)
}
pub(crate) fn config_reference() -> ConfigReference {
let mut reference = ConfigReference::from_spec(&spec())
.description(
"Settings are TOML: `version = 1`, a `[defaults]` table, optional \
`[profile.NAME]` tables and `[themes.NAME]` style tables. `rich config explain` \
shows where each effective value comes from.",
)
.source("defaults", "", "Built-in defaults")
.source(
"environment",
"NO_COLOR",
"A non-empty value sets no_color = true. A no_color = false in ./rich.toml \
cannot undo it; ~/.config/rich/config.toml, --config PATH and --color can",
)
.source(
"config file",
"./rich.toml, else ~/.config/rich/config.toml",
"The [defaults] table. Only the first file found is read; --config PATH reads \
that file instead and --no-config reads none",
)
.source(
"profile",
"[profile.NAME]",
"The profile selected with --profile (default: default) overrides the [defaults] \
table",
)
.source(
"command line",
"",
"Explicit flags override every other source",
);
for entry in &mut reference.entries {
let kind = match entry.key.as_str() {
"width" | "height" | "jobs" | "image_max_width" | "image_max_height" => {
"positive integer"
}
"watch_interval" | "watch_debounce" | "image_brightness" | "image_contrast"
| "image_gamma" => "number",
"plugins" => "list of paths",
_ => continue,
};
entry.kind = kind.to_string();
}
reference.entries.insert(
0,
ConfigEntry::new("mode", "enum")
.choices(MODES.iter().copied())
.default_value("auto")
.flag("--print, --markdown, ... or a command word")
.description("Render mode, as the flag or command of the same name"),
);
reference
}
pub(crate) fn default_layer() -> Layer {
let mut layer = Layer::new("defaults").value("mode", "auto");
for arg in root_args() {
let Some(key) = &arg.config_key else { continue };
match (&arg.default, arg.takes_value()) {
(Some(value), _) => layer = layer.value(key.clone(), value.clone()),
(None, false) => layer = layer.value(key.clone(), "false"),
(None, true) => {}
}
}
layer
}
pub(crate) fn known_config_keys() -> Vec<String> {
config_keys()
}
pub(crate) fn console(no_color: bool) -> Console {
Console::builder().no_color(no_color).build()
}
pub(crate) fn render(renderable: &dyn Renderable, no_color: bool) -> String {
console(no_color).render_to_string(renderable)
}
pub(crate) fn print_help(no_color: bool) {
console(no_color).print(&HelpView::new(&spec()));
}
pub(crate) fn subcommand_help(path: &[&str], no_color: bool) -> Option<String> {
let spec = spec();
HelpView::for_path(&spec, path).map(|view| render(&view, no_color))
}
pub(crate) fn no_color_requested(args: &[String]) -> bool {
let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
for arg in args.iter().take_while(|arg| *arg != "--") {
match arg.as_str() {
"--no-color" => no_color = true,
"--color" => no_color = false,
_ => {}
}
}
no_color
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{config_args, ConfigRoots};
use std::collections::BTreeSet;
const MAIN: &str = include_str!("lib.rs");
const CONFIG: &str = include_str!("config.rs");
const DEMO: &str = include_str!("demo.rs");
const INSPECT: &str = include_str!("inspect.rs");
const TOOLS: &str = include_str!("tools.rs");
const VIEWERS: &str = include_str!("viewers.rs");
#[cfg(feature = "interact")]
const INTERACTIVE: &str = include_str!("interactive.rs");
#[cfg(feature = "art")]
const MICRO: &str = include_str!("micro.rs");
fn item<'a>(source: &'a str, start: &str) -> &'a str {
let from = source
.find(start)
.unwrap_or_else(|| panic!("{start} not found"));
let rest = &source[from..];
let end = rest[1..]
.find("\nfn ")
.into_iter()
.chain(rest[1..].find("\nconst "))
.chain(rest[1..].find("\npub(crate) fn "))
.chain(rest[1..].find("\npub(super) fn "))
.min()
.map_or(rest.len(), |i| i + 1);
&rest[..end]
}
fn option_literals(source: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for piece in source.split('"').skip(1).step_by(2) {
let long = piece.len() > 2
&& piece.starts_with("--")
&& piece[2..].starts_with(|c: char| c.is_ascii_lowercase())
&& piece[2..]
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
let short = piece.len() == 2
&& piece.starts_with('-')
&& piece[1..].starts_with(|c: char| c.is_ascii_alphabetic());
if long || short {
out.insert(piece.to_string());
}
}
out
}
fn parser_options() -> BTreeSet<String> {
let mut out = BTreeSet::new();
for source in [
item(MAIN, "fn parse_inner("),
item(MAIN, "const VALUE_OPTIONS"),
item(MAIN, "fn mode_flag_alias("),
item(CONFIG, "fn arguments("),
item(CONFIG, "fn boolean_flags("),
item(DEMO, "fn options("),
item(INSPECT, "impl DataOptions {"),
item(TOOLS, "impl ToolOptions {"),
item(VIEWERS, "impl ViewerOptions {"),
] {
out.extend(option_literals(source));
}
#[cfg(feature = "interact")]
out.extend(option_literals(item(INTERACTIVE, "fn parse_args(")));
#[cfg(feature = "art")]
for start in ["const CREATE_VALUES", "const CREATE_FLAGS", "fn request("] {
out.extend(option_literals(item(MICRO, start)));
}
for arg in rich_ext::cli::arg_specs() {
out.extend(arg.switches());
}
out
}
fn all_args(spec: &CommandSpec) -> Vec<ArgSpec> {
let mut out: Vec<ArgSpec> = spec.args.clone();
for command in &spec.subcommands {
out.extend(all_args(command));
}
out
}
fn spec_switches() -> BTreeSet<String> {
all_args(&spec())
.iter()
.filter(|arg| !arg.positional)
.flat_map(ArgSpec::switches)
.collect()
}
#[test]
fn the_heuristic_sees_the_parser() {
let found = parser_options();
for known in [
"--width",
"-w",
"--no-watch",
"--config",
"--demo-delay",
"--gif-mode",
] {
assert!(found.contains(known), "{known} not found: {found:?}");
}
assert!(!found.iter().any(|o| o.contains('/')));
assert!(
found.len() > 90,
"the heuristic found too little: {found:?}"
);
}
#[test]
fn every_parser_option_is_in_the_spec() {
let documented = spec_switches();
let missing: Vec<_> = parser_options()
.into_iter()
.filter(|option| !documented.contains(option))
.collect();
assert!(missing.is_empty(), "undocumented options: {missing:?}");
}
#[test]
fn every_spec_option_has_a_parser() {
let parser = parser_options();
let unknown: Vec<_> = spec_switches()
.into_iter()
.filter(|s| !parser.contains(s))
.filter(|s| s != "--output")
.collect();
assert!(
unknown.is_empty(),
"spec options with no parser: {unknown:?}"
);
}
fn sample(arg: &ArgSpec) -> String {
arg.choice_list()
.first()
.map_or_else(|| "1".to_string(), |choice| choice.value.clone())
}
#[test]
fn every_spec_switch_is_accepted_by_the_parser() {
let root = tempfile::tempdir().unwrap();
let roots = ConfigRoots {
home: None,
cwd: root.path().into(),
no_color_env: false,
};
let demo = demo_options();
for arg in root_args().iter().filter(|arg| !arg.positional) {
for switch in arg.switches() {
if demo.iter().any(|d| d.id == arg.id) {
assert!(DEMO.contains(&format!("\"{switch}\" =>")), "{switch}");
continue;
}
let mut args = vec![switch.clone()];
if arg.takes_value() {
args.push(sample(arg));
}
args.push("input.txt".into());
let result = config_args(&args, &roots).and_then(|merged| {
super::super::parse_inner(&merged).map(|_| ())
});
if let Err(message) = result {
assert!(
!message.starts_with("unknown option"),
"{switch} rejected: {message}"
);
}
}
}
}
#[test]
fn value_taking_agrees_with_the_parser() {
for arg in root_args().iter().filter(|arg| !arg.positional) {
for switch in arg.switches() {
let consumes = super::super::VALUE_OPTIONS.contains(&switch.as_str());
assert_eq!(
arg.takes_value(),
consumes,
"{switch}: spec and VALUE_OPTIONS disagree about taking a value"
);
}
}
}
#[test]
fn config_keys_match_the_validator() {
let keys = config_keys();
for key in &keys {
let probe = toml::Value::Boolean(true);
if let Err(message) = crate::config::validate_value(key, &probe) {
assert!(!message.starts_with("unknown key"), "{key}: {message}");
}
}
for key in crate::config::all_keys() {
assert!(
keys.iter().any(|k| k == key),
"{key} has no documented flag"
);
}
}
#[test]
fn subcommands_cover_every_mode_word() {
let spec = spec();
for mode in super::super::MODE_SPECS.iter().skip(1) {
for alias in mode.aliases {
assert!(spec.find_subcommand(alias).is_some(), "{alias}");
}
}
for name in ["config", "completions", "docs", "doctor", "inspect"] {
assert!(spec.find_subcommand(name).is_some(), "{name}");
}
}
#[test]
fn help_keeps_the_documented_phrases() {
let out = Console::builder()
.width(100)
.build()
.render_to_string(&HelpView::new(&spec()));
let flat = out.split_whitespace().collect::<Vec<_>>().join(" ");
for phrase in [
"Usage: rich [OPTIONS] [RESOURCE]",
"rich --watch [OPTIONS] FILE...",
"stdin until EOF",
"Choose at most one",
"-y, --hyperlinks",
"default 1; 0 = forever",
"--jobs <N>",
"[config: watch_interval]",
"[env: NO_COLOR]",
"RICH_SIXEL",
"5: threshold/gate failure",
"a capability demo is shown",
] {
assert!(flat.contains(phrase), "missing {phrase:?}:\n{out}");
}
}
}