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 LOG: &str = "Log";
const SOURCE: &str = "Source";
const IMAGE: &str = "Image";
const INSPECT: &str = "Inspect";
const DIFF: &str = "Diff & ANSI";
const VIEWERS: &str = "Viewers";
const SOURCES: &str = "Diagram sources";
const CHART: &str = "Chart";
const PROFILE: &str = "Profile";
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", "org", "asciidoc", "man", "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"],
"dot" => &["dot-backend", "export-svg", "width"],
"deps" => &[
"metadata",
"why",
"graph",
"depth",
"duplicates",
"no-dev",
"features",
"package",
"timings",
"audit",
"licenses",
"sizes",
"build-profile",
"source-sizes",
"top",
"baseline",
"threshold",
"save",
"policy",
"sbom",
"width",
],
"schema" => &["er", "width"],
"chart" => &["kind", "x", "y", "width"],
"profile" => &[
"sample",
"columns",
"top",
"depth",
"max-paths",
"flat",
"style-rule",
"rules",
"report",
"width",
],
"env" => &["show-secrets", "width"],
"capture" => &[
"cast",
"redact",
"redact-pattern",
"redact-preview",
"sanitize",
"width",
],
"log" => &[
"log-presentation",
"level",
"where",
"follow",
"collapse",
"group",
"fields",
"parser",
"time",
"output",
"summary",
"interactive",
"redact",
"redact-pattern",
"redact-preview",
"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",
"conflicts",
"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;
if name == "deps" && long != &"width" {
arg.heading = Some(SOURCES.into());
}
if name == "profile" && *long == "depth" {
arg = arg.heading(PROFILE).help(
"With `rich profile` of JSON Lines, profile paths at most N steps deep \
(default 4), a key or [] each; an object or array at the last step \
counts as its JSON text",
);
}
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(
"org",
None,
"Render RESOURCE as an Org document (headlines, TODO keywords, lists, blocks, \
tables, drawers)",
),
mode(
"asciidoc",
None,
"Render RESOURCE as AsciiDoc (sections, attributes, lists, source blocks with \
callouts, tables, admonitions)",
)
.alias("adoc"),
mode(
"man",
None,
"Render RESOURCE as a man page from its roff source (man and mdoc macros, tbl \
tables)",
)
.alias("roff"),
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 log records: JSON Lines, logfmt and common text formats, filtered, followed \
and grouped",
),
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",
),
flag(
"conflicts",
DIFF,
"With --diff, show the merge conflicts left in one FILE: numbered, ours, base \
and theirs side by side when the width allows, with context; exits 0 either way \
and 4 when the markers do not parse",
),
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|BY",
VIEWERS,
"With `rich hex`, bytes per group (default 8); with `rich log`, group records by \
trace, span or correlation id (trace_id, span_id, correlation_id, request_id, ...) \
under a header each, with a summary at the end",
),
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` and `rich log`, also mask matches of REGEX (only its `secret` \
group when it has one); repeatable. Experimental: check the output before sharing \
it",
)
.multiple(true),
flag(
"redact-preview",
VIEWERS,
"With `rich capture` or `rich log` and --redact or --redact-pattern, show each \
match on its line, highlighted, with its rule and replacement, and a summary, \
instead of the output; write nothing (no cast, export or log output) and name on \
stderr what would have been written. The preview shows the secrets unmasked. With \
--report json, the findings as one JSON document on stdout",
),
]
}
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, likely Markdown, reStructuredText, Org, AsciiDoc and man pages to \
their renderers, anything else to plain text), and a named format overrides the \
extension. A document format (markdown, rst, org, asciidoc, man) is the same as its \
mode flag",
)
.choices([
"auto", "json", "yaml", "toml", "xml", "ini", "env", "markdown", "rst", "org", "asciidoc",
"man",
])
.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, \
--org, --asciidoc or --man, 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",
),
flag(
"infer",
SOURCE,
"With --csv (or a .csv/.tsv file), infer each column's type (integer, float, \
boolean, date, timestamp, text) and show it under the heading; number columns \
right-align even with null tokens such as NA. Without it the table is rich-cli's",
),
]
}
fn log_options() -> Vec<ArgSpec> {
vec![
option(
"level",
"LEVEL",
LOG,
"With `rich log`, keep only records at LEVEL (trace, debug, info, warn, error or \
fatal), or at it and above with a `+` (`warn+`); several with commas or by \
repeating it",
)
.multiple(true),
option(
"where",
"EXPR",
LOG,
"With `rich log`, keep only records whose fields pass EXPR: FIELD=VALUE, \
FIELD!=VALUE, FIELD>N (and >=, <, <=), FIELD~REGEX, FIELD!~REGEX, FIELD (it is \
there) or !FIELD (it is not); numbers, levels and times compare as such, dots \
reach nested fields; repeatable, and every one must pass",
)
.multiple(true),
flag(
"follow",
LOG,
"With `rich log`, read FILE, then what is appended to it, starting again when it \
is truncated or replaced (log rotation), until Ctrl+C",
)
.short('f'),
flag(
"collapse",
LOG,
"With `rich log`, fold a run of identical records (their times aside) into the \
first and a `× N` count; on by default with --follow and --interactive",
)
.alias("no-collapse"),
flag(
"fields",
LOG,
"With `rich log`, list each record's fields under it, one a line, instead of on \
its line",
),
option(
"parser",
"NAME",
LOG,
"With `rich log`, how lines that are not JSON are read: auto tries each format; \
logfmt, level (`LEVEL message`), timestamp (a line that starts with a time) and \
access (nginx and Apache) read one; text reads none. A line no parser reads \
joins the record before it, or is shown as text",
)
.choices(["auto", "logfmt", "level", "timestamp", "access", "text"])
.default_value("auto"),
option(
"time",
"MODE",
LOG,
"With `rich log`, show each time as written (absolute), since the first record \
shown (relative), or not at all (none)",
)
.choices(["absolute", "relative", "none"])
.default_value("absolute"),
option(
"output",
"FORMAT",
LOG,
"With `rich log`, write the records kept as text, or as one JSON object a line \
(jsonl) for another program",
)
.choices(["text", "jsonl"])
.default_value("text"),
flag(
"summary",
LOG,
"With `rich log`, end with a summary: records shown, counts by level and, with \
--group, the groups with errors; on by default with --group (on standard error \
with --output jsonl)",
)
.alias("no-summary"),
flag(
"interactive",
LOG,
"With `rich log` on a terminal, show the records in a live view that follows the \
end: / filters, Space pauses, Enter shows a record's fields, q quits; without a \
terminal the records stream as usual",
)
.short('i'),
]
}
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, sequence and ER diagrams 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(
"dot-backend",
"BACKEND",
MODE_OPTIONS,
"How DOT graphs (`rich dot`, ```dot fences in Markdown) are drawn: text draws them \
natively, graphviz also makes --export-svg write Graphviz's own SVG (the `dot` \
program, installed separately), off leaves fences as code",
)
.choices(["text", "graphviz", "off"])
.config_key("dot_backend"),
option(
"metadata",
"FILE",
SOURCES,
"With `rich deps`, read `cargo metadata --format-version 1` JSON from FILE (`-` for \
stdin) instead of running cargo",
),
option(
"why",
"CRATE",
SOURCES,
"With `rich deps`, show what pulls CRATE (`name` or `name@version`) in: every path \
from the workspace to it",
),
flag(
"graph",
SOURCES,
"With `rich deps`, draw the dependencies (or --why's paths) as a diagram instead of \
a tree",
),
option(
"depth",
"N",
SOURCES,
"With `rich deps`, show at most N levels below each workspace member; with `rich \
profile`, profile JSON paths at most N steps deep (default 4), an object or array \
at the last step counting as its JSON text",
),
flag(
"duplicates",
SOURCES,
"With `rich deps`, keep only the branches that lead to a crate resolved at more than \
one version, and add a consolidation summary: who pulls each version, and which \
version most of them already use",
),
flag(
"no-dev",
SOURCES,
"With `rich deps`, leave dev-dependencies out",
),
flag(
"features",
SOURCES,
"With `rich deps`, show the features Cargo resolved for each crate: what each turns \
on, and which dependents asked for them; every crate with a feature, or only \
--package's",
),
option(
"package",
"CRATE",
SOURCES,
"With `rich deps --features`, show only CRATE, as `name` or `name@version`",
),
option(
"timings",
"FILE",
SOURCES,
"With `rich deps`, show a `cargo build --timings` report (the HTML file, or \
timing-info JSON lines; `-` for stdin): the slowest units as bars and a table; \
runs nothing",
),
option(
"audit",
"FILE",
SOURCES,
"With `rich deps`, show `cargo audit --json` output from FILE (`-` for stdin) grouped \
by severity; exits 5 when it lists a vulnerability. Runs no scanner",
),
flag(
"licenses",
SOURCES,
"With `rich deps`, group the crates by licence, marking copyleft, unknown and missing \
licences",
),
flag(
"sizes",
SOURCES,
"With `rich deps`, show artifact sizes per crate as bars and a table: from the target \
directory given next (default: the one `cargo metadata` names), matched to packages \
through `cargo metadata`, or from a file given next: `cargo bloat --crates \
--message-format json` output or a report saved with --save (`-` for stdin)",
),
option(
"build-profile",
"NAME",
SOURCES,
"With `rich deps --sizes`, read this profile's artifacts: debug (dev), release, or a \
custom profile (default: debug if it was built, else release)",
),
flag(
"source-sizes",
SOURCES,
"With `rich deps --sizes`, also add up each package's source files (its manifest \
directory, without target/, .git/ and nested packages)",
),
option(
"baseline",
"FILE",
SOURCES,
"With `rich deps --timings` or `--sizes`, compare with an older report, unit by unit \
(a report saved with --save, or the original format: a timings report, cargo-bloat \
JSON or a target directory); exits 5 when a unit or the total regressed past \
--threshold",
),
option(
"save",
"FILE",
SOURCES,
"With `rich deps --timings` or `--sizes`, also write the report as JSON to FILE, to \
keep as a --baseline",
),
option(
"policy",
"FILE",
SOURCES,
"With `rich deps`, check every crate's licence against a policy: a TOML allow and deny \
list, or cargo-deny's deny.toml; conflicts with the root crate's licence are shown \
too (a heuristic, not legal advice). Exits 5 on a violation",
),
option(
"sbom",
"FILE",
SOURCES,
"With `rich deps --licenses` or `--policy`, read the packages from an SPDX (2.2, 2.3) \
or CycloneDX (1.4 to 1.6) JSON document instead of `cargo metadata`",
),
flag(
"er",
SOURCES,
"With `rich schema`, draw the schema as an ER diagram: a box per table with its \
columns and keys, an edge per foreign key",
),
option(
"kind",
"KIND",
CHART,
"With `rich chart`, what to draw: spark (a sparkline per series), bar (a bar per \
row, labelled by --x), line (the default) and scatter (each series against --x, or \
the row number), heatmap (rows by series, each value a shade), pie (one series as \
slices, labelled by --x) or stacked (a bar per row, each series a part)",
)
.choices(["spark", "bar", "line", "scatter", "heatmap", "pie", "stacked"]),
option(
"x",
"COLUMN",
CHART,
"With `rich chart`, the column of x positions (line, scatter) or labels (bar, \
heatmap, pie, stacked), by header or 1-based number (default: the row number; for labels, the \
first column that is not all numbers)",
),
option(
"y",
"COLUMN",
CHART,
"With `rich chart`, a column to draw, by header or 1-based number; repeatable, one \
series each (default: every numeric column but --x)",
)
.multiple(true),
option(
"sample",
"N",
PROFILE,
"With `rich profile`, the most rows kept for types, statistics and distributions \
(default 10000): a longer input is sampled uniformly, the sample size is printed, \
and nulls still count every row",
),
option(
"columns",
"COLUMN[,COLUMN...]",
PROFILE,
"With `rich profile`, profile only these columns, by header, in this order; \
repeatable",
)
.multiple(true),
option(
"top",
"N",
PROFILE,
"With `rich profile`, the most common values shown for a column that is not \
numeric (default 5); with `rich deps --sizes`, `--timings` or `--baseline`, the \
units shown (default 20)",
),
option(
"max-paths",
"N",
PROFILE,
"With `rich profile` of JSON Lines, profile at most N paths (default 200), in the \
order first seen; the rest are noted",
),
flag(
"flat",
PROFILE,
"With `rich profile` of JSON Lines, profile rows of the top-level keys (nested \
values as JSON text) instead of every nested path",
),
option(
"style-rule",
"RULE",
PROFILE,
"Style the cells, rows or columns of a data table by value: COLUMN OP VALUE:STYLE \
or COLUMN OP VALUE:row:STYLE (e.g. 'latency>500:bold red', \
'status=failed:row:on red'); OP is == != < <= > >= *= ^= $= or a word such as \
contains or empty. Repeatable; applies to --csv, `rich profile`'s table and \
`rich inspect --table`, and has no effect elsewhere",
)
.multiple(true)
.config_key("rules"),
option(
"rules",
"FILE",
PROFILE,
"Read style rules from FILE's [[rules]] tables (column, op, value, style, target), \
after the rules before it",
)
.value(ValueHint::Path)
.multiple(true),
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)",
),
option(
"speed",
"X",
MODE_OPTIONS,
"With --gif, play X times as fast (0.5 = half speed; default 1)",
),
option(
"fps",
"N",
MODE_OPTIONS,
"With --gif, draw at most N frames a second (default 30)",
),
option(
"max-frames",
"N",
MODE_OPTIONS,
"With --gif, stop each animation after N frames, even with --loop 0",
),
option(
"max-duration",
"SECS",
MODE_OPTIONS,
"With --gif, stop after SECS seconds, whatever is still playing",
),
];
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. With `rich deps --baseline`, what \
counts as a regression: a percentage (default 10%) and a floor the change must \
also pass, a time (`0.5s`, the default for --timings) or a size (`64KiB`, the \
default for --sizes); give it twice for both",
),
]
}
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; with `rich log`, the same in each \
record's fields and lines. 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"),
option(
"export-png",
"PATH",
EXPORT,
"Also write a PNG image of the output to PATH: the terminal's width, rich's SVG \
export theme, in a window titled like the SVG's, drawn with an embedded DejaVu \
Sans Mono so it is the same on every machine (the png feature; not with --batch)",
)
.value(ValueHint::File),
]
}
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; the default wasm-plugins \
feature) or a native library (.so, .dylib, .dll, which runs its own code; a build \
with the 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`, `rich schema`, `rich log` \
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(),
log_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, \
.mmd/.mermaid and .dot/.gv by extension, and .org, .adoc/.asciidoc and \
.man/.roff/.mdoc documents; a man page's section extension (.1 to .9) or .asc \
counts when the content agrees, and a file with no extension that is plainly \
Org, AsciiDoc or roff renders as one — 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`)"),
("org", &[][..], "Render an Org document (`--org`)"),
("asciidoc", &["adoc"][..], "Render AsciiDoc (`--asciidoc`)"),
(
"man",
&["roff"][..],
"Render a man page from its roff source (`--man`)",
),
(
"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 log records (`--log`): JSON Lines, logfmt and common text formats; filter \
with --level and --where, follow a file with -f, group with --group",
),
("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, sequence and ER diagrams as text, every type \
through mmdc where built in \
(.mmd and .mermaid files are detected)",
),
(
"dot",
&["graphviz"][..],
"Draw a DOT (Graphviz) graph as text (.dot and .gv files are detected); what the \
native parser does not support is refused with its line",
),
] {
spec = spec.subcommand(mode_command(name, aliases, about));
}
spec = spec.subcommand(
CommandSpec::new("deps")
.about(
"A Cargo dependency tree from `cargo metadata`, crates resolved at several \
versions marked; --why CRATE for what pulls a crate in, --graph to draw it; \
--features, --licenses, --policy FILE, --timings FILE, --sizes and --audit FILE \
for supply-chain reports, --baseline FILE to compare timings or sizes",
)
.usage("deps [OPTIONS] [MANIFEST]")
.arg(ArgSpec::positional("manifest").value(ValueHint::Path).help(
"A Cargo.toml, or the directory holding one (default: the working directory)",
))
.args(command_options("deps")),
);
spec = spec.subcommand(
CommandSpec::new("schema")
.about(
"A JSON Schema, SQL DDL or Arrow schema as a tree: types, required markers, \
constraints, keys, $refs resolved; with two schemas (of any of these formats), \
what changed between them and which changes break; --er draws an ER diagram",
)
.usage("schema [OPTIONS] SCHEMA [NEW_SCHEMA]")
.arg(ArgSpec::positional("schema").value(ValueHint::Path).help(
"A JSON Schema, SQL DDL (.sql, .ddl, or text starting with CREATE) or \
Arrow IPC file (.arrow, .feather, .arrows, .ipc; the `arrow` feature), \
an http(s) URL, or `-` for stdin",
))
.arg(
ArgSpec::positional("new_schema")
.value(ValueHint::Path)
.help("A second version: show what changed from SCHEMA to it"),
)
.args(command_options("schema")),
);
spec = spec.subcommand(
CommandSpec::new("chart")
.about(
"A chart from CSV, JSON or stdin: a sparkline, bars, lines, points, a heatmap, a \
pie or stacked bars; --x and --y pick the columns, a missing column or a value \
that is not a number is refused with its row and column",
)
.usage("chart [OPTIONS] [FILE]")
.arg(ArgSpec::positional("file").value(ValueHint::Path).help(
"CSV, TSV, JSON, JSON Lines or whitespace-separated numbers, an http(s) URL, or \
`-` for stdin (the default)",
))
.args(command_options("chart")),
);
spec = spec.subcommand(
CommandSpec::new("profile")
.about(
"A profile of CSV, TSV or JSON Lines: each column's type, nulls, distinct values, \
statistics and distribution, and a map of where the nulls are; a long input is \
sampled. --report json writes the profile as JSON to stdout",
)
.usage("profile [OPTIONS] [FILE]")
.arg(ArgSpec::positional("file").value(ValueHint::Path).help(
"CSV, TSV or JSON Lines (by extension, else by the first character), an http(s) \
URL, or `-` for stdin (the default)",
))
.args(command_options("profile")),
);
spec = spec.subcommand(config_command());
#[cfg(feature = "interact")]
for command in super::interactive::commands() {
spec = spec.subcommand(command);
}
#[cfg(feature = "interact")]
for command in super::approve_jump::commands() {
spec = spec.subcommand(command);
}
for command in authoring_commands() {
spec = spec.subcommand(command);
}
#[cfg(feature = "art")]
{
spec = spec.subcommand(super::micro::command());
spec = spec.subcommand(super::banner::command());
}
spec = spec.subcommand(super::shell::command());
spec = spec.subcommand(super::spin::command());
#[cfg(feature = "serve")]
{
spec = spec.subcommand(super::serve::command());
}
spec.subcommand(
CommandSpec::new("record")
.about(
"Run tapes (scripted terminal sessions: type, press and hold keys, wait for \
screen text, take screenshots) in a real terminal and write PNG, SVG and text \
screenshots, an asciinema cast, a GIF, an animated PNG (when asked), an MP4 (with \
FFmpeg) and an HTML page with a player; video has every screen update at the tape's frame \
rate, and Kitty, Sixel and iTerm2 images; --check compares screenshots with \
committed ones instead",
)
.usage(
"record [--check] [--output DIR] [--bin-dir DIR] \
[--format png,svg,cast,gif,apng,mp4,html] [--no-video] [--fps N] [--font FILE] \
[--window-frame on|off] [--caption TEXT] [--key-overlay on|off] TAPE...",
)
.section(
"Tapes",
"- Set Framerate N: video frames a second, 1 to 60 (12); --fps overrides it\n\
- Set FontSize N: the text size of video frames, in pixels (16)\n\
- KeyDown KEY, KeyUp KEY: hold a key (Up, Ctrl+F, or one character) down, let \
it go; it autorepeats meanwhile (Set KeyRepeat DELAY INTERVAL, 200ms 33ms)\n\
- Hold KEY DURATION: KeyDown, wait, KeyUp\n\
- Set KittyKeyboard on: report the kitty keyboard protocol, so a program can \
turn it on and hear releases\n\
- Output gif png: write only these formats; without an Output line, every \
format but the APNG\n\
- Output apng (or --format apng, or all): an animated PNG beside (or instead \
of) the GIF; it is written only when asked",
),
)
.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(super::marketplace_spec())
.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, date, plugin install and update)\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, date)",
)
}
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",
"rules" => "array of rule tables",
_ => 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");
const SOURCES_RS: &str = include_str!("sources.rs");
const CHART_RS: &str = include_str!("chart.rs");
const PROFILE_RS: &str = include_str!("profile.rs");
const LOGS_RS: &str = include_str!("logs.rs");
const STYLE_RULES_RS: &str = include_str!("style_rules.rs");
#[cfg(feature = "interact")]
const INTERACTIVE: &str = include_str!("interactive.rs");
#[cfg(feature = "interact")]
const APPROVE_JUMP: &str = include_str!("approve_jump.rs");
#[cfg(feature = "art")]
const MICRO: &str = include_str!("micro.rs");
#[cfg(feature = "serve")]
const SERVE: &str = include_str!("serve.rs");
#[cfg(feature = "art")]
const BANNER: &str = include_str!("banner.rs");
const SHELL: &str = include_str!("shell.rs");
const SPIN: &str = include_str!("spin.rs");
const MARKETPLACE: &str = include_str!("marketplace.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 {"),
item(SOURCES_RS, "impl GraphSourceOptions {"),
item(SOURCES_RS, "impl SchemaOptions {"),
item(CHART_RS, "impl ChartOptions {"),
item(PROFILE_RS, "impl ProfileOptions {"),
item(LOGS_RS, "impl LogOptions {"),
item(STYLE_RULES_RS, "impl RuleOptions {"),
] {
out.extend(option_literals(source));
}
#[cfg(feature = "interact")]
out.extend(option_literals(item(INTERACTIVE, "fn parse_args(")));
#[cfg(feature = "interact")]
out.extend(option_literals(item(APPROVE_JUMP, "fn parse_args(")));
#[cfg(feature = "serve")]
out.extend(option_literals(item(SERVE, "fn parse_args(")));
#[cfg(feature = "art")]
out.extend(option_literals(item(BANNER, "fn parse_args(")));
out.extend(option_literals(item(SHELL, "fn parse_args(")));
out.extend(option_literals(item(SPIN, "fn parse_args(")));
out.extend(option_literals(item(MARKETPLACE, "fn parse(")));
out.extend(option_literals(item(MARKETPLACE, "const PUBLISH_OPTIONS")));
#[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",
"shell",
"spin",
] {
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}");
}
}
}