1use std::io::{BufRead, IsTerminal, Read, Write};
34use std::path::{Path, PathBuf};
35use std::process::ExitCode;
36
37mod animation;
38#[cfg(feature = "interact")]
39mod approve_jump;
40mod authoring;
41#[cfg(feature = "art")]
42mod banner;
43mod batch;
44mod batch_output;
45mod batch_paths;
46#[cfg(test)]
47mod batch_races;
48mod chart;
49mod cli_spec;
50mod config;
51mod controls;
52mod demo;
53mod doctor;
54mod documents;
55mod inspect;
56#[cfg(feature = "interact")]
57mod interactive;
58mod logs;
59#[cfg(feature = "art")]
60mod micro;
61mod plugins;
62mod profile;
63#[cfg(feature = "record")]
64mod record;
65mod redact_preview;
66mod render_target;
67#[cfg(feature = "serve")]
68mod serve;
69mod shell;
70mod sources;
71mod structured_log;
72mod style_rules;
73mod tools;
74mod viewers;
75mod watch;
76use batch::run_batch;
77use config::{config_args, ConfigRoots};
78
79use rich::cells::cell_len;
80use rich::markdown::Markdown;
81use rich::measure::Measurement;
82use rich::protocol::{LineRenderable, OwnedTableRows};
83use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
84use rich::text::Text;
85use rich::{
86 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
87 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
88 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
89 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
90 DEFAULT_TERMINAL_THEME,
91};
92use rich_data::csv::{has_header, read_rows, sniff, Dialect};
93use rich_data::infer::{InferredType, Inferrer};
94use rich_ext::cli::CliExtensions;
95use rich_ext::encoding::{has_utf16_bom, Encoding};
96use rich_ext::sanitize_terminal_controls;
97use rich_ext::ConsoleExt;
98
99fn text(content: &str) -> Box<dyn Renderable> {
101 Box::new(Text::new(content))
102}
103
104const VERSION: &str = env!("CARGO_PKG_VERSION");
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum Mode {
109 Auto,
111 Print,
113 Markdown,
115 Json,
117 Syntax,
119 Csv,
121 Ipynb,
123 Rst,
125 Org,
127 AsciiDoc,
129 Man,
132 Gif,
134 Diff,
136 Image,
138 Rule,
140 JsonLines,
142 Log,
144 Inspect,
146 AnsiExplain,
148 View,
151 Hex,
153 Unicode,
155 Env,
157 Capture,
159 Mermaid,
161 Dot,
163 Deps,
165 Schema,
168 Chart,
170 Profile,
172}
173
174impl Mode {
175 fn streams_records(self) -> bool {
176 matches!(self, Self::JsonLines | Self::Log)
177 }
178
179 fn draws_directly(self) -> bool {
180 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
181 }
182
183 fn allows_extensions(self) -> bool {
184 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
185 && !self.streams_records()
186 }
187
188 fn accepts_multiple_resources(self) -> bool {
189 matches!(
190 self,
191 Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
192 )
193 }
194}
195
196struct ModeSpec {
197 mode: Mode,
198 primary: &'static str,
199 aliases: &'static [&'static str],
200}
201
202const MODE_SPECS: &[ModeSpec] = &[
203 ModeSpec {
204 mode: Mode::Auto,
205 primary: "auto",
206 aliases: &[],
207 },
208 ModeSpec {
209 mode: Mode::Print,
210 primary: "print",
211 aliases: &["print"],
212 },
213 ModeSpec {
214 mode: Mode::Markdown,
215 primary: "markdown",
216 aliases: &["markdown", "md"],
217 },
218 ModeSpec {
219 mode: Mode::Json,
220 primary: "json",
221 aliases: &["json"],
222 },
223 ModeSpec {
224 mode: Mode::Syntax,
225 primary: "syntax",
226 aliases: &["syntax", "code"],
227 },
228 ModeSpec {
229 mode: Mode::Csv,
230 primary: "csv",
231 aliases: &["csv", "tsv"],
232 },
233 ModeSpec {
234 mode: Mode::Ipynb,
235 primary: "ipynb",
236 aliases: &["ipynb", "notebook"],
237 },
238 ModeSpec {
239 mode: Mode::Rst,
240 primary: "rst",
241 aliases: &["rst"],
242 },
243 ModeSpec {
244 mode: Mode::Org,
245 primary: "org",
246 aliases: &["org"],
247 },
248 ModeSpec {
249 mode: Mode::AsciiDoc,
250 primary: "asciidoc",
251 aliases: &["asciidoc", "adoc"],
252 },
253 ModeSpec {
254 mode: Mode::Man,
255 primary: "man",
256 aliases: &["man", "roff"],
257 },
258 ModeSpec {
259 mode: Mode::Gif,
260 primary: "gif",
261 aliases: &["gif"],
262 },
263 ModeSpec {
264 mode: Mode::Diff,
265 primary: "diff",
266 aliases: &["diff"],
267 },
268 ModeSpec {
269 mode: Mode::Image,
270 primary: "image",
271 aliases: &["image"],
272 },
273 ModeSpec {
274 mode: Mode::Rule,
275 primary: "rule",
276 aliases: &["rule"],
277 },
278 ModeSpec {
279 mode: Mode::JsonLines,
280 primary: "jsonl",
281 aliases: &["jsonl", "ndjson"],
282 },
283 ModeSpec {
284 mode: Mode::Log,
285 primary: "log",
286 aliases: &["log", "logs"],
287 },
288 ModeSpec {
289 mode: Mode::Inspect,
290 primary: "inspect",
291 aliases: &["inspect"],
292 },
293 ModeSpec {
294 mode: Mode::AnsiExplain,
295 primary: "ansi",
296 aliases: &["ansi", "ansi-explain"],
297 },
298 ModeSpec {
299 mode: Mode::View,
300 primary: "view",
301 aliases: &["view"],
302 },
303 ModeSpec {
304 mode: Mode::Hex,
305 primary: "hex",
306 aliases: &["hex", "hexdump"],
307 },
308 ModeSpec {
309 mode: Mode::Unicode,
310 primary: "unicode",
311 aliases: &["unicode"],
312 },
313 ModeSpec {
314 mode: Mode::Env,
315 primary: "env",
316 aliases: &["env"],
317 },
318 ModeSpec {
319 mode: Mode::Capture,
320 primary: "capture",
321 aliases: &["capture"],
322 },
323 ModeSpec {
324 mode: Mode::Mermaid,
325 primary: "mermaid",
326 aliases: &["mermaid", "mmd"],
327 },
328 ModeSpec {
329 mode: Mode::Dot,
330 primary: "dot",
331 aliases: &["dot", "graphviz"],
332 },
333 ModeSpec {
334 mode: Mode::Deps,
335 primary: "deps",
336 aliases: &["deps"],
337 },
338 ModeSpec {
339 mode: Mode::Schema,
340 primary: "schema",
341 aliases: &["schema"],
342 },
343 ModeSpec {
344 mode: Mode::Chart,
345 primary: "chart",
346 aliases: &["chart"],
347 },
348 ModeSpec {
349 mode: Mode::Profile,
350 primary: "profile",
351 aliases: &["profile"],
352 },
353];
354
355const RENDER_MODE_FLAGS: &str =
356 "--print/--markdown/--rst/--org/--asciidoc/--man/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359enum ReportFormat {
360 Human,
361 Json,
362}
363
364impl ReportFormat {
365 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
366 match option {
367 "--report" => {
368 *self = match value.ok_or("--report requires one of: human, json")? {
369 "human" => Self::Human,
370 "json" => Self::Json,
371 other => return Err(format!("unknown report format {other:?} (human, json)")),
372 };
373 }
374 "--machine-json" => *self = Self::Json,
375 _ => {}
376 }
377 Ok(())
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382enum ExitClass {
383 Success,
384 Usage,
385 Input,
386 Data,
387 #[cfg_attr(not(feature = "art"), allow(dead_code))]
388 Gate,
389}
390
391impl ExitClass {
392 fn code(self) -> u8 {
393 match self {
394 Self::Success => 0,
395 Self::Usage => 2,
396 Self::Input => 3,
397 Self::Data => 4,
398 Self::Gate => 5,
399 }
400 }
401
402 fn name(self) -> &'static str {
403 match self {
404 Self::Success => "success",
405 Self::Usage => "usage",
406 Self::Input => "input",
407 Self::Data => "data",
408 Self::Gate => "gate",
409 }
410 }
411
412 fn exit_code(self) -> ExitCode {
413 ExitCode::from(self.code())
414 }
415}
416
417#[derive(Clone, Copy, PartialEq, Eq, Debug)]
424enum ImageMode {
425 Auto,
427 Sixel,
429 Blocks,
431 Braille,
433 Quadrants,
435 Ascii,
437 None,
439}
440
441impl std::str::FromStr for ImageMode {
442 type Err = String;
443
444 fn from_str(value: &str) -> Result<Self, Self::Err> {
445 match value.to_ascii_lowercase().as_str() {
446 "auto" => Ok(Self::Auto),
447 "sixel" => Ok(Self::Sixel),
448 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
449 "braille" => Ok(Self::Braille),
450 "quadrants" | "quadrant" => Ok(Self::Quadrants),
451 "ascii" | "art" => Ok(Self::Ascii),
452 "none" | "off" => Ok(Self::None),
453 other => Err(format!(
454 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
455 )),
456 }
457 }
458}
459
460#[derive(Clone, Debug, Default, PartialEq)]
464struct SourceOptions {
465 head: Option<usize>,
467 tail: Option<usize>,
468 line_numbers: bool,
470 guides: bool,
472 lexer: Option<String>,
474 no_wrap: bool,
476 infer: bool,
480}
481
482impl SourceOptions {
483 fn wants_syntax(&self) -> bool {
485 self.head.is_some()
486 || self.tail.is_some()
487 || self.line_numbers
488 || self.guides
489 || self.lexer.is_some()
490 || self.no_wrap
491 }
492
493 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
498 let lines = python_line_count(code) as i64;
499 match (self.head, self.tail) {
500 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
501 (Some(head), None) => Ok(Some((1, head as i64))),
502 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
503 (None, None) => Ok(None),
504 }
505 }
506
507 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
509 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
510 .line_numbers(self.line_numbers)
511 .indent_guides(self.guides)
512 .word_wrap(!self.no_wrap);
513 if let Some((start, end)) = self.line_range(code)? {
514 syntax = syntax.line_range(Some(start), Some(end));
515 }
516 Ok(syntax)
517 }
518}
519
520fn python_line_count(text: &str) -> usize {
524 let mut count = 0;
525 let mut chars = text.chars().peekable();
526 let mut open = false;
527 while let Some(c) = chars.next() {
528 match c {
529 '\r' => {
530 if chars.peek() == Some(&'\n') {
531 chars.next();
532 }
533 count += 1;
534 open = false;
535 }
536 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
537 | '\u{2029}' => {
538 count += 1;
539 open = false;
540 }
541 _ => open = true,
542 }
543 }
544 count + usize::from(open)
545}
546
547#[derive(Clone)]
549struct Cli {
550 mode: Mode,
551 resource: Option<String>,
552 #[cfg_attr(not(feature = "art"), allow(dead_code))]
556 resources: Vec<String>,
557 #[cfg_attr(not(feature = "art"), allow(dead_code))]
559 loops: Option<usize>,
560 #[cfg_attr(not(feature = "art"), allow(dead_code))]
562 animation: animation::AnimationControls,
563 #[cfg_attr(not(feature = "art"), allow(dead_code))]
566 diff_threshold: Option<f32>,
567 #[cfg_attr(not(feature = "art"), allow(dead_code))]
569 image_mode: ImageMode,
570 #[cfg_attr(not(feature = "art"), allow(dead_code))]
571 image_fit: Option<String>,
572 #[cfg_attr(not(feature = "art"), allow(dead_code))]
573 image_anchor: Option<String>,
574 #[cfg_attr(not(feature = "art"), allow(dead_code))]
575 image_background: Option<ImageBackdrop>,
576 #[cfg_attr(not(feature = "art"), allow(dead_code))]
577 image_color: Option<String>,
578 #[cfg_attr(not(feature = "art"), allow(dead_code))]
579 image_dither: Option<String>,
580 #[cfg_attr(not(feature = "art"), allow(dead_code))]
581 image_color_distance: Option<String>,
582 #[cfg_attr(not(feature = "art"), allow(dead_code))]
583 image_rotate: Option<u16>,
584 #[cfg_attr(not(feature = "art"), allow(dead_code))]
585 image_flip_horizontal: bool,
586 #[cfg_attr(not(feature = "art"), allow(dead_code))]
587 image_flip_vertical: bool,
588 #[cfg_attr(not(feature = "art"), allow(dead_code))]
589 image_grayscale: bool,
590 #[cfg_attr(not(feature = "art"), allow(dead_code))]
591 image_max_width: Option<usize>,
592 #[cfg_attr(not(feature = "art"), allow(dead_code))]
593 image_max_height: Option<usize>,
594 #[cfg_attr(not(feature = "art"), allow(dead_code))]
596 image_brightness: Option<f32>,
597 #[cfg_attr(not(feature = "art"), allow(dead_code))]
598 image_contrast: Option<f32>,
599 #[cfg_attr(not(feature = "art"), allow(dead_code))]
600 image_gamma: Option<f32>,
601 log_presentation: String,
602 mermaid_backend: Option<MermaidBackend>,
605 dot_backend: Option<sources::DotBackend>,
608 graph_sources: sources::GraphSourceOptions,
610 chart: chart::ChartOptions,
612 schema: sources::SchemaOptions,
614 profile: profile::ProfileOptions,
616 rules: style_rules::RuleOptions,
619 highlighter: Option<String>,
621 code_theme: Option<String>,
623 plugins: Vec<String>,
626 #[cfg_attr(not(feature = "art"), allow(dead_code))]
629 micro_project: bool,
630 theme_styles: std::collections::BTreeMap<String, Style>,
631 theme_file_styles: std::collections::BTreeMap<String, Style>,
634 #[cfg_attr(not(feature = "art"), allow(dead_code))]
637 height: Option<usize>,
638 extensions: CliExtensions,
639 width: Option<usize>,
640 justify: Option<Justify>,
641 text_justify: Option<Justify>,
645 emoji: bool,
648 soft: bool,
650 max_width: Option<usize>,
652 rule_style: Option<Style>,
654 rule_char: Option<String>,
655 force_terminal: bool,
657 source: SourceOptions,
659 no_color: bool,
660 export_html: Option<String>,
663 export_svg: Option<String>,
665 export_png: Option<String>,
668 panel: Option<BoxSet>,
672 padding: Option<(usize, usize, usize, usize)>,
674 title: Option<String>,
676 caption: Option<String>,
678 panel_style: Option<Style>,
680 style: Option<Style>,
683 expand: bool,
686 pager: bool,
688 auto_pager: bool,
689 hyperlinks: bool,
693 sanitize: bool,
695 report_format: ReportFormat,
698 watch: bool,
700 watch_interval: f64,
702 watch_cache: bool,
704 watch_debounce: f64,
707 watch_poll: bool,
709 watch_exit_on_error: bool,
711 batch: bool,
712 progress: bool,
713 continue_on_error: bool,
714 jobs: usize,
715 dry_run: bool,
716 batch_paths: batch_paths::BatchPathOptions,
717 worker_args: Vec<String>,
718 overwrite: bool,
719 collision: CollisionPolicy,
720 data: inspect::DataOptions,
721 tools: tools::ToolOptions,
722 viewers: viewers::ViewerOptions,
723 logs: logs::LogOptions,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729enum MermaidBackend {
730 Text,
732 Mmdc,
735 Off,
737}
738
739impl MermaidBackend {
740 fn parse(value: &str) -> Result<Self, String> {
741 match value {
742 "text" => Ok(Self::Text),
743 "off" => Ok(Self::Off),
744 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
745 "mmdc" => Err(
746 "--mermaid-backend mmdc needs a build with the mmdc feature \
747 (cargo install rs-rich-cli --features mmdc)"
748 .into(),
749 ),
750 other => Err(format!(
751 "unknown Mermaid backend {other:?} (text, mmdc, off)"
752 )),
753 }
754 }
755}
756
757#[cfg(feature = "mermaid")]
759fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
760 rich_mermaid::MermaidOptions {
761 backend: match backend {
762 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
763 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
764 },
765 ..rich_mermaid::MermaidOptions::default()
766 }
767}
768
769fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
772 plugin_registry_with(mermaid, true)
773}
774
775fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
778 let mut registry = builtin_registry(mermaid, dot);
779 plugins::add_to(&mut registry);
780 registry
781}
782
783fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
786 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
787 if dot {
788 registry
789 .add_plugin(&rich_diagram::plugin::DotPlugin::default())
790 .expect("the DOT plugin registers cleanly");
791 }
792 #[cfg(feature = "mermaid")]
793 if mermaid != MermaidBackend::Off {
794 registry
795 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
796 .expect("the Mermaid plugin registers cleanly");
797 }
798 #[cfg(not(feature = "mermaid"))]
799 let _ = mermaid;
800 #[cfg(feature = "lumis")]
801 registry
802 .add_plugin(&rich_lumis::LumisPlugin)
803 .expect("the lumis plugin registers cleanly");
804 registry
805}
806
807fn code_highlighting(
811 highlighter: Option<&str>,
812 theme: Option<&str>,
813) -> Result<Option<rich::CodeHighlighting>, String> {
814 if highlighter.is_none() && theme.is_none() {
815 return Ok(None);
816 }
817 let mut registry = plugin_registry(MermaidBackend::Off);
818 let name = highlighter.unwrap_or("syntect");
819 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
820 return Err(match error {
821 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
822 if name == "lumis" && !cfg!(feature = "lumis") =>
823 {
824 format!(
825 "{error} (lumis needs a build with the lumis feature: cargo install \
826 rs-rich-cli --features lumis)"
827 )
828 }
829 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
830 let themes = registry
831 .code_highlighter(name)
832 .map(|engine| engine.themes())
833 .unwrap_or_default();
834 if themes.len() <= 40 {
835 format!("{error}; its themes: {}", themes.join(", "))
836 } else {
837 format!(
838 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
839 lists them all",
840 themes.len(),
841 themes[..12].join(", ")
842 )
843 }
844 }
845 other => other.to_string(),
846 });
847 }
848 Ok(registry.code_highlighting())
849}
850
851fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
853 use rich::ConsoleCodeHighlighting;
854 if let Ok(Some(highlighting)) =
855 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
856 {
857 console.set_code_highlighting(Some(highlighting));
858 }
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862enum CollisionPolicy {
863 Error,
864 Overwrite,
865 Suffix,
866}
867
868fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
880 Markdown::new(source).hyperlinks(hyperlinks)
881}
882
883pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
898 dispatch(remember_raw_args(args))
899}
900
901static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
905 std::sync::Mutex::new(Vec::new());
906
907fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
916 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
917 let args = args
918 .into_iter()
919 .map(|arg| match arg.into_string() {
920 Ok(arg) => arg,
921 Err(arg) => {
922 let spelling = escaped_path(&arg);
923 match raw.iter_mut().find(|(known, _)| *known == spelling) {
924 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
925 Some(_) => {}
926 None => raw.push((spelling.clone(), Some(arg))),
927 }
928 spelling
929 }
930 })
931 .collect();
932 if let Ok(mut slot) = RAW_ARGS.lock() {
933 *slot = raw;
934 }
935 args
936}
937
938pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
942 RAW_ARGS
943 .lock()
944 .ok()
945 .and_then(|raw| {
946 raw.iter()
947 .find(|(spelling, _)| spelling == arg)
948 .and_then(|(_, original)| original.clone())
949 })
950 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
951}
952
953pub(crate) fn text_arg(arg: &str) -> String {
957 if !arg.starts_with('\0') {
958 return arg.to_string();
959 }
960 RAW_ARGS
961 .lock()
962 .ok()
963 .and_then(|raw| {
964 raw.iter()
965 .find(|(spelling, _)| spelling == arg)
966 .and_then(|(_, original)| original.clone())
967 })
968 .map_or_else(
969 || arg.to_string(),
970 |original| original.to_string_lossy().into_owned(),
971 )
972}
973
974pub(crate) fn path_arg(path: &Path) -> String {
982 if let Some(text) = path.to_str() {
983 return text.to_string();
984 }
985 let spelling = escaped_path(path.as_os_str());
986 if let Ok(mut raw) = RAW_ARGS.lock() {
987 let original = path.as_os_str().to_owned();
988 match raw.iter_mut().find(|(known, _)| *known == spelling) {
989 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
990 Some(_) => {}
991 None => raw.push((spelling.clone(), Some(original))),
992 }
993 }
994 spelling
995}
996
997#[cfg(unix)]
1000fn escaped_path(path: &std::ffi::OsStr) -> String {
1001 use std::os::unix::ffi::OsStrExt;
1002 let mut out = String::from('\0');
1003 for chunk in path.as_bytes().utf8_chunks() {
1004 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
1005 for byte in chunk.invalid() {
1006 out.push_str(&format!("\\x{byte:02X}"));
1007 }
1008 }
1009 out
1010}
1011
1012#[cfg(not(unix))]
1013fn escaped_path(path: &std::ffi::OsStr) -> String {
1014 format!("\0{}", path.to_string_lossy())
1015}
1016
1017pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
1027 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
1030 if let Ok(mut slot) = SELF_PROGRAM.lock() {
1031 *slot = program;
1032 }
1033 run(args)
1034}
1035
1036static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
1038
1039static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
1041
1042fn dispatch(args: Vec<String>) -> ExitCode {
1043 plugins::install(Vec::new());
1046 if demo::requested(&args) {
1047 return demo::dispatch(&args);
1048 }
1049 if doctor::requested(&args) {
1050 return doctor::dispatch(&args);
1051 }
1052 if plugins::requested(&args) {
1053 return plugins::dispatch(&args);
1054 }
1055 #[cfg(feature = "art")]
1056 if banner::requested(&args) {
1057 return banner::dispatch(&args);
1058 }
1059 if shell::requested(&args) {
1060 return shell::dispatch(&args);
1061 }
1062 #[cfg(feature = "art")]
1063 if micro::requested(&args) {
1064 return micro::dispatch(&args);
1065 }
1066 #[cfg(feature = "interact")]
1067 if let Some(command) = interactive::requested(&args) {
1068 return interactive::dispatch(command, &args);
1069 }
1070 #[cfg(feature = "interact")]
1071 if let Some(command) = approve_jump::requested(&args) {
1072 return approve_jump::dispatch(command, &args);
1073 }
1074 #[cfg(feature = "record")]
1075 if record::requested(&args) {
1076 return record::dispatch(&args);
1077 }
1078 #[cfg(feature = "serve")]
1079 if serve::requested(&args) {
1080 return serve::dispatch(&args);
1081 }
1082 match parse(&args) {
1083 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
1085 if let Err(message) = plugins::check_linked() {
1086 return fail(&cli, ExitClass::Usage, message);
1087 }
1088 if !cli.plugins.is_empty() {
1089 match plugins::load(&cli.plugins) {
1090 Ok(loaded) => plugins::install(loaded),
1091 Err((class, message)) => return fail(&cli, class, message),
1092 }
1093 }
1094 if let Some(message) = cli
1096 .data
1097 .unknown_transform(&plugin_registry(MermaidBackend::Text))
1098 {
1099 return fail(&cli, ExitClass::Usage, message);
1100 }
1101 if cli.batch {
1102 if let Err(error) = batch::install_interrupt_handler() {
1103 return fail(
1104 &cli,
1105 ExitClass::Input,
1106 format!("cannot install batch interruption handler: {error}"),
1107 );
1108 }
1109 }
1110 run_cli(cli)
1111 }
1112 Err(message) => emit_error(
1113 wants_json_report(&args),
1114 ExitClass::Usage,
1115 &format!("{message} (try --help)"),
1116 ),
1117 }
1118}
1119
1120fn self_command() -> std::io::Result<std::process::Command> {
1124 let program = SELF_PROGRAM
1125 .lock()
1126 .map(|program| program.clone())
1127 .unwrap_or_default();
1128 Ok(match program.split_first() {
1129 Some((executable, rest)) => {
1130 let mut command = std::process::Command::new(executable);
1131 command.args(rest);
1132 command
1133 }
1134 None => std::process::Command::new(std::env::current_exe()?),
1135 })
1136}
1137
1138const VALUE_OPTIONS: &[&str] = &[
1141 "--batch-input-root",
1142 "--batch-name-template",
1143 "--log-presentation",
1144 "--level",
1145 "--where",
1146 "--parser",
1147 "--time",
1148 "--output",
1149 "--mermaid-backend",
1150 "--dot-backend",
1151 "--metadata",
1152 "--why",
1153 "--package",
1154 "--timings",
1155 "--audit",
1156 "--build-profile",
1157 "--baseline",
1158 "--save",
1159 "--policy",
1160 "--sbom",
1161 "--depth",
1162 "--kind",
1163 "--x",
1164 "--y",
1165 "--sample",
1166 "--columns",
1167 "--top",
1168 "--max-paths",
1169 "--style-rule",
1170 "--rules",
1171 "--highlighter",
1172 "--code-theme",
1173 "--plugin",
1174 "-w",
1175 "--width",
1176 "-o",
1177 "--export-html",
1178 "--export-svg",
1179 "--export-png",
1180 "-a",
1181 "--panel",
1182 "-d",
1183 "--padding",
1184 "-h",
1185 "--head",
1186 "-t",
1187 "--tail",
1188 "--lexer",
1189 "-W",
1190 "--max-width",
1191 "--rule-style",
1192 "--rule-char",
1193 "--title",
1194 "--caption",
1195 "-s",
1196 "--style",
1197 "-S",
1198 "--panel-style",
1199 "--report",
1200 "--image-mode",
1201 "--image-fit",
1202 "--image-anchor",
1203 "--image-background",
1204 "--height",
1205 "--theme",
1206 "--theme-style",
1207 "--theme-file",
1208 "--image-color",
1209 "--image-dither",
1210 "--image-color-distance",
1211 "--image-rotate",
1212 "--image-max-width",
1213 "--image-max-height",
1214 "--image-brightness",
1215 "--image-contrast",
1216 "--image-gamma",
1217 "--demo-section",
1218 "--demo-delay",
1219 "--watch-interval",
1220 "--interval",
1221 "--watch-debounce",
1222 "--gif-mode",
1223 "--encoding",
1224 "--threshold",
1225 "--loop",
1226 "--speed",
1227 "--fps",
1228 "--max-frames",
1229 "--max-duration",
1230 "--jobs",
1231 "--collision",
1232 "--format",
1233 "--select",
1234 "--filter",
1235 "--highlight",
1236 "--transform",
1237 "--find",
1238 "--max-depth",
1239 "--max-length",
1240 "--compare",
1241 "--context",
1242 "--language",
1243 "--search",
1244 "--offset",
1245 "--length",
1246 "--bytes-per-line",
1247 "--group",
1248 "--limit",
1249 "--cast",
1250 "--redact-pattern",
1251 "--config",
1252 "--profile",
1253];
1254
1255fn selects_mode_explicitly(args: &[String]) -> bool {
1259 explicit_mode(args).is_some()
1260}
1261
1262fn explicit_mode(args: &[String]) -> Option<Mode> {
1264 let mut iter = args.iter();
1265 let mut seen_positional = false;
1266 while let Some(arg) = iter.next() {
1267 if arg == "--" {
1268 break;
1269 }
1270 if VALUE_OPTIONS.contains(&arg.as_str()) {
1271 iter.next();
1272 continue;
1273 }
1274 if arg.starts_with('-') && arg.len() > 1 {
1275 if let Some(flag) = mode_flag_alias(arg) {
1276 return command_mode(&flag[2..]);
1277 }
1278 continue;
1279 }
1280 if !seen_positional {
1281 seen_positional = true;
1282 if let Some(mode) = command_mode(arg) {
1283 return Some(mode);
1284 }
1285 }
1286 }
1287 None
1288}
1289
1290fn command_word(args: &[String]) -> Option<&str> {
1293 let mut iter = args.iter();
1294 while let Some(arg) = iter.next() {
1295 if arg == "--" {
1296 return None;
1297 }
1298 if VALUE_OPTIONS.contains(&arg.as_str()) {
1299 iter.next();
1300 } else if !arg.starts_with('-') || arg == "-" {
1301 return command_mode(arg).map(|_| arg.as_str());
1302 }
1303 }
1304 None
1305}
1306
1307pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1313 let mut iter = args.iter();
1314 while let Some(arg) = iter.next() {
1315 if arg == "--" || mode_flag_alias(arg).is_some() {
1316 return None;
1317 }
1318 if VALUE_OPTIONS.contains(&arg.as_str()) {
1319 iter.next();
1320 } else if !arg.starts_with('-') || arg == "-" {
1321 return Some(arg);
1322 }
1323 }
1324 None
1325}
1326
1327fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1329 Some(match arg {
1330 "--print" | "-p" => "--print",
1331 "--markdown" | "-m" => "--markdown",
1332 "--json" | "-j" | "-J" => "--json",
1333 "--syntax" | "-x" => "--syntax",
1334 "--csv" => "--csv",
1335 "--ipynb" => "--ipynb",
1336 "--rst" => "--rst",
1337 "--org" => "--org",
1338 "--asciidoc" | "--adoc" => "--asciidoc",
1339 "--man" | "--roff" => "--man",
1340 "--jsonl" | "--ndjson" => "--jsonl",
1341 "--log" => "--log",
1342 "--rule" | "-u" => "--rule",
1343 "--image" => "--image",
1344 "--gif" => "--gif",
1345 "--diff" => "--diff",
1346 "--inspect" => "--inspect",
1347 "--ansi-explain" => "--ansi-explain",
1348 _ => return None,
1349 })
1350}
1351
1352fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1353 let message = &controls::shown(message);
1356 if json {
1357 eprintln!(
1358 "{}",
1359 serde_json::json!({
1360 "ok": false,
1361 "code": class.name(),
1362 "exit_code": class.code(),
1363 "message": message,
1364 "error": {
1365 "message": message,
1366 },
1367 })
1368 );
1369 } else {
1370 eprintln!("rich: {message}");
1371 }
1372 class.exit_code()
1373}
1374
1375fn emit_success_report(format: ReportFormat) {
1376 if format == ReportFormat::Json {
1377 eprintln!(
1378 "{}",
1379 serde_json::json!({
1380 "ok": true,
1381 "code": ExitClass::Success.name(),
1382 "exit_code": ExitClass::Success.code(),
1383 "result": {},
1384 })
1385 );
1386 }
1387}
1388
1389fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1390 let message = controls::shown(message.as_ref());
1391 if watch::capture_error(&message) {
1393 return class.exit_code();
1394 }
1395 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1396}
1397
1398fn success(cli: &Cli) -> ExitCode {
1399 if !watch::capturing() {
1400 emit_success_report(cli.report_format);
1401 }
1402 ExitClass::Success.exit_code()
1403}
1404
1405#[cfg(test)]
1412fn glob_match(pattern: &str, text: &str) -> bool {
1413 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1414 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1415 glob_match_units(&pattern, &text)
1416}
1417
1418#[derive(Clone, Copy, PartialEq, Eq)]
1421enum GlobUnit {
1422 Char(char),
1423 Byte(u8),
1424}
1425
1426fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1429 #[cfg(unix)]
1430 {
1431 use std::os::unix::ffi::OsStrExt;
1432 let mut units = Vec::new();
1433 for chunk in name.as_bytes().utf8_chunks() {
1434 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1435 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1436 }
1437 units
1438 }
1439 #[cfg(not(unix))]
1440 {
1441 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1442 }
1443}
1444
1445fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1446 const ANY: GlobUnit = GlobUnit::Char('?');
1447 const STAR: GlobUnit = GlobUnit::Char('*');
1448 let (mut p, mut t) = (0usize, 0usize);
1449 let (mut star, mut retry) = (None, 0usize);
1450 while t < text.len() {
1451 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1452 p += 1;
1453 t += 1;
1454 } else if p < pattern.len() && pattern[p] == STAR {
1455 star = Some(p);
1456 retry = t;
1457 p += 1;
1458 } else if let Some(open) = star {
1459 p = open + 1;
1460 retry += 1;
1461 t = retry;
1462 } else {
1463 return false;
1464 }
1465 }
1466 while p < pattern.len() && pattern[p] == STAR {
1467 p += 1;
1468 }
1469 p == pattern.len()
1470}
1471
1472fn has_glob_meta(value: &str) -> bool {
1473 value.contains('*') || value.contains('?')
1474}
1475
1476fn split_glob(resource: &str) -> (PathBuf, String) {
1478 let meta = resource.find(['*', '?']).unwrap_or(0);
1479 match resource[..meta].rfind(['/', '\\']) {
1480 Some(cut) => (
1481 PathBuf::from(&resource[..cut]),
1482 resource[cut + 1..].to_string(),
1483 ),
1484 None => (PathBuf::from("."), resource.to_string()),
1485 }
1486}
1487
1488fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1496 let mut stack = vec![dir.to_path_buf()];
1497 while let Some(current) = stack.pop() {
1498 batch::check_interrupted()?;
1499 let entries = std::fs::read_dir(¤t)
1500 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1501 for entry in entries {
1502 batch::check_interrupted()?;
1503 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1504 let path = entry.path();
1505 let kind = std::fs::symlink_metadata(&path)
1506 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1507 .file_type();
1508 if kind.is_symlink() {
1509 continue;
1510 }
1511 if kind.is_dir() {
1512 stack.push(path);
1513 } else if kind.is_file() {
1514 out.push(path_arg(&path));
1515 }
1516 }
1517 }
1518 Ok(())
1519}
1520
1521fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1522 let mut out = Vec::new();
1523 for resource in resources {
1524 batch::check_interrupted()?;
1525 if resource == "-" || is_url(resource) {
1526 return Err("batch resources must be local files or directories".into());
1527 }
1528 if has_glob_meta(resource) {
1529 let raw = fs_path(resource);
1533 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1534 let (dir, pattern) = if escaped {
1535 let parent = raw.parent().unwrap_or(Path::new(""));
1536 if has_glob_meta(&parent.to_string_lossy()) {
1537 return Err(format!(
1538 "batch glob {} may only use * and ? in the final path segment",
1539 controls::shown(resource)
1540 ));
1541 }
1542 let dir = if parent.as_os_str().is_empty() {
1543 PathBuf::from(".")
1544 } else {
1545 parent.to_path_buf()
1546 };
1547 (dir, glob_units(raw.file_name().unwrap_or_default()))
1548 } else {
1549 let (dir, pattern) = split_glob(resource);
1550 if pattern.contains('/') || pattern.contains('\\') {
1551 return Err(format!(
1552 "batch glob {resource:?} may only use * and ? in the final path segment"
1553 ));
1554 }
1555 (dir, pattern.chars().map(GlobUnit::Char).collect())
1556 };
1557 let entries = std::fs::read_dir(&dir)
1558 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1559 for entry in entries {
1560 batch::check_interrupted()?;
1561 let entry =
1562 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1563 let path = entry.path();
1564 if !path.is_file() {
1565 continue;
1566 }
1567 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1568 out.push(path_arg(&path));
1569 }
1570 }
1571 continue;
1572 }
1573 let path = fs_path(resource);
1574 if path.is_file() {
1575 out.push(resource.clone());
1576 } else if path.is_dir() {
1577 collect_files(&path, &mut out)?;
1578 } else {
1579 return Err(format!("batch resource does not exist: {resource}"));
1580 }
1581 }
1582 batch::check_interrupted()?;
1583 out.sort();
1584 out.dedup();
1585 batch::check_interrupted()?;
1586 if out.is_empty() {
1587 return Err("batch plan contains no files".into());
1588 }
1589 Ok(out)
1590}
1591
1592#[derive(Default, Clone)]
1598struct BatchOutputs {
1599 html: Option<String>,
1600 svg: Option<String>,
1601}
1602
1603fn batch_export_path(
1604 path: Option<&str>,
1605 input: &str,
1606 index: usize,
1607 total: usize,
1608) -> Option<String> {
1609 let path = path?;
1610 if total == 1 {
1611 return Some(path.into());
1612 }
1613 let p = fs_path(path);
1614 let input = fs_path(input);
1616 let stem = input
1617 .file_stem()
1618 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1619 let mut name = stem.to_owned();
1620 match p.extension() {
1621 Some(ext) if !ext.is_empty() => {
1622 name.push(".");
1623 name.push(ext);
1624 }
1625 _ => name.push(format!("-{index}")),
1626 }
1627 Some(path_arg(&if p.is_dir() {
1628 p.join(name)
1629 } else {
1630 p.with_file_name(name)
1631 }))
1632}
1633
1634fn suffixed_path(path: &Path, suffix: usize) -> String {
1636 let mut name = path
1637 .file_stem()
1638 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1639 .to_owned();
1640 name.push(format!("-{suffix}"));
1641 if let Some(ext) = path.extension() {
1642 name.push(".");
1643 name.push(ext);
1644 }
1645 path_arg(&path.with_file_name(name))
1646}
1647
1648fn resolve_destination(
1654 cli: &Cli,
1655 candidate: String,
1656 taken: &mut std::collections::BTreeSet<String>,
1657) -> Result<String, (ExitClass, String)> {
1658 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1659 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1660 };
1661 match cli.collision {
1662 CollisionPolicy::Overwrite => {
1663 taken.insert(candidate.clone());
1664 Ok(candidate)
1665 }
1666 CollisionPolicy::Suffix => {
1667 let mut chosen = candidate.clone();
1668 let mut suffix = 2;
1669 while occupied(&chosen, taken) {
1670 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1671 chosen = suffixed_path(&fs_path(&candidate), suffix);
1672 suffix += 1;
1673 }
1674 taken.insert(chosen.clone());
1675 Ok(chosen)
1676 }
1677 CollisionPolicy::Error => {
1678 if taken.contains(&candidate) {
1679 return Err((
1680 ExitClass::Usage,
1681 format!("batch output collision: {candidate}"),
1682 ));
1683 }
1684 if !cli.overwrite && fs_path(&candidate).exists() {
1685 return Err((
1686 ExitClass::Input,
1687 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1688 ));
1689 }
1690 taken.insert(candidate.clone());
1691 Ok(candidate)
1692 }
1693 }
1694}
1695
1696fn worker_args(args: &[String]) -> Vec<String> {
1699 let mut result = vec!["--no-config".into()];
1700 let mut iter = args.iter();
1701 let mut seen_resource = false;
1702 let mut selected_mode = false;
1703 while let Some(arg) = iter.next() {
1704 if arg == "--" {
1705 break;
1706 }
1707 if VALUE_OPTIONS.contains(&arg.as_str()) {
1708 let value = iter.next();
1709 if !matches!(
1710 arg.as_str(),
1711 "--jobs"
1712 | "--batch-input-root"
1713 | "--batch-name-template"
1714 | "--collision"
1715 | "--report"
1716 | "-o"
1717 | "--export-html"
1718 | "--export-svg"
1719 | "--config"
1720 | "--profile"
1721 ) {
1722 result.push(arg.clone());
1723 if let Some(value) = value {
1724 result.push(value.clone());
1725 }
1726 }
1727 } else if arg.starts_with('-') && arg != "-" {
1728 selected_mode |= mode_flag_alias(arg).is_some();
1729 if !matches!(
1730 arg.as_str(),
1731 "--batch"
1732 | "--batch-preserve-dirs"
1733 | "--no-batch-preserve-dirs"
1734 | "--no-batch"
1735 | "--dry-run"
1736 | "--progress"
1737 | "--no-progress"
1738 | "--continue-on-error"
1739 | "--no-continue-on-error"
1740 | "--overwrite"
1741 | "--no-overwrite"
1742 | "--machine-json"
1743 ) {
1744 result.push(arg.clone());
1745 }
1746 } else if !seen_resource {
1747 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1748 selected_mode = true;
1749 result.push(format!("--{}", mode_name(mode)));
1750 } else {
1751 seen_resource = true;
1752 }
1753 }
1754 }
1755 result
1756}
1757
1758fn wants_json_report(args: &[String]) -> bool {
1759 let mut format = ReportFormat::Human;
1760 let mut iter = args.iter();
1761 while let Some(arg) = iter.next() {
1762 match arg.as_str() {
1763 "--report" => {
1764 if let Some(value) = iter.next() {
1765 let _ = format.apply_option("--report", Some(value));
1766 }
1767 }
1768 "--machine-json" => {
1769 let _ = format.apply_option("--machine-json", None);
1770 }
1771 "--" => break,
1772 _ => {}
1773 }
1774 }
1775 format == ReportFormat::Json
1776}
1777
1778fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1790 let name = name.to_ascii_lowercase();
1791 if name == "none" {
1792 return Ok(None);
1793 }
1794 PANEL_BOXES
1795 .iter()
1796 .find(|(known, _)| *known == name)
1797 .map(|(_, set)| Some(*set))
1798 .ok_or_else(|| {
1799 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1800 format!(
1801 "unknown panel box {name:?} (use none or {})",
1802 names.join(", ")
1803 )
1804 })
1805}
1806
1807pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1809 use rich::r#box as b;
1810 &[
1811 ("ascii", b::ASCII),
1812 ("ascii2", b::ASCII2),
1813 ("square", b::SQUARE),
1814 ("rounded", b::ROUNDED),
1815 ("heavy", b::HEAVY),
1816 ("double", b::DOUBLE),
1817 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1818 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1819 ("minimal", b::MINIMAL),
1820 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1821 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1822 ("simple", b::SIMPLE),
1823 ("simple_head", b::SIMPLE_HEAD),
1824 ("simple_heavy", b::SIMPLE_HEAVY),
1825 ("horizontals", b::HORIZONTALS),
1826 ("heavy_edge", b::HEAVY_EDGE),
1827 ("heavy_head", b::HEAVY_HEAD),
1828 ("double_edge", b::DOUBLE_EDGE),
1829 ("markdown", b::MARKDOWN),
1830 ]
1831};
1832
1833fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1837 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1838 let parts: Result<Vec<usize>, _> = value
1839 .split(',')
1840 .map(|p| p.trim().parse::<usize>())
1841 .collect();
1842 let parts = parts.map_err(|_| error())?;
1843 match parts.as_slice() {
1844 [p] => Ok((*p, *p, *p, *p)),
1845 [v, h] => Ok((*v, *h, *v, *h)),
1846 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1847 _ => Err(error()),
1848 }
1849}
1850
1851fn mode_name(mode: Mode) -> &'static str {
1853 MODE_SPECS
1854 .iter()
1855 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1856 .unwrap_or("unknown")
1857}
1858
1859fn command_mode(command: &str) -> Option<Mode> {
1860 MODE_SPECS
1861 .iter()
1862 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1863}
1864
1865fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1867 let flag = match flag {
1868 "-h" | "--head" => "--head",
1869 _ => "--tail",
1870 };
1871 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1872 match value.parse::<i64>() {
1873 Ok(lines) if lines >= 1 => Ok(lines as usize),
1874 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1875 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1876 }
1877}
1878
1879fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1880 if *current != Mode::Auto && *current != mode {
1881 return Err(format!(
1882 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1883 ));
1884 }
1885 *current = mode;
1886 Ok(())
1887}
1888
1889fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1891 let roots = ConfigRoots::default();
1892 if authoring::dispatch(args)? {
1893 return Ok(None);
1894 }
1895 if tools::bench_dispatch(args)? {
1896 return Ok(None);
1897 }
1898 if let Some(output) = config::inspect(args, &roots)? {
1899 authoring::out(&format!("{output}\n"));
1900 return Ok(None);
1901 }
1902 let merged = config_args(args, &roots)?;
1903 parse_inner(&merged)
1904}
1905
1906fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1907 let mut mode = Mode::Auto;
1908 let mut resources: Vec<String> = Vec::new();
1909 let mut loops = None;
1910 let mut animation = animation::AnimationControls::default();
1911 let mut diff_threshold = None;
1912 let mut image_mode = ImageMode::Auto;
1913 let mut image_fit = None;
1914 let mut image_anchor = None;
1915 let mut image_background = None;
1916 let mut image_color = None;
1917 let mut image_dither = None;
1918 let mut image_color_distance = None;
1919 let mut image_rotate = None;
1920 let mut image_flip_horizontal = false;
1921 let mut image_flip_vertical = false;
1922 let mut image_grayscale = false;
1923 let mut image_max_width = None;
1924 let mut image_max_height = None;
1925 let mut image_brightness = None;
1926 let mut image_contrast = None;
1927 let mut image_gamma = None;
1928 let mut log_presentation = String::from("plain");
1929 let mut mermaid_backend = None;
1930 let mut dot_backend = None;
1931 let mut graph_sources = sources::GraphSourceOptions::default();
1932 let mut chart = chart::ChartOptions::default();
1933 let mut schema_options = sources::SchemaOptions::default();
1934 let mut profile_options = profile::ProfileOptions::default();
1935 let mut rule_options = style_rules::RuleOptions::default();
1936 let mut highlighter: Option<String> = None;
1937 let mut plugins: Vec<String> = Vec::new();
1938 let mut code_theme: Option<String> = None;
1939 let mut theme_styles = std::collections::BTreeMap::new();
1940 let mut theme_file_styles = std::collections::BTreeMap::new();
1941 let mut height = None;
1942 let mut extensions = CliExtensions::default();
1943 let mut data = inspect::DataOptions::default();
1944 let mut tool_options = tools::ToolOptions::default();
1945 let mut viewer_options = viewers::ViewerOptions::default();
1946 let mut log_options = logs::LogOptions::default();
1947 let mut paging_explicit = false;
1949 let mut ansi_word = false;
1951 let mut width = None;
1952 let mut justify = None;
1953 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1954 let mut export_html: Option<String> = None;
1955 let mut export_svg: Option<String> = None;
1956 let mut export_png: Option<String> = None;
1957 let mut panel = None;
1958 let mut padding = None;
1959 let mut title = None;
1960 let mut caption = None;
1961 let mut panel_style = None;
1962 let mut style = None;
1963 let mut expand = false;
1964 let mut pager = false;
1965 let mut auto_pager = false;
1966 let mut hyperlinks = false;
1967 let mut source = SourceOptions::default();
1968 let mut text_flags = [false; 4];
1971 let mut emoji = false;
1972 let mut soft = false;
1973 let mut max_width = None;
1974 let mut rule_style = None;
1975 let mut rule_char = None;
1976 let mut force_terminal = false;
1977 let mut sanitize = None;
1980 let mut report_format = ReportFormat::Human;
1981 let mut watch = false;
1982 let mut watch_interval = 1.0;
1983 let mut watch_cache = false;
1984 let mut watch_debounce: Option<f64> = None;
1985 let mut watch_poll = false;
1986 let mut watch_exit_on_error = false;
1987 let mut batch = false;
1988 let mut progress = true;
1989 let mut micro_project = false;
1990 let mut continue_on_error = false;
1991 let mut jobs = 1usize;
1992 let mut dry_run = false;
1993 let mut batch_paths = batch_paths::BatchPathOptions::default();
1994 let mut overwrite = false;
1995 let mut collision = CollisionPolicy::Error;
1996 let mut end_of_options = false;
2001 let mut document_format: Option<(String, Mode)> = None;
2004
2005 let deps_mode = explicit_mode(args) == Some(Mode::Deps);
2008 let mut iter = args.iter();
2009 while let Some(arg) = iter.next() {
2010 if end_of_options {
2011 resources.push(arg.to_string());
2012 continue;
2013 }
2014 if extensions.parse_option(arg, &mut iter)? {
2015 continue;
2016 }
2017 if arg == "--format" {
2018 let named = iter
2019 .clone()
2020 .next()
2021 .and_then(|value| documents::format_mode(value).map(|mode| (value.clone(), mode)));
2022 if let Some(named) = named {
2023 iter.next();
2024 document_format = Some(named);
2025 data.format = None;
2026 continue;
2027 }
2028 document_format = None;
2029 }
2030 if data
2031 .parse_option(arg, &mut iter)
2032 .map_err(|err| match arg.as_str() {
2033 "--format" => documents::FORMAT_USAGE.to_string(),
2034 _ => err,
2035 })?
2036 {
2037 continue;
2038 }
2039 if tool_options.parse_option(arg, &mut iter)? {
2040 continue;
2041 }
2042 if log_options.parse_option(arg, &mut iter)? {
2045 continue;
2046 }
2047 if viewer_options.parse_option(arg, &mut iter)? {
2048 continue;
2049 }
2050 if graph_sources.parse_option(arg, &mut iter, deps_mode)? {
2051 continue;
2052 }
2053 if chart.parse_option(arg, &mut iter)? {
2054 continue;
2055 }
2056 if schema_options.parse_option(arg) {
2057 continue;
2058 }
2059 if profile_options.parse_option(arg, &mut iter)? {
2060 continue;
2061 }
2062 if rule_options.parse_option(arg, &mut iter)? {
2063 continue;
2064 }
2065 if matches!(
2066 arg.as_str(),
2067 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
2068 ) {
2069 paging_explicit = true;
2070 }
2071 match arg.as_str() {
2072 "--" => end_of_options = true,
2073 "--help" => {
2075 let no_color = no_color || cli_spec::no_color_requested(args);
2076 let help = command_word(args)
2078 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
2079 match help {
2080 Some(help) => authoring::out(&format!("{help}\n")),
2081 None => print_help(no_color),
2082 }
2083 return Ok(None);
2084 }
2085 "-V" | "-v" | "--version" => {
2086 println!("rich (rs-rich-cli) {VERSION}");
2087 return Ok(None);
2088 }
2089 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
2090 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
2091 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
2094 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
2095 "--csv" => set_mode(&mut mode, Mode::Csv)?,
2096 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
2097 "--rst" => set_mode(&mut mode, Mode::Rst)?,
2098 "--org" => set_mode(&mut mode, Mode::Org)?,
2099 "--asciidoc" | "--adoc" => set_mode(&mut mode, Mode::AsciiDoc)?,
2100 "--man" | "--roff" => set_mode(&mut mode, Mode::Man)?,
2101 "--gif" => set_mode(&mut mode, Mode::Gif)?,
2102 "--diff" => set_mode(&mut mode, Mode::Diff)?,
2103 "--image" => set_mode(&mut mode, Mode::Image)?,
2104 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
2105 "--log" => set_mode(&mut mode, Mode::Log)?,
2106 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
2107 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
2108 "--highlighter" => {
2109 let value = iter
2110 .next()
2111 .ok_or("--highlighter requires a code highlighter's name")?;
2112 highlighter = Some(value.clone());
2113 }
2114 "--code-theme" => {
2115 let value = iter.next().ok_or("--code-theme requires a theme name")?;
2116 code_theme = Some(value.clone());
2117 }
2118 "--plugin" => {
2119 let path = iter.next().ok_or("--plugin requires PATH")?;
2120 plugins.push(path.clone());
2121 }
2122 "--mermaid-backend" => {
2123 let value = iter
2124 .next()
2125 .ok_or("--mermaid-backend requires text, mmdc or off")?;
2126 mermaid_backend = Some(MermaidBackend::parse(value)?);
2127 }
2128 "--dot-backend" => {
2129 let value = iter
2130 .next()
2131 .ok_or("--dot-backend requires text, graphviz or off")?;
2132 dot_backend = Some(sources::DotBackend::parse(value)?);
2133 }
2134 "--log-presentation" => {
2135 let value = iter
2136 .next()
2137 .ok_or("--log-presentation requires plain or rich")?;
2138 if !matches!(value.as_str(), "plain" | "rich") {
2139 return Err("--log-presentation requires plain or rich".into());
2140 }
2141 log_presentation = value.clone();
2142 }
2143 "--report" => {
2144 let value = iter.next().map(String::as_str);
2145 report_format.apply_option("--report", value)?;
2146 }
2147 "--machine-json" => {
2148 report_format.apply_option("--machine-json", None)?;
2149 }
2150 "--progress" => progress = true,
2151 "--no-progress" => progress = false,
2152 "--micro-project" => micro_project = true,
2153 "--no-micro-project" => micro_project = false,
2154 "--theme-style" => {
2155 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2156 let (name, value) = binding
2157 .split_once('=')
2158 .ok_or("--theme-style requires NAME=STYLE")?;
2159 config::validate_theme_binding(name, value)?;
2160 let style = Style::parse(value)
2161 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2162 theme_styles.insert(name.to_owned(), style);
2163 }
2164 "--theme-file" => {
2165 let path = iter.next().ok_or("--theme-file requires PATH")?;
2166 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2167 }
2168 "--image-color" => {
2169 const USAGE: &str =
2170 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2171 let value = iter.next().ok_or(USAGE)?;
2172 if !matches!(
2173 value.as_str(),
2174 "truecolor" | "ansi256" | "ansi16" | "grayscale"
2175 ) {
2176 return Err(USAGE.into());
2177 }
2178 image_color = Some(value.clone());
2179 }
2180 flag @ ("--image-max-width" | "--image-max-height") => {
2181 let usage = format!("{flag} requires a positive integer");
2182 let value = iter
2183 .next()
2184 .and_then(|v| v.parse::<usize>().ok())
2185 .filter(|v| *v > 0)
2186 .ok_or(usage)?;
2187 if flag == "--image-max-width" {
2188 image_max_width = Some(value);
2189 } else {
2190 image_max_height = Some(value);
2191 }
2192 }
2193 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2194 let gamma = flag == "--image-gamma";
2195 let usage = if gamma {
2196 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2197 } else {
2198 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2199 };
2200 let value = iter
2201 .next()
2202 .and_then(|v| v.parse::<f32>().ok())
2203 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2204 .ok_or(usage)?;
2205 match flag {
2206 "--image-brightness" => image_brightness = Some(value),
2207 "--image-contrast" => image_contrast = Some(value),
2208 _ => image_gamma = Some(value),
2209 }
2210 }
2211 "--image-flip-horizontal" => image_flip_horizontal = true,
2212 "--no-image-flip-horizontal" => image_flip_horizontal = false,
2213 "--image-flip-vertical" => image_flip_vertical = true,
2214 "--no-image-flip-vertical" => image_flip_vertical = false,
2215 "--image-grayscale" => image_grayscale = true,
2216 "--no-image-grayscale" => image_grayscale = false,
2217 "--image-rotate" => {
2218 let value = iter
2219 .next()
2220 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2221 image_rotate = Some(match value.as_str() {
2222 "0" => 0,
2223 "90" => 90,
2224 "180" => 180,
2225 "270" => 270,
2226 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2227 });
2228 }
2229 "--image-dither" => {
2230 const USAGE: &str =
2231 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2232 let value = iter.next().ok_or(USAGE)?;
2233 if !matches!(
2234 value.as_str(),
2235 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2236 ) {
2237 return Err(USAGE.into());
2238 }
2239 image_dither = Some(value.clone());
2240 }
2241 "--image-color-distance" => {
2242 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2243 let value = iter.next().ok_or(USAGE)?;
2244 if !matches!(value.as_str(), "rgb" | "oklab") {
2245 return Err(USAGE.into());
2246 }
2247 image_color_distance = Some(value.clone());
2248 }
2249 "--image-mode" => {
2250 let value = iter.next().ok_or(
2251 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2252 )?;
2253 image_mode = value.parse()?;
2254 }
2255 "--image-fit" => {
2256 let value = iter
2257 .next()
2258 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2259 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2260 return Err("--image-fit requires contain, cover, stretch or native".into());
2261 }
2262 image_fit = Some(value.clone());
2263 }
2264 "--image-anchor" => {
2265 let value = iter.next().ok_or("--image-anchor requires a position")?;
2266 if !matches!(
2267 value.as_str(),
2268 "center"
2269 | "top"
2270 | "bottom"
2271 | "left"
2272 | "right"
2273 | "top-left"
2274 | "top-right"
2275 | "bottom-left"
2276 | "bottom-right"
2277 ) {
2278 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2279 }
2280 image_anchor = Some(value.clone());
2281 }
2282 "--image-background" => {
2283 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2284 let value = iter.next().ok_or(USAGE)?;
2285 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2286 }
2287 "--height" => {
2288 let value = iter.next().ok_or("--height requires a number")?;
2289 let rows: usize = value
2290 .parse()
2291 .map_err(|_| format!("invalid height '{value}'"))?;
2292 if rows == 0 {
2293 return Err("--height must be at least 1".into());
2294 }
2295 height = Some(rows);
2296 }
2297 "--threshold" => {
2298 let value = iter
2299 .next()
2300 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2301 let percent: f32 = value
2302 .parse()
2303 .map_err(|_| format!("invalid threshold '{value}'"))?;
2304 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2309 return Err(format!(
2310 "threshold must be a percentage between 0 and 100, got '{value}'"
2311 ));
2312 }
2313 diff_threshold = Some(percent);
2314 }
2315 "--loop" => {
2316 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2317 loops = Some(
2318 value
2319 .parse()
2320 .map_err(|_| format!("invalid loop count {value:?}"))?,
2321 );
2322 }
2323 "--speed" | "--fps" | "--max-frames" | "--max-duration" => {
2324 let value = iter
2325 .next()
2326 .ok_or_else(|| format!("{arg} requires a value"))?;
2327 animation.set(arg, value)?;
2328 }
2329 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2330 "-l" | "--left" => justify = Some(Justify::Left),
2331 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2332 "-r" | "--right" => {}
2333 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2334 "-c" | "--center" => {}
2335 "-L" | "--text-left" => text_flags[0] = true,
2336 "-R" | "--text-right" => text_flags[1] = true,
2337 "-C" | "--text-center" => text_flags[2] = true,
2338 "-F" | "--text-full" => text_flags[3] = true,
2339 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2340 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2341 "-n" | "--line-numbers" => source.line_numbers = true,
2342 "-g" | "--guides" => source.guides = true,
2343 "--lexer" => {
2344 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2345 }
2346 "--no-wrap" => source.no_wrap = true,
2347 "--infer" => source.infer = true,
2348 "--emoji" => emoji = true,
2349 "--soft" => soft = true,
2350 "-W" | "--max-width" => {
2351 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2352 let size: i64 = value
2354 .parse()
2355 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2356 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2357 }
2358 "--rule-style" => {
2359 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2360 rule_style = Some(
2361 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2362 );
2363 }
2364 "--rule-char" => {
2365 rule_char = Some(
2366 iter.next()
2367 .ok_or("--rule-char requires a CHARACTER")?
2368 .clone(),
2369 );
2370 }
2371 "--force-terminal" => force_terminal = true,
2372 "--no-color" => no_color = true,
2373 "--pager" => {
2374 pager = true;
2375 auto_pager = false;
2376 }
2377 "--auto-pager" => {
2378 auto_pager = true;
2379 pager = false;
2380 }
2381 "--no-pager" => {
2382 pager = false;
2383 auto_pager = false;
2384 }
2385 "--no-auto-pager" => auto_pager = false,
2386 "--color" => no_color = false,
2387 "--no-watch" => watch = false,
2388 "--no-watch-cache" => watch_cache = false,
2389 "--no-watch-poll" => watch_poll = false,
2390 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2391 "--no-batch" => batch = false,
2392 "--no-continue-on-error" => continue_on_error = false,
2393 "--no-overwrite" => {
2394 overwrite = false;
2395 collision = CollisionPolicy::Error;
2396 }
2397 "--no-sanitize" => sanitize = Some(false),
2398 "--dry-run" => dry_run = true,
2399 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2400 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2401 "--batch-input-root" => {
2402 batch_paths.input_root = Some(fs_path(
2403 iter.next().ok_or("--batch-input-root requires a path")?,
2404 ))
2405 }
2406 "--batch-name-template" => {
2407 batch_paths.template = Some(
2408 batch_paths::FilenameTemplate::parse(
2409 iter.next()
2410 .ok_or("--batch-name-template requires a template")?,
2411 )
2412 .map_err(|e| e.to_string())?,
2413 )
2414 }
2415 "--watch" => watch = true,
2416 "--watch-cache" => watch_cache = true,
2417 "--watch-poll" => watch_poll = true,
2418 "--watch-exit-on-error" => watch_exit_on_error = true,
2419 "--watch-debounce" => {
2420 let value = iter
2421 .next()
2422 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2423 let seconds = value
2424 .parse::<f64>()
2425 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2426 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2427 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2428 }
2429 watch_debounce = Some(seconds);
2430 }
2431 "--watch-interval" | "--interval" => {
2432 let value = iter
2433 .next()
2434 .ok_or("--watch-interval requires seconds greater than zero")?;
2435 watch_interval = value
2436 .parse::<f64>()
2437 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2438 if !watch_interval.is_finite() || watch_interval <= 0.0 || watch_interval > 86400.0
2439 {
2440 return Err(
2441 "--watch-interval must be greater than zero and at most 86400 seconds"
2442 .into(),
2443 );
2444 }
2445 }
2446 "--batch" => batch = true,
2447 "--continue-on-error" => continue_on_error = true,
2448 "--overwrite" => {
2449 overwrite = true;
2450 collision = CollisionPolicy::Overwrite;
2451 }
2452 "--collision" => {
2453 collision = match iter
2454 .next()
2455 .ok_or("--collision requires error, overwrite, or suffix")?
2456 .as_str()
2457 {
2458 "error" => CollisionPolicy::Error,
2459 "overwrite" => CollisionPolicy::Overwrite,
2460 "suffix" => CollisionPolicy::Suffix,
2461 other => return Err(format!("unknown collision policy {other:?}")),
2462 };
2463 overwrite = collision == CollisionPolicy::Overwrite;
2464 }
2465 "--jobs" => {
2466 jobs = iter
2467 .next()
2468 .ok_or("--jobs requires a positive number")?
2469 .parse()
2470 .map_err(|_| "--jobs requires a positive number".to_string())?;
2471 if jobs == 0 {
2472 return Err("--jobs must be at least 1".into());
2473 }
2474 }
2475 "--sanitize" => sanitize = Some(true),
2476 "-y" | "--hyperlinks" => hyperlinks = true,
2480 "--export-html" | "-o" => {
2484 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2485 }
2486 "--export-svg" => {
2487 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2488 }
2489 "--export-png" => {
2490 export_png = Some(iter.next().ok_or("--export-png requires a PATH")?.clone());
2491 }
2492 "-a" | "--panel" => {
2493 let value = iter.next().ok_or("--panel requires a box name")?;
2494 panel = parse_box(value)?;
2495 }
2496 "-d" | "--padding" => {
2497 let value = iter.next().ok_or("--padding requires a value")?;
2498 padding = Some(parse_padding(value)?);
2499 }
2500 "-e" | "--expand" => expand = true,
2501 "--title" => {
2502 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2503 }
2504 "--caption" => {
2505 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2506 }
2507 "-s" | "--style" => {
2512 let value = iter.next().ok_or("--style requires a value")?;
2513 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2514 }
2515 "-S" | "--panel-style" => {
2516 let value = iter.next().ok_or("--panel-style requires a value")?;
2517 panel_style =
2518 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2519 }
2520 "-w" | "--width" => {
2521 let value = iter.next().ok_or("--width requires a number")?;
2522 let columns: usize = value.parse().map_err(|_| {
2523 if std::path::Path::new(value).exists() {
2527 format!("--width needs a number, but got the file '{value}' — a value is missing")
2528 } else {
2529 format!("invalid width '{value}'")
2530 }
2531 })?;
2532 if columns == 0 {
2533 return Err("--width must be at least 1".into());
2534 }
2535 width = Some(columns);
2536 }
2537 other if other.starts_with('-') && other != "-" => {
2538 return Err(format!("unknown option {other:?}"));
2539 }
2540 other
2541 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2542 {
2543 ansi_word = other == "ansi";
2544 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2545 }
2546 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2547 other => resources.push(other.to_string()),
2548 }
2549 }
2550
2551 if let Some((name, document)) = document_format {
2555 if mode != Mode::Auto && mode != document {
2556 return Err(format!(
2557 "--format {name} cannot be combined with --{}",
2558 mode_name(mode)
2559 ));
2560 }
2561 mode = document;
2562 }
2563 if data.format.is_some() {
2566 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2567 return Err(format!(
2568 "--format cannot be combined with --{}",
2569 mode_name(mode)
2570 ));
2571 }
2572 if mode == Mode::Auto && resources.is_empty() && !batch {
2573 resources.push("-".into());
2574 }
2575 }
2576 if let Some(flag) = data
2577 .inspect_only_option(matches!(mode, Mode::Capture | Mode::Log))
2578 .filter(|_| mode != Mode::Inspect)
2579 {
2580 if flag == "--redact" {
2581 return Err(
2582 "--redact only has an effect with --inspect, `rich capture` or `rich log`".into(),
2583 );
2584 }
2585 return Err(format!("{flag} only has an effect with --inspect"));
2586 }
2587 if let Some(flag) = data.transform_option() {
2588 if !transforms_apply(mode) {
2589 return Err(format!(
2590 "{flag} only has an effect on {}",
2591 transform_modes(flag)
2592 ));
2593 }
2594 data.check_transforms(mode == Mode::Inspect)?;
2595 }
2596 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2597 return Err(format!("{flag} only has an effect with --diff"));
2598 }
2599 if let Some(flag) = tool_options
2600 .ansi_option()
2601 .filter(|_| mode != Mode::AnsiExplain)
2602 {
2603 return Err(format!("{flag} only has an effect with --ansi-explain"));
2604 }
2605 if mode == Mode::Log {
2606 if viewer_options
2608 .given()
2609 .iter()
2610 .any(|(flag, _)| *flag == "--group")
2611 {
2612 return Err("--group requires trace, span or correlation with `rich log`".into());
2613 }
2614 log_options.validate(resources.first().map(String::as_str))?;
2615 if log_options.follows() && (watch || batch) {
2616 return Err("--follow cannot be combined with --watch or --batch".into());
2617 }
2618 }
2619 if mode == Mode::Profile {
2622 if let Some(depth) = graph_sources.take_depth() {
2623 profile_options.set_depth(depth)?;
2624 }
2625 }
2626 for (flag, commands) in viewer_options
2627 .given()
2628 .into_iter()
2629 .chain(graph_sources.given())
2630 .chain(chart.given())
2631 .chain(schema_options.given())
2632 .chain(profile_options.given())
2633 .chain(log_options.given())
2634 {
2635 if !commands.contains(&mode_name(mode)) {
2636 return Err(format!(
2637 "{flag} only has an effect with `rich {}`",
2638 commands.join("` or `rich ")
2639 ));
2640 }
2641 }
2642 if mode == Mode::Capture && resources.is_empty() {
2643 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2644 }
2645 if mode == Mode::View && !paging_explicit {
2646 auto_pager = true;
2647 }
2648 data.validate()?;
2649
2650 if watch && (batch || pager || auto_pager) {
2651 return Err("--watch cannot be combined with --batch or paging".into());
2652 }
2653
2654 if batch {
2658 if mode.accepts_multiple_resources() {
2659 return Err(format!(
2660 "--batch cannot be combined with {}",
2661 if mode == Mode::Diff {
2662 "--diff"
2663 } else {
2664 "--gif"
2665 }
2666 ));
2667 }
2668 if resources.is_empty() {
2669 return Err("--batch needs at least one file, directory or glob".into());
2670 }
2671 if pager || auto_pager {
2673 return Err("--pager cannot be combined with --batch".into());
2674 }
2675 }
2676
2677 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2680 return Err(
2681 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2682 );
2683 }
2684 if mode == Mode::Diff && tool_options.conflicts() {
2687 if resources.len() != 1 {
2688 return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2689 }
2690 if diff_threshold.is_some() {
2691 return Err("--threshold cannot be combined with --conflicts".into());
2692 }
2693 }
2694 if mode == Mode::Image {
2695 if resources.is_empty() {
2696 return Err(
2697 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2698 );
2699 }
2700 if image_mode == ImageMode::None {
2701 return Err(
2702 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2703 .into(),
2704 );
2705 }
2706 }
2707 let quantized = matches!(
2708 image_color.as_deref(),
2709 Some("ansi256" | "ansi16" | "grayscale")
2710 );
2711 if quantized && image_mode == ImageMode::Braille {
2712 return Err(format!(
2713 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2714 image_color.as_deref().unwrap()
2715 ));
2716 }
2717 for (flag, value, neutral) in [
2718 ("--image-dither", image_dither.as_deref(), "none"),
2719 (
2720 "--image-color-distance",
2721 image_color_distance.as_deref(),
2722 "rgb",
2723 ),
2724 ] {
2725 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2726 return Err(format!(
2727 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2728 ));
2729 }
2730 }
2731 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2732 return Err("--image-anchor requires --image-fit cover".into());
2733 }
2734 if (batch_paths.preserve_dirs
2735 || batch_paths.template.is_some()
2736 || batch_paths.input_root.is_some())
2737 && !batch
2738 {
2739 return Err("batch path options require --batch".into());
2740 }
2741 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2742 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2743 }
2744 if dry_run && !batch {
2745 return Err("--dry-run requires --batch".into());
2746 }
2747 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2748 return Err("--jobs greater than 1 requires --batch with a file export".into());
2749 }
2750 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2752 return Err("--image-fit requires --height to define the target rectangle".into());
2753 }
2754 let effective_mode = if mode == Mode::Auto {
2759 detect_mode(resources.first().map(String::as_str))
2760 } else {
2761 mode
2762 };
2763 extensions.validate(
2764 effective_mode == Mode::Gif,
2765 effective_mode.allows_extensions()
2766 && !(mode == Mode::Auto && resources.is_empty())
2767 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2768 )?;
2769 if export_png.is_some() && !cfg!(feature = "png") {
2770 return Err("--export-png needs a build with the png feature".into());
2771 }
2772 if export_png.is_some() && batch {
2773 return Err("--export-png cannot be combined with --batch".into());
2774 }
2775 let exporting = export_html.is_some() || export_svg.is_some() || export_png.is_some();
2776 if exporting {
2777 let unsupported = if effective_mode == Mode::Gif {
2778 Some("--gif")
2779 } else if mode == Mode::Auto && resources.is_empty() {
2780 Some("the capability demo")
2781 } else {
2782 None
2783 };
2784 if let Some(what) = unsupported {
2785 return Err(format!(
2786 "--export-html/--export-svg/--export-png cannot capture {what}"
2787 ));
2788 }
2789 }
2790
2791 let text_justify = [
2797 Justify::Left,
2798 Justify::Right,
2799 Justify::Center,
2800 Justify::Full,
2801 ]
2802 .into_iter()
2803 .zip(text_flags)
2804 .find_map(|(justify, given)| given.then_some(justify));
2805 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2808 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2809 let orphans = [
2810 (
2811 "--head/--tail",
2812 source.head.is_some() || source.tail.is_some(),
2813 "--syntax, --csv or --ipynb",
2814 source_mode || effective_mode == Mode::Csv,
2815 ),
2816 (
2817 "--infer",
2818 source.infer,
2819 "--csv",
2820 effective_mode == Mode::Csv,
2821 ),
2822 (
2823 "--line-numbers",
2824 source.line_numbers,
2825 "--syntax or --ipynb",
2826 source_mode,
2827 ),
2828 (
2829 "--guides",
2830 source.guides,
2831 "--syntax or --ipynb",
2832 source_mode,
2833 ),
2834 (
2835 "--lexer",
2836 source.lexer.is_some(),
2837 "--syntax, --ipynb, --rst, --org, --asciidoc or --man",
2838 source_mode
2839 || matches!(
2840 effective_mode,
2841 Mode::Rst | Mode::Org | Mode::AsciiDoc | Mode::Man
2842 ),
2843 ),
2844 (
2845 "--no-wrap",
2846 source.no_wrap,
2847 "--syntax, --ipynb, --print or --rule",
2848 source_mode || print_mode,
2849 ),
2850 (
2851 "--text-left/--text-right/--text-center/--text-full",
2852 text_justify.is_some(),
2853 "--print or --rule",
2854 print_mode,
2855 ),
2856 (
2857 "--rule-style",
2858 rule_style.is_some(),
2859 "--rule",
2860 effective_mode == Mode::Rule,
2861 ),
2862 (
2863 "--rule-char",
2864 rule_char.is_some(),
2865 "--rule",
2866 effective_mode == Mode::Rule,
2867 ),
2868 (
2871 "--soft",
2872 soft,
2873 "a mode other than --jsonl or --log",
2874 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2875 ),
2876 (
2877 "--max-width",
2878 max_width.is_some(),
2879 "a mode other than --jsonl or --log",
2880 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2881 ),
2882 (
2883 "--threshold",
2884 diff_threshold.is_some(),
2885 "--diff",
2886 mode == Mode::Diff,
2887 ),
2888 (
2889 "--image-mode",
2890 image_mode != ImageMode::Auto,
2891 "--diff/--image",
2892 mode == Mode::Diff || mode == Mode::Image,
2893 ),
2894 ("--height", height.is_some(), "--image", mode == Mode::Image),
2895 (
2896 "--image-rotate",
2897 image_rotate.is_some(),
2898 "--image",
2899 mode == Mode::Image,
2900 ),
2901 (
2902 "--image-flip-horizontal",
2903 image_flip_horizontal,
2904 "--image",
2905 mode == Mode::Image,
2906 ),
2907 (
2908 "--image-flip-vertical",
2909 image_flip_vertical,
2910 "--image",
2911 mode == Mode::Image,
2912 ),
2913 (
2914 "--image-grayscale",
2915 image_grayscale,
2916 "--image",
2917 mode == Mode::Image,
2918 ),
2919 (
2920 "--image-color",
2921 image_color.is_some(),
2922 "--image or --gif",
2923 mode == Mode::Image || effective_mode == Mode::Gif,
2924 ),
2925 (
2926 "--image-max-width",
2927 image_max_width.is_some(),
2928 "--image",
2929 mode == Mode::Image,
2930 ),
2931 (
2932 "--image-max-height",
2933 image_max_height.is_some(),
2934 "--image",
2935 mode == Mode::Image,
2936 ),
2937 (
2938 "--image-brightness",
2939 image_brightness.is_some(),
2940 "--image",
2941 mode == Mode::Image,
2942 ),
2943 (
2944 "--image-contrast",
2945 image_contrast.is_some(),
2946 "--image",
2947 mode == Mode::Image,
2948 ),
2949 (
2950 "--image-gamma",
2951 image_gamma.is_some(),
2952 "--image",
2953 mode == Mode::Image,
2954 ),
2955 (
2956 "--image-dither",
2957 image_dither.is_some(),
2958 "--image or --gif",
2959 mode == Mode::Image || effective_mode == Mode::Gif,
2960 ),
2961 (
2962 "--image-color-distance",
2963 image_color_distance.is_some(),
2964 "--image or --gif",
2965 mode == Mode::Image || effective_mode == Mode::Gif,
2966 ),
2967 (
2968 "--image-fit",
2969 image_fit.is_some(),
2970 "--image",
2971 mode == Mode::Image,
2972 ),
2973 (
2974 "--image-background",
2975 image_background.is_some(),
2976 "--image",
2977 mode == Mode::Image,
2978 ),
2979 (
2980 "--loop",
2981 loops.is_some(),
2982 "--gif",
2983 effective_mode == Mode::Gif,
2984 ),
2985 (
2986 "--speed",
2987 animation.speed.is_some(),
2988 "--gif",
2989 effective_mode == Mode::Gif,
2990 ),
2991 (
2992 "--fps",
2993 animation.fps.is_some(),
2994 "--gif",
2995 effective_mode == Mode::Gif,
2996 ),
2997 (
2998 "--max-frames",
2999 animation.max_frames.is_some(),
3000 "--gif",
3001 effective_mode == Mode::Gif,
3002 ),
3003 (
3004 "--max-duration",
3005 animation.max_duration.is_some(),
3006 "--gif",
3007 effective_mode == Mode::Gif,
3008 ),
3009 (
3014 "--panel-style",
3015 panel_style.is_some(),
3016 "--panel",
3017 panel.is_some(),
3018 ),
3019 (
3020 "--expand",
3021 expand,
3022 "--panel/--padding",
3023 panel.is_some() || padding.is_some(),
3024 ),
3025 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
3026 ("--watch-cache", watch_cache, "--watch", watch),
3027 (
3028 "--watch-debounce",
3029 watch_debounce.is_some(),
3030 "--watch",
3031 watch,
3032 ),
3033 ("--watch-poll", watch_poll, "--watch", watch),
3034 (
3035 "--watch-exit-on-error",
3036 watch_exit_on_error,
3037 "--watch",
3038 watch,
3039 ),
3040 ];
3041 if let Some((flag, _, needs, _)) = orphans
3042 .iter()
3043 .find(|(_, given, _, mode_present)| *given && !*mode_present)
3044 {
3045 return Err(format!("{flag} only has an effect with {needs}"));
3046 }
3047
3048 let demo = mode == Mode::Auto && resources.is_empty();
3051 if effective_mode.draws_directly() || demo {
3052 let mode_name = if effective_mode == Mode::Gif {
3053 "--gif"
3054 } else if effective_mode == Mode::Image {
3055 "--image"
3056 } else if effective_mode.streams_records() {
3057 mode_name(effective_mode)
3058 } else if demo {
3059 "the capability demo"
3060 } else {
3061 "--diff"
3062 };
3063 let unsupported = [
3064 (
3065 "--pager",
3066 (pager || auto_pager)
3067 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
3068 ),
3069 (
3070 "--export-html/--export-svg/--export-png",
3071 (export_html.is_some() || export_svg.is_some() || export_png.is_some())
3072 && effective_mode.streams_records(),
3073 ),
3074 ("--width", demo && width.is_some()),
3075 ("--theme-style", demo && !theme_styles.is_empty()),
3076 ("--theme-file", demo && !theme_file_styles.is_empty()),
3077 (
3078 "--hyperlinks",
3079 hyperlinks && (effective_mode.streams_records() || demo),
3080 ),
3081 ("--panel", panel.is_some()),
3082 ("--padding", padding.is_some()),
3083 ("--title", title.is_some()),
3084 ("--caption", caption.is_some()),
3085 ("--style", style.is_some()),
3086 ("--panel-style", panel_style.is_some()),
3087 ("--expand", expand),
3088 ("--left/--center/--right", justify.is_some()),
3089 ];
3090 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
3091 return Err(format!("{flag} cannot be combined with {mode_name}"));
3092 }
3093 }
3094 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
3097 return Err("only one resource may be given (except with --gif or --watch)".into());
3098 }
3099 let sanitize = sanitize.unwrap_or_else(|| {
3104 mode == Mode::View
3105 || mode == Mode::Schema
3106 || mode == Mode::Log
3107 || (mode == Mode::Diff
3108 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
3109 });
3110 let resource = resources.first().map(|resource| {
3112 if print_mode {
3113 text_arg(resource)
3114 } else {
3115 resource.clone()
3116 }
3117 });
3118 let title = title.map(|title| text_arg(&title));
3119 let caption = caption.map(|caption| text_arg(&caption));
3120 let rule_char = rule_char.map(|characters| text_arg(&characters));
3121
3122 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
3124 Ok(Some(Cli {
3125 mode,
3126 resource,
3127 resources,
3128 loops,
3129 animation,
3130 diff_threshold,
3131 image_mode,
3132 image_fit,
3133 image_anchor,
3134 image_background,
3135 image_color,
3136 image_dither,
3137 image_color_distance,
3138 image_rotate,
3139 image_flip_horizontal,
3140 image_flip_vertical,
3141 image_grayscale,
3142 image_max_width,
3143 image_max_height,
3144 image_brightness,
3145 image_contrast,
3146 image_gamma,
3147 log_presentation,
3148 mermaid_backend,
3149 dot_backend,
3150 graph_sources,
3151 chart,
3152 schema: schema_options,
3153 profile: profile_options,
3154 rules: rule_options,
3155 highlighter,
3156 code_theme,
3157 plugins,
3158 micro_project,
3159 theme_styles,
3160 theme_file_styles,
3161 height,
3162 extensions,
3163 width,
3164 justify,
3165 text_justify,
3166 emoji,
3167 soft,
3168 max_width,
3169 rule_style,
3170 rule_char,
3171 force_terminal,
3172 source,
3173 no_color,
3174 export_html,
3175 export_svg,
3176 export_png,
3177 panel,
3178 padding,
3179 title,
3180 caption,
3181 panel_style,
3182 style,
3183 expand,
3184 pager,
3185 auto_pager,
3186 hyperlinks,
3187 sanitize,
3188 report_format,
3189 watch,
3190 watch_interval,
3191 watch_cache,
3192 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
3193 watch_poll,
3194 watch_exit_on_error,
3195 batch,
3196 progress,
3197 continue_on_error,
3198 jobs,
3199 dry_run,
3200 batch_paths,
3201 worker_args: worker_args(args),
3202 overwrite,
3203 collision,
3204 data,
3205 tools: tool_options,
3206 viewers: viewer_options,
3207 logs: log_options,
3208 }))
3209}
3210
3211fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3221 use std::io::{Seek, SeekFrom};
3222 let resource = cli.resource.as_deref();
3223 let name = resource.unwrap_or("<stdin>");
3224 let window = |bytes: Vec<u8>| {
3225 let start = usize::try_from(offset)
3226 .unwrap_or(usize::MAX)
3227 .min(bytes.len());
3228 let mut rest = bytes[start..].to_vec();
3229 let more = rest.len() as u64 > max;
3230 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3231 (rest, more)
3232 };
3233 if let Some(url) = resource.filter(|r| is_url(r)) {
3234 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3235 }
3236 if cli.extensions.encoding.is_some() {
3237 return read_resource_limited(
3238 resource,
3239 cli.extensions.encoding,
3240 Some(controls::INSPECT_LIMIT),
3241 )
3242 .map(|text| window(text.into_bytes()))
3243 .map_err(|err| format!("cannot read {name}: {err}"));
3244 }
3245 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3246 let take = max.saturating_add(1);
3248 let mut bytes = Vec::new();
3249 match resource.filter(|r| *r != "-") {
3250 Some(path) if fs_path(path).is_dir() => {
3251 return Err(format!("cannot read {path}: it is a directory"));
3252 }
3253 Some(path) => {
3254 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3255 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3257 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3258 .map_err(error)?;
3259 }
3260 file.take(take).read_to_end(&mut bytes).map_err(error)?;
3261 }
3262 None => {
3263 let mut stdin = std::io::stdin().lock();
3264 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3265 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3266 }
3267 }
3268 let more = bytes.len() as u64 > max;
3269 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3270 Ok((bytes, more))
3271}
3272
3273fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3275 let mut bytes = Vec::new();
3276 match limit {
3277 None => {
3278 let mut reader = reader;
3279 reader.read_to_end(&mut bytes)?;
3280 }
3281 Some(limit) => {
3282 reader
3283 .take(limit.saturating_add(1))
3284 .read_to_end(&mut bytes)?;
3285 if bytes.len() as u64 > limit {
3286 return Err(std::io::Error::other(format!(
3287 "larger than {}, the most this command reads",
3288 controls::size(limit)
3289 )));
3290 }
3291 }
3292 }
3293 Ok(bytes)
3294}
3295
3296fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3309 read_resource_limited(resource, encoding, None)
3310}
3311
3312fn read_resource_limited(
3316 resource: Option<&str>,
3317 encoding: Option<Encoding>,
3318 limit: Option<u64>,
3319) -> std::io::Result<String> {
3320 let content = match resource {
3321 Some(path) if path != "-" => {
3322 let file = &fs_path(path);
3323 if file.is_dir() {
3326 return Err(std::io::Error::new(
3327 std::io::ErrorKind::InvalidInput,
3328 "is a directory, not a file",
3329 ));
3330 }
3331 let bytes = match limit {
3332 None => std::fs::read(file)?,
3333 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3334 };
3335 if let Some(encoding) = encoding {
3336 return encoding.decode(&bytes).map(normalize_newlines);
3337 }
3338 if has_utf16_bom(&bytes) {
3339 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
3340 }
3341 match String::from_utf8(bytes) {
3342 Ok(text) => text,
3343 Err(_) if looks_like_image(path) => {
3348 return Err(std::io::Error::new(
3349 std::io::ErrorKind::InvalidData,
3350 "looks like an image; use `rich --image <path>` to view it, or \
3351 `rich --diff <before> <after>` to compare two images",
3352 ))
3353 }
3354 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3355 }
3356 }
3357 _ => {
3358 if std::io::stdin().is_terminal() {
3359 let eof = if cfg!(windows) {
3360 "Ctrl-Z then Enter"
3361 } else {
3362 "Ctrl-D"
3363 };
3364 eprintln!("rich: reading stdin; finish input with {eof}");
3365 }
3366 let buffer = read_limited(std::io::stdin(), limit)?;
3367 decode_strict(buffer, encoding)?
3368 }
3369 };
3370 Ok(normalize_newlines(if encoding.is_some() {
3371 content
3372 } else {
3373 strip_bom(content)
3374 }))
3375}
3376
3377fn sanitize_json_value(value: &mut serde_json::Value) {
3378 match value {
3379 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3380 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3381 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3382 _ => {}
3383 }
3384}
3385
3386fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3387 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3388 sanitize_json_value(&mut value);
3389 serde_json::to_string(&value)
3390}
3391
3392fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3394 if let Some(encoding) = encoding {
3395 return encoding.decode(&bytes);
3396 }
3397 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3398 std::io::ErrorKind::InvalidData,
3399 if has_utf16_bom(err.as_bytes()) {
3400 "input has a UTF-16 BOM; use --encoding utf-16"
3401 } else {
3402 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3403 },
3404 ))
3405}
3406
3407fn normalize_newlines(text: String) -> String {
3416 const CR: char = '\r';
3417 const LF: char = '\n';
3418 if !text.contains(CR) {
3419 return text;
3420 }
3421 let mut out = String::with_capacity(text.len());
3422 let mut chars = text.chars().peekable();
3423 while let Some(c) = chars.next() {
3424 if c == CR {
3425 if chars.peek() == Some(&LF) {
3428 chars.next();
3429 }
3430 out.push(LF);
3431 } else {
3432 out.push(c);
3433 }
3434 }
3435 out
3436}
3437
3438fn looks_like_image(path: &str) -> bool {
3448 std::path::Path::new(path)
3449 .extension()
3450 .and_then(|e| e.to_str())
3451 .map(|e| e.to_ascii_lowercase())
3452 .is_some_and(|e| {
3453 matches!(
3454 e.as_str(),
3455 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3456 )
3457 })
3458}
3459
3460fn strip_bom(text: String) -> String {
3461 match text.strip_prefix('\u{feff}') {
3462 Some(rest) => rest.to_string(),
3463 None => text,
3464 }
3465}
3466
3467fn is_url(resource: &str) -> bool {
3470 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3471 head.starts_with("http://") || head.starts_with("https://")
3472}
3473
3474fn resource_ext(resource: &str) -> Option<String> {
3478 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3479 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3480 basename
3481 .rsplit_once('.')
3482 .map(|(_, ext)| ext.to_ascii_lowercase())
3483 .filter(|ext| !ext.is_empty())
3484}
3485
3486#[cfg(feature = "fetch")]
3489const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3490
3491#[cfg(feature = "fetch")]
3499fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3500 use std::time::Duration;
3501 let mut response = ureq::get(url)
3502 .config()
3503 .timeout_global(Some(Duration::from_secs(30)))
3504 .http_status_as_error(false)
3507 .build()
3508 .call()
3509 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3510 let content_type = response
3511 .headers()
3512 .get("content-type")
3513 .and_then(|value| value.to_str().ok())
3514 .map(str::to_string);
3515
3516 let mut buffer = Vec::new();
3522 response
3523 .body_mut()
3524 .with_config()
3525 .reader()
3526 .take(MAX_BODY_BYTES + 1)
3527 .read_to_end(&mut buffer)
3528 .map_err(|err| format!("cannot read {url}: {err}"))?;
3529 if buffer.len() as u64 > MAX_BODY_BYTES {
3530 return Err(format!(
3531 "{url} exceeds the {} MiB limit",
3532 MAX_BODY_BYTES / (1024 * 1024)
3533 ));
3534 }
3535 let body =
3536 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3537 Ok((body, content_type))
3538}
3539
3540#[cfg(not(feature = "fetch"))]
3542fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3543 Err(format!(
3544 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3545 ))
3546}
3547
3548fn content_type_mode(content_type: &str) -> Option<Mode> {
3552 let mime = mime_of(content_type);
3553 match mime.as_str() {
3554 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3555 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3556 "text/org" | "text/x-org" => Some(Mode::Org),
3557 "text/asciidoc" | "text/x-asciidoc" => Some(Mode::AsciiDoc),
3558 "text/troff" | "application/x-troff" | "application/x-troff-man" | "text/x-troff-man" => {
3559 Some(Mode::Man)
3560 }
3561 "application/json" | "text/json" => Some(Mode::Json),
3562 "text/csv" => Some(Mode::Csv),
3563 _ => None,
3564 }
3565}
3566
3567fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3571 let mime = mime_of(content_type);
3572 Some(match mime.as_str() {
3573 "text/html" | "application/xhtml+xml" => "html",
3574 "text/css" => "css",
3575 "application/javascript" | "text/javascript" => "javascript",
3576 "application/xml" | "text/xml" => "xml",
3577 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3578 "text/x-rust" => "rust",
3579 "text/x-c" | "text/x-csrc" => "c",
3580 "application/x-sh" | "text/x-shellscript" => "bash",
3581 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3582 "application/toml" | "text/x-toml" => "toml",
3583 _ => return None,
3584 })
3585}
3586
3587fn is_uninformative_ext(ext: &str) -> bool {
3590 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3591}
3592
3593fn mime_of(content_type: &str) -> String {
3596 content_type
3597 .split(';')
3598 .next()
3599 .unwrap_or("")
3600 .trim()
3601 .to_ascii_lowercase()
3602}
3603
3604fn transforms_apply(mode: Mode) -> bool {
3608 matches!(
3609 mode,
3610 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3611 )
3612}
3613
3614fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3618 if let Some(flag) = cli
3619 .data
3620 .transform_option()
3621 .filter(|_| !transforms_apply(mode))
3622 {
3623 return Some(transforms_misplaced(flag, mode));
3624 }
3625 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3626 .then(|| cli.data.check_transforms(true).unwrap_err())
3627}
3628
3629fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3630 format!(
3631 "{flag} only has an effect on {}, not {}",
3632 transform_modes(flag),
3633 mode_name(mode)
3634 )
3635}
3636
3637fn transform_modes(flag: &str) -> &'static str {
3639 if flag == "--transform" {
3640 "text, --print or --syntax"
3641 } else {
3642 "text, --print, --syntax or --inspect"
3643 }
3644}
3645
3646fn detect_mode(resource: Option<&str>) -> Mode {
3647 match resource.and_then(resource_ext).as_deref() {
3648 Some("md") | Some("markdown") => Mode::Markdown,
3649 Some("json") => Mode::Json,
3650 Some("csv") | Some("tsv") => Mode::Csv,
3651 Some("ipynb") => Mode::Ipynb,
3652 Some("rst") => Mode::Rst,
3653 Some("org") if !resource.is_some_and(documents::bare_host) => Mode::Org,
3656 Some("adoc" | "asciidoc") => Mode::AsciiDoc,
3657 Some("man" | "roff" | "mdoc") => Mode::Man,
3658 Some("gif") => Mode::Gif,
3659 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3660 Some("dot") | Some("gv") => Mode::Dot,
3661 Some(_) => Mode::Syntax,
3669 None => Mode::Auto,
3673 }
3674}
3675
3676struct ForceWidth {
3684 child: Box<dyn Renderable>,
3685 width: usize,
3686}
3687
3688impl Renderable for ForceWidth {
3689 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3690 self.child
3691 .rich_render(console, &options.update_width(self.width))
3692 }
3693
3694 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3695 Measurement::new(self.width, self.width)
3696 }
3697}
3698
3699struct Aligned {
3709 child: Box<dyn Renderable>,
3710 width: usize,
3716 justify: Justify,
3717}
3718
3719impl Renderable for Aligned {
3720 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3721 let width = self.width.clamp(1, options.max_width);
3722 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3732 let widths: Vec<usize> = lines
3733 .iter()
3734 .map(|line| line.iter().map(Segment::cell_length).sum())
3735 .collect();
3736 let shape = widths.iter().copied().max().unwrap_or(0);
3740
3741 let excess = options.max_width.saturating_sub(shape);
3742 let (left, right) = match self.justify {
3743 Justify::Right => (excess, 0),
3744 Justify::Center => (excess / 2, excess - excess / 2),
3745 _ => (0, excess),
3746 };
3747 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3748
3749 let mut segments = Vec::new();
3750 let last = lines.len().saturating_sub(1);
3751 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3752 if left > 0 {
3753 segments.push(blank(left));
3754 }
3755 segments.extend(line);
3756 let pad = shape - line_width + right;
3757 if pad > 0 {
3758 segments.push(blank(pad));
3759 }
3760 if index != last {
3761 segments.push(Segment::line());
3762 }
3763 }
3764 segments
3765 }
3766}
3767
3768fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3780 console
3781 .render_lines(renderable, &console.options(), false)
3782 .iter()
3783 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3784 .max()
3785 .unwrap_or(0)
3786}
3787
3788fn micro_pending() -> bool {
3793 #[cfg(feature = "art")]
3794 return micro::drawing_pending();
3795 #[cfg(not(feature = "art"))]
3796 false
3797}
3798
3799fn validate_labels(cli: &Cli) -> Result<(), String> {
3800 for label in cli.title.iter().chain(cli.caption.iter()) {
3801 Text::from_markup(label).map_err(|err| err.to_string())?;
3802 }
3803 Ok(())
3804}
3805
3806fn decorate_and_emit(
3812 cli: &Cli,
3813 console: &Console,
3814 export: &Export,
3815 renderable: Box<dyn Renderable>,
3816 fit: Option<usize>,
3817) -> ExitCode {
3818 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3819}
3820
3821fn decorate_and_emit_with(
3824 cli: &Cli,
3825 console: &Console,
3826 export: &Export,
3827 renderable: Box<dyn Renderable>,
3828 fit: Option<usize>,
3829 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3830) -> ExitCode {
3831 if cli.panel.is_some() {
3832 if let Err(err) = validate_labels(cli) {
3833 return fail(cli, ExitClass::Data, err);
3834 }
3835 }
3836 let max_width = console.width();
3837 let expand = cli.expand || cli.width.is_some();
3839 let mut renderable = renderable;
3840 let mut fit = fit;
3841
3842 if let Some((top, right, bottom, left)) = cli.padding {
3848 let padded: Box<dyn Renderable> =
3849 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3850 fit = fit.map(|width| (width + left + right).min(max_width));
3853 renderable = if expand {
3854 padded
3855 } else {
3856 Box::new(Constrain::new(padded, fit))
3857 };
3858 }
3859
3860 if let Some(box_set) = cli.panel {
3861 let mut panel = Panel::new(renderable).box_set(box_set);
3862 #[cfg(feature = "art")]
3865 let micro_label = |label: &Option<String>| {
3866 label
3867 .as_deref()
3868 .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3869 };
3870 #[cfg(not(feature = "art"))]
3871 let micro_label = |_: &Option<String>| None::<Text>;
3872 let micro_title = micro_label(&cli.title);
3873 if let Some(title) = micro_title.clone() {
3874 panel = panel.title_as_text(title);
3875 } else if let Some(title) = cli.title.clone() {
3876 panel = panel.title(title);
3877 }
3878 if let Some(caption) = micro_label(&cli.caption) {
3879 panel = panel.subtitle_as_text(caption);
3880 } else if let Some(caption) = cli.caption.clone() {
3881 panel = panel.subtitle(caption);
3882 }
3883 if let Some(style) = cli.panel_style.clone() {
3884 panel = panel.border_style(style);
3885 }
3886 let inner = max_width.saturating_sub(4);
3895 let child = fit.unwrap_or(inner).min(inner);
3896 let title_width = cli
3897 .title
3898 .as_deref()
3899 .map(|title| {
3900 let mut title = match µ_title {
3903 Some(title) => {
3904 let mut flat = title.blank_copy();
3905 flat.append(&title.plain().replace('\n', " "), None);
3906 flat
3907 }
3908 None => {
3909 let title = rich::emoji::replace(title);
3910 Text::from_markup(&title.replace('\n', " "))
3911 .expect("title markup validated before rendering")
3912 }
3913 };
3914 title.expand_tabs(8);
3915 cell_len(title.plain()) + 2
3916 })
3917 .unwrap_or(0);
3918 fit = Some(child.max(title_width) + 4);
3919 let panel: Box<dyn Renderable> = Box::new(panel);
3920 renderable = if expand {
3921 panel
3922 } else {
3923 Box::new(Constrain::new(panel, fit))
3924 };
3925 }
3926
3927 if let Some(style) = cli.style.clone() {
3930 renderable = Box::new(Styled::new(renderable, style));
3931 }
3932
3933 if let Some(width) = cli.width.filter(|_| !cli.pager) {
3935 renderable = Box::new(ForceWidth {
3936 child: renderable,
3937 width,
3938 });
3939 fit = Some(width);
3940 }
3941
3942 if let Some(justify) = cli.justify {
3943 renderable = Box::new(Aligned {
3944 child: renderable,
3945 width: fit.unwrap_or(max_width),
3946 justify,
3947 });
3948 }
3949
3950 #[cfg(feature = "art")]
3955 if let Some(registry) =
3956 micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
3957 {
3958 renderable = micro::drawn(console, registry, renderable);
3959 }
3960
3961 let mut options = console.options();
3964 if let Some(max_width) = cli.max_width {
3965 options.max_width = max_width.min(options.max_width);
3966 }
3967 let shown = if cli.soft {
3968 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
3969 if !segments.is_empty() {
3970 segments.push(Segment::line());
3971 }
3972 emit_segments(console, export, segments)
3973 } else {
3974 emit(console, export, |c| {
3975 c.print_with(renderable.as_ref(), &options)
3976 })
3977 };
3978 finish(cli, shown)
3979}
3980
3981fn soft_wrap_segments(
3987 console: &Console,
3988 renderable: &dyn Renderable,
3989 options: &ConsoleOptions,
3990) -> Vec<Segment> {
3991 let mut options = options.clone();
3992 options.no_wrap.get_or_insert(true);
3993 options
3994 .overflow
3995 .get_or_insert(rich::console::Overflow::Ignore);
3996 let joined = renderable.printed_text();
3997 let renderable: &dyn Renderable = match &joined {
3998 Some(text) => text,
3999 None => renderable,
4000 };
4001 if renderable.fit_to_measurement() {
4002 let measurement = renderable.measure(console, &options);
4003 options.max_width = measurement.maximum.min(options.max_width).max(1);
4004 }
4005 console.render(renderable, Some(&options))
4006}
4007
4008fn run_cli(cli: Cli) -> ExitCode {
4009 if cli.watch {
4010 return run_watch(cli);
4011 }
4012 run_once(cli)
4013}
4014
4015fn run_watch(mut cli: Cli) -> ExitCode {
4016 let Some(resource) = cli.resource.clone() else {
4017 return fail(
4018 &cli,
4019 ExitClass::Usage,
4020 "--watch requires a file path or URL",
4021 );
4022 };
4023 let several = cli.resources.len() > 1;
4024 for resource in &cli.resources {
4025 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
4026 return fail(
4027 &cli,
4028 ExitClass::Usage,
4029 "--watch requires a local file or URL, not stdin or literal markup",
4030 );
4031 }
4032 if several && is_url(resource) {
4033 return fail(
4034 &cli,
4035 ExitClass::Usage,
4036 "--watch with several resources requires local files; watch a URL on its own",
4037 );
4038 }
4039 let effective_mode = if cli.mode == Mode::Auto {
4040 detect_mode(Some(resource))
4041 } else {
4042 cli.mode
4043 };
4044 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
4045 return fail(
4046 &cli,
4047 ExitClass::Usage,
4048 "--watch requires a resource-backed render mode",
4049 );
4050 }
4051 }
4052 if several
4053 && (cli.export_html.is_some() || cli.export_svg.is_some() || cli.export_png.is_some())
4054 {
4055 return fail(
4056 &cli,
4057 ExitClass::Usage,
4058 "--export-html/--export-svg/--export-png cannot be combined with watching several \
4059 files",
4060 );
4061 }
4062 if !std::io::stdout().is_terminal() {
4066 cli.watch = false;
4067 if !several {
4068 return run_once(cli);
4069 }
4070 let mut first_failure = None;
4071 for resource in cli.resources.clone() {
4072 let mut single = cli.clone();
4073 single.resource = Some(resource.clone());
4074 single.resources = vec![resource];
4075 let status = run_once(single);
4076 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
4077 first_failure = Some(status);
4078 }
4079 }
4080 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
4081 }
4082 watch::start_watching();
4084 let resource = resource.as_str();
4085 if !is_url(resource) {
4086 return watch::watch_files(cli);
4087 }
4088 if is_url(resource) {
4090 #[cfg(not(feature = "fetch"))]
4091 {
4092 cli.watch = false;
4093 return run_once(cli);
4094 }
4095 }
4096
4097 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
4098 let mut previous = None;
4099 loop {
4100 #[cfg(feature = "fetch")]
4101 let cached = if is_url(resource) && cli.watch_cache {
4102 Some(fetch_url(resource, cli.extensions.encoding))
4103 } else {
4104 None
4105 };
4106 #[cfg(not(feature = "fetch"))]
4107 let cached: Option<Result<(String, Option<String>), String>> = None;
4108 let fingerprint = match cached.as_ref() {
4109 Some(Ok((body, content_type))) => {
4110 use std::hash::{Hash, Hasher};
4111 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4112 body.hash(&mut hasher);
4113 content_type.hash(&mut hasher);
4114 format!("url:{:x}", hasher.finish())
4115 }
4116 Some(Err(error)) => format!("url-error:{error}"),
4117 None => watch_fingerprint(resource, false, cli.extensions.encoding),
4118 };
4119 let changed = previous.as_ref() != Some(&fingerprint);
4120 if changed || (is_url(resource) && !cli.watch_cache) {
4121 previous = Some(fingerprint);
4122 let console = Console::new();
4125 console.clear();
4126 console.control(&Control::home());
4127 let _ = std::io::stdout().flush();
4128 let mut iteration = cli.clone();
4129 iteration.watch = false;
4130 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
4131 if status != ExitClass::Success.exit_code() {
4132 eprintln!("rich: watch will retry after the next change");
4135 }
4136 }
4137 std::thread::sleep(interval);
4138 }
4139}
4140
4141fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
4142 if is_url(resource) {
4143 #[cfg(feature = "fetch")]
4144 if cache_url {
4145 return match fetch_url(resource, encoding) {
4146 Ok((body, content_type)) => {
4147 use std::hash::{Hash, Hasher};
4148 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4149 body.hash(&mut hasher);
4150 content_type.hash(&mut hasher);
4151 format!("url:{:x}", hasher.finish())
4152 }
4153 Err(error) => format!("url-error:{error}"),
4154 };
4155 }
4156 #[cfg(not(feature = "fetch"))]
4157 let _ = (cache_url, encoding);
4158 return "url".to_string();
4159 }
4160 match std::fs::metadata(fs_path(resource)) {
4161 Ok(metadata) if metadata.is_file() => {
4162 use std::hash::Hasher;
4163 let mut file = match std::fs::File::open(fs_path(resource)) {
4167 Ok(file) => file,
4168 Err(error) => return format!("file-error:{error}"),
4169 };
4170 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4171 let mut buffer = [0u8; 64 * 1024];
4172 loop {
4173 match file.read(&mut buffer) {
4174 Ok(0) => break,
4175 Ok(count) => hasher.write(&buffer[..count]),
4176 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
4177 Err(error) => return format!("file-error:{error}"),
4178 }
4179 }
4180 format!("file:{:x}", hasher.finish())
4181 }
4182 Ok(_) => "not-file".to_string(),
4183 Err(_) => "missing".to_string(),
4184 }
4185}
4186
4187fn run_once(cli: Cli) -> ExitCode {
4188 run_once_with_fetch(cli, None)
4189}
4190
4191fn cli_theme(cli: &Cli) -> rich::Theme {
4194 let mut theme = rich::Theme::default_theme();
4195 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
4196 theme.insert(name.clone(), style.clone());
4197 }
4198 theme
4199}
4200
4201impl Cli {
4202 fn themed(&self) -> bool {
4204 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
4205 }
4206}
4207
4208fn read_theme_file(
4216 path: &str,
4217 label: &str,
4218) -> Result<std::collections::BTreeMap<String, Style>, String> {
4219 let limit = controls::THEME_FILE_LIMIT;
4220 let path = &fs_path(path);
4222 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
4224 if !metadata.is_file() {
4225 return Err(format!("{label}: not a regular file"));
4226 }
4227 let too_large = || {
4228 format!(
4229 "{label}: larger than {}, the most read from a theme file",
4230 controls::size(limit)
4231 )
4232 };
4233 if metadata.len() > limit {
4234 return Err(too_large());
4235 }
4236 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4237 let mut bytes = Vec::new();
4238 file.take(limit + 1)
4239 .read_to_end(&mut bytes)
4240 .map_err(|err| format!("{label}: {err}"))?;
4241 if bytes.len() as u64 > limit {
4242 return Err(too_large());
4243 }
4244 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4245 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4246 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4248 _ => match bad_style_line(&text) {
4249 Some(line) => format!("{label}: line {line}: {err}"),
4250 None => format!("{label}: {err}"),
4251 },
4252 })?;
4253 Ok(theme
4254 .names()
4255 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4256 .collect())
4257}
4258
4259fn bad_style_line(text: &str) -> Option<usize> {
4262 let mut in_styles = false;
4263 for (index, line) in text.lines().enumerate() {
4264 let trimmed = line.trim();
4265 if trimmed.starts_with('[') {
4266 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4267 continue;
4268 }
4269 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4270 continue;
4271 }
4272 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4273 continue;
4274 };
4275 if Style::parse(value.trim()).is_err() {
4276 return Some(index + 1);
4277 }
4278 }
4279 None
4280}
4281
4282fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4283 if cli.sanitize {
4284 cli.title = cli
4285 .title
4286 .take()
4287 .map(|title| sanitize_terminal_controls(&title));
4288 cli.caption = cli
4289 .caption
4290 .take()
4291 .map(|caption| sanitize_terminal_controls(&caption));
4292 }
4293
4294 if cli.batch {
4295 return run_batch(&cli);
4296 }
4297
4298 if cli.mode == Mode::Auto && cli.resource.is_none() {
4300 run_demo(cli.no_color, std::time::Duration::ZERO);
4301 return success(&cli);
4302 }
4303
4304 let mut mode = match cli.mode {
4305 Mode::Auto => detect_mode(cli.resource.as_deref()),
4306 other => other,
4307 };
4308 if let Some(message) = transform_misplaced_in(&cli, mode) {
4309 return fail(&cli, ExitClass::Usage, message);
4310 }
4311 if matches!(cli.mode, Mode::Auto | Mode::Man) {
4313 if let Some(message) = cli
4314 .resource
4315 .as_deref()
4316 .and_then(documents::compressed_man_page)
4317 {
4318 return fail(&cli, ExitClass::Input, message);
4319 }
4320 }
4321
4322 let mut prefetched = prefetched;
4326 let mut view_as = None;
4327 let mut view_bytes = Vec::new();
4328 if mode == Mode::View {
4329 let ext = cli
4330 .resource
4331 .as_deref()
4332 .filter(|r| *r != "-")
4333 .and_then(resource_ext);
4334 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4337 if !matches!(
4338 by_name,
4339 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4340 ) {
4341 let limit = if by_name == viewers::ViewAs::Notebook {
4344 controls::INSPECT_LIMIT
4345 } else {
4346 controls::VIEW_LIMIT
4347 };
4348 let more;
4349 (view_bytes, more) = match read_window(&cli, 0, limit) {
4350 Ok(read) => read,
4351 Err(err) => return fail(&cli, ExitClass::Input, err),
4352 };
4353 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4354 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4355 if kind == viewers::ViewAs::Binary {
4356 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4357 view_bytes.truncate(controls::BYTES_LIMIT as usize);
4358 viewers::notice(
4359 name,
4360 &controls::size(controls::BYTES_LIMIT),
4361 "use rich hex with --offset and --length for the rest",
4362 );
4363 }
4364 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4365 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4366 {
4367 view_bytes.truncate(end);
4369 viewers::notice(
4370 name,
4371 &format!("{} lines", controls::LINE_LIMIT),
4372 "use a pager or rich --syntax for the whole file",
4373 );
4374 } else if more {
4375 viewers::notice(
4376 name,
4377 &controls::size(limit),
4378 "use a pager or rich --syntax for the whole file",
4379 );
4380 }
4381 }
4382 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4383 mode = match kind {
4384 viewers::ViewAs::Markdown => Mode::Markdown,
4385 viewers::ViewAs::Csv => Mode::Csv,
4386 viewers::ViewAs::Notebook => Mode::Ipynb,
4387 viewers::ViewAs::Image => Mode::Image,
4388 viewers::ViewAs::Gif => Mode::Gif,
4389 viewers::ViewAs::Log => Mode::Log,
4390 _ => Mode::View,
4391 };
4392 let text = String::from_utf8_lossy(&view_bytes).into_owned();
4393 prefetched = Some((text, None));
4394 view_as = Some(kind);
4395 }
4396
4397 let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
4402 if cli.themed() {
4403 builder = builder.theme(cli_theme(&cli));
4404 }
4405 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4408 builder = builder.force_terminal(terminal == "1");
4409 }
4410 if cli.force_terminal {
4411 builder = builder.force_terminal(true);
4412 }
4413 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4414 builder = builder.width(width);
4415 }
4416 if let Some(width) = watch::capture_width() {
4419 builder = builder.width(width);
4420 }
4421 let mut console = builder.build();
4422 render_target::attach(&mut console);
4423 console.install_extensions();
4424 plugins::install_highlighters(&mut console);
4425 apply_code_highlighting(&mut console, &cli);
4426
4427 if mode == Mode::Rule {
4428 if let Some(title) = &cli.resource {
4429 if let Err(err) = Text::from_markup(title) {
4430 return fail(&cli, ExitClass::Data, err.to_string());
4431 }
4432 }
4433 }
4434
4435 let literal = mode == Mode::Rule
4441 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4442 let mut svg_title = cli
4443 .resource
4444 .as_deref()
4445 .filter(|r| *r != "-" && !literal)
4446 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4447 .unwrap_or_else(|| "rich".to_string());
4448 if cli.sanitize {
4449 svg_title = sanitize_terminal_controls(&svg_title);
4450 }
4451 #[allow(unused_mut)]
4452 let mut export = Export {
4453 html_path: cli.export_html.as_deref(),
4454 svg_path: cli.export_svg.as_deref(),
4455 png_path: cli.export_png.as_deref(),
4456 svg_title: &svg_title,
4457 pager: cli.pager,
4458 auto_pager: cli.auto_pager,
4459 };
4460
4461 if mode == Mode::Gif {
4464 return play_gifs(&cli, &console);
4465 }
4466
4467 if mode == Mode::Diff {
4470 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4471 if !images {
4472 return run_text_diff(&cli, &console, &export);
4473 }
4474 if let Some(flag) = cli.tools.diff_option() {
4475 return fail(
4476 &cli,
4477 ExitClass::Usage,
4478 format!("{flag} applies to text diffs, not images"),
4479 );
4480 }
4481 #[cfg(feature = "art")]
4482 return run_diff(&cli, &console, &export);
4483 #[cfg(not(feature = "art"))]
4484 return fail(
4485 &cli,
4486 ExitClass::Usage,
4487 "this build has no image support (rebuild with the `art` feature)",
4488 );
4489 }
4490
4491 #[cfg(feature = "art")]
4494 if mode == Mode::Image {
4495 return run_image(&cli, &console, &export);
4496 }
4497 #[cfg(not(feature = "art"))]
4498 if mode == Mode::Image {
4499 return fail(
4500 &cli,
4501 ExitClass::Usage,
4502 "this build has no image support (rebuild with the `art` feature)",
4503 );
4504 }
4505
4506 if mode.streams_records() {
4507 return run_json_lines(&cli, &console, mode == Mode::Log);
4508 }
4509
4510 if mode == Mode::Rule {
4514 let mut rule = match cli.resource.as_deref() {
4515 Some(title) if title != "-" && cli.sanitize => {
4516 Rule::new(sanitize_terminal_controls(title))
4517 }
4518 Some(title) if title != "-" => Rule::new(title),
4519 _ => Rule::line(),
4520 };
4521 let style = cli
4526 .rule_style
4527 .clone()
4528 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4529 rule = rule.style(style);
4530 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4531 if cli.sanitize {
4534 rule = rule.characters(sanitize_terminal_controls(characters));
4535 } else {
4536 rule = rule.characters(characters);
4537 }
4538 }
4539 match cli.text_justify {
4540 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4541 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4542 _ => {}
4543 }
4544 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4547 }
4548
4549 if mode == Mode::Capture {
4551 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4552 Ok(redactor) => redactor,
4553 Err(err) => return fail(&cli, ExitClass::Usage, err),
4554 };
4555 let preview = viewers::redact_preview(&cli.viewers);
4556 if preview && redactor.is_none() {
4557 return fail(
4558 &cli,
4559 ExitClass::Usage,
4560 "--redact-preview needs --redact or --redact-pattern",
4561 );
4562 }
4563 let width = cli
4566 .width
4567 .unwrap_or_else(|| console.width())
4568 .saturating_sub(4)
4569 .max(1);
4570 let cast = match viewers::cast_path(&cli.viewers).filter(|_| !preview) {
4573 Some(path) => match std::fs::File::create(path) {
4574 Ok(file) => Some((path, file)),
4575 Err(err) => {
4576 return fail(
4577 &cli,
4578 ExitClass::Input,
4579 format!("cannot write {path}: {err}"),
4580 )
4581 }
4582 },
4583 None => None,
4584 };
4585 let mut captured = match viewers::capture(&cli.resources, width) {
4586 Ok(captured) => captured,
4587 Err(err) => return fail(&cli, ExitClass::Input, err),
4588 };
4589 if let Some(notice) = &captured.notice {
4590 eprintln!("rich: capture: {notice}");
4591 }
4592 if let (true, Some(redactor)) = (preview, &redactor) {
4593 let would_write: Vec<String> = [
4594 viewers::cast_path(&cli.viewers),
4595 export.html_path,
4596 export.svg_path,
4597 export.png_path,
4598 ]
4599 .into_iter()
4600 .flatten()
4601 .map(str::to_string)
4602 .collect();
4603 let previews = redact_preview::capture(&captured, redactor);
4604 return redact_preview::emit(&cli, &console, previews, &would_write);
4605 }
4606 if let Some(redactor) = &redactor {
4608 captured.redact(redactor);
4609 }
4610 if cli.sanitize {
4611 captured.sanitize();
4612 }
4613 if let Some((path, mut file)) = cast {
4614 let cast = viewers::asciicast(&captured, width, console.height());
4615 if let Err(err) = file.write_all(cast.as_bytes()) {
4616 return fail(
4617 &cli,
4618 ExitClass::Input,
4619 format!("cannot write {path}: {err}"),
4620 );
4621 }
4622 }
4623 let view = viewers::capture_view(&captured);
4624 let fit = view.measure(&console, &console.options()).maximum;
4625 let code = captured.exit_code();
4628 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4629 if code == 0 || shown.is_err() {
4630 return emit_exit_code(cli, shown);
4631 }
4632 if cli.report_format == ReportFormat::Json {
4634 eprintln!("{}", viewers::capture_report(&captured));
4635 }
4636 ExitCode::from(code)
4637 });
4638 }
4639 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4640 return fail(
4641 &cli,
4642 ExitClass::Usage,
4643 "this build has no Mermaid support (the mermaid feature)",
4644 );
4645 }
4646 if mode == Mode::Env {
4647 let view = viewers::env(&cli.viewers, &cli.resources);
4648 let fit = view.measure(&console, &console.options()).maximum;
4649 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4650 }
4651 if mode == Mode::Profile {
4652 let profile = match profile::profile(&cli) {
4653 Ok(profile) => profile,
4654 Err((class, err)) => return fail(&cli, class, err),
4655 };
4656 if cli.report_format == ReportFormat::Json {
4658 let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4659 authoring::out(&format!("{json}\n"));
4660 return ExitClass::Success.exit_code();
4661 }
4662 let view: Box<dyn Renderable> = Box::new(profile);
4663 let fit = view.measure(&console, &console.options()).maximum;
4664 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4665 }
4666 if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4667 let view = match mode {
4668 Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4669 Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4670 _ => chart::chart(&cli).map(|view| (view, None)),
4671 };
4672 let (view, gate) = match view {
4673 Ok(view) => view,
4674 Err((class, err)) => return fail(&cli, class, err),
4675 };
4676 let fit = view.measure(&console, &console.options()).maximum;
4677 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4680 match gate {
4681 Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4682 _ => emit_exit_code(cli, shown),
4683 }
4684 });
4685 }
4686 if matches!(mode, Mode::Hex | Mode::Unicode) {
4687 let hex = mode == Mode::Hex;
4690 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4691 let length = cli.viewers.length().filter(|_| hex);
4692 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4693 let (bytes, more) = match read_window(&cli, offset, max) {
4694 Ok(read) => read,
4695 Err(err) => return fail(&cli, ExitClass::Input, err),
4696 };
4697 if more && length.is_none() {
4698 let hint = if hex {
4699 "use --offset and --length for the rest"
4700 } else {
4701 "pass a shorter input"
4702 };
4703 viewers::notice(
4704 cli.resource.as_deref().unwrap_or("<stdin>"),
4705 &controls::size(controls::BYTES_LIMIT),
4706 hint,
4707 );
4708 }
4709 let view = if hex {
4710 viewers::hex(&cli.viewers, bytes, offset)
4711 } else {
4712 viewers::unicode(&cli.viewers, &bytes)
4713 };
4714 let view = match view {
4715 Ok(view) => view,
4716 Err(err) => return fail(&cli, ExitClass::Usage, err),
4717 };
4718 let fit = view.measure(&console, &console.options()).maximum;
4719 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4720 }
4721
4722 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4726 let (mut content, content_type) = if let Some(fetched) = prefetched {
4727 fetched
4728 } else if resource_is_url {
4729 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4730 Ok(fetched) => fetched,
4731 Err(err) => {
4732 return fail(&cli, ExitClass::Input, err);
4733 }
4734 }
4735 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4736 (cli.resource.clone().unwrap(), None)
4737 } else {
4738 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4740 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4741 Ok(content) => (content, None),
4742 Err(err) => {
4743 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4744 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4745 }
4746 }
4747 };
4748 if cli.sanitize && mode != Mode::AnsiExplain {
4751 content = sanitize_terminal_controls(&content);
4752 }
4753
4754 let mut routed_language = None;
4757 let mut routed = false;
4758 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4759 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4760 routed = true;
4761 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4762 inspect::Route::Json => Mode::Json,
4763 inspect::Route::Syntax(lexer) => {
4764 routed_language = Some(lexer);
4765 Mode::Syntax
4766 }
4767 inspect::Route::Text => Mode::Auto,
4768 };
4769 }
4770 }
4771
4772 if cli.mode == Mode::Auto
4777 && !cli.source.wants_syntax()
4778 && (mode == Mode::Auto || (mode == Mode::Syntax && !routed))
4779 {
4780 let auto = cli.data.format == Some(inspect::InputFormat::Auto);
4781 if let Some(document) = documents::by_content(cli.resource.as_deref(), &content, auto) {
4782 mode = document;
4783 }
4784 }
4785
4786 if resource_is_url && mode == Mode::Auto && !routed {
4790 mode = content_type
4791 .as_deref()
4792 .and_then(content_type_mode)
4793 .unwrap_or(Mode::Syntax);
4794 }
4795
4796 if mode == Mode::Auto && cli.source.wants_syntax() {
4799 mode = Mode::Syntax;
4800 }
4801
4802 let json_content = if mode == Mode::Json && cli.sanitize {
4804 match sanitize_json_source(content.trim()) {
4805 Ok(content) => Some(content),
4806 Err(err) => {
4807 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4808 }
4809 }
4810 } else {
4811 None
4812 };
4813
4814 let json = if mode == Mode::Json {
4815 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4816 match Json::new(source) {
4817 Ok(json) => Some(json),
4818 Err(err) => {
4819 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4820 }
4821 }
4822 } else {
4823 None
4824 };
4825
4826 if mode == Mode::Ipynb {
4831 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4832 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4833 if cli.sanitize {
4834 sanitize_json_value(&mut value);
4835 }
4836 value
4837 }
4838 Ok(_) => {
4839 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4840 }
4841 Err(err) => {
4842 return fail(
4843 &cli,
4844 ExitClass::Data,
4845 format!("invalid notebook JSON: {err}"),
4846 );
4847 }
4848 };
4849 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4850 Ok(renderable) => renderable,
4851 Err(err) => return fail(&cli, ExitClass::Usage, err),
4852 };
4853 let fit = renderable.measure(&console, &console.options()).maximum;
4854 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4855 }
4856
4857 if mode == Mode::Print {
4861 if let Err(err) = console.try_build_text(&content) {
4862 return fail(&cli, ExitClass::Data, err.to_string());
4863 }
4864 }
4865
4866 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4871 .filter(|ext| !is_uninformative_ext(ext));
4872 let language = routed_language
4873 .map(str::to_string)
4874 .or(extension)
4875 .or_else(|| {
4876 content_type
4877 .as_deref()
4878 .and_then(content_type_lexer)
4879 .map(str::to_string)
4880 })
4881 .unwrap_or_default();
4882
4883 if let Some(message) = transform_misplaced_in(&cli, mode) {
4885 return fail(&cli, ExitClass::Usage, message);
4886 }
4887
4888 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4891 Mode::Markdown => {
4894 #[cfg(feature = "art")]
4896 let micro = micro::markdown(&console, &content, cli.micro_project);
4897 #[cfg(feature = "art")]
4898 let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
4899 #[cfg(not(feature = "art"))]
4900 let source = content.as_str();
4901 let mut markdown = build_markdown(source, cli.hyperlinks);
4902 if let Some(fences) = plugin_registry_with(
4905 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4906 cli.dot_backend != Some(sources::DotBackend::Off),
4907 )
4908 .fences()
4909 {
4910 markdown = markdown.fence_renderer(fences);
4911 }
4912 #[cfg(feature = "art")]
4913 let view: Box<dyn Renderable> = match micro {
4914 Some(micro) => Box::new(micro.view(markdown)),
4915 None => Box::new(markdown),
4916 };
4917 #[cfg(not(feature = "art"))]
4918 let view: Box<dyn Renderable> = Box::new(markdown);
4919 (view, None)
4920 }
4921 Mode::Rst => {
4925 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4926 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4927 (Box::new(document), None)
4928 }
4929 Mode::Org | Mode::AsciiDoc | Mode::Man => {
4932 let fences = plugin_registry_with(
4933 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4934 cli.dot_backend != Some(sources::DotBackend::Off),
4935 )
4936 .fences();
4937 (documents::render(mode, &content, &cli, fences), None)
4938 }
4939 #[cfg(feature = "mermaid")]
4940 Mode::Mermaid => {
4941 let default = if cfg!(feature = "mmdc") {
4943 MermaidBackend::Mmdc
4944 } else {
4945 MermaidBackend::Text
4946 };
4947 let backend = match cli.mermaid_backend.unwrap_or(default) {
4948 MermaidBackend::Off => MermaidBackend::Text,
4949 chosen => chosen,
4950 };
4951 let diagram =
4952 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4953 (Box::new(diagram), None)
4954 }
4955 Mode::Dot => {
4956 let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
4959 let mut svg_done = false;
4960 if let (true, Some(path)) = (graphviz, export.svg_path) {
4961 svg_done = sources::graphviz_svg(&content, path);
4962 if svg_done {
4963 export.svg_path = None;
4964 }
4965 }
4966 match sources::dot(&content, cli.resource.as_deref()) {
4967 Ok(diagram) => (diagram, None),
4968 Err((_, err)) if svg_done => {
4971 eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
4972 return success(&cli);
4973 }
4974 Err((class, err)) => return fail(&cli, class, err),
4975 }
4976 }
4977 Mode::Json => {
4978 let nested = cli.padding.is_some()
4995 || cli.panel.is_some()
4996 || cli.style.is_some()
4997 || cli.width.is_some()
4998 || cli.pager
4999 || cli.auto_pager;
5000 let json = json.expect("json parsed above").no_wrap(nested);
5001 #[cfg(feature = "json-escape-safe")]
5002 let json = json.escape_safe(true);
5003 let fit = measure_rendered(&console, &json);
5004 (Box::new(json), Some(fit))
5005 }
5006 Mode::Csv => {
5007 if let Err(err) = validate_labels(&cli) {
5008 return fail(&cli, ExitClass::Data, err);
5009 }
5010 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
5013 let rules = cli.rules.rules();
5014 let table = match build_csv_table_with(
5015 &content,
5016 fallback,
5017 cli.title.as_deref(),
5018 cli.caption.as_deref(),
5019 (cli.source.head, cli.source.tail),
5020 cli.source.infer,
5021 (!rules.is_empty()).then_some((&console, rules)),
5022 ) {
5023 Some(table) => table,
5024 None => {
5027 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
5028 }
5029 };
5030 #[cfg(feature = "art")]
5033 let table = {
5034 let mut table = table;
5035 let label = |label: &Option<String>, style| {
5036 label.as_deref().and_then(|label| {
5037 micro::table_label(&console, label, style, cli.micro_project)
5038 })
5039 };
5040 if let Some(title) = label(&cli.title, "table.title") {
5041 table = table.title_text(title);
5042 }
5043 if let Some(caption) = label(&cli.caption, "table.caption") {
5044 table = table.caption_text(caption);
5045 }
5046 table
5047 };
5048 if cli.panel.is_none()
5052 && cli.padding.is_none()
5053 && cli.style.is_none()
5054 && cli.justify.is_none()
5055 && cli.max_width.is_none()
5056 && !cli.soft
5057 && !cli.pager
5058 && !cli.auto_pager
5059 && cli.export_html.is_none()
5060 && cli.export_svg.is_none()
5061 && cli.export_png.is_none()
5062 && !watch::capturing()
5063 && !micro_pending()
5064 {
5065 let mut options = console.options();
5066 options.max_width = cli.width.unwrap_or_else(|| {
5067 table
5068 .measure(&console, &options)
5069 .maximum
5070 .min(options.max_width)
5071 .max(1)
5072 });
5073 let stdout = std::io::stdout();
5074 let mut output = std::io::BufWriter::new(stdout.lock());
5075 let result = table
5076 .try_for_each_line(&console, &options, |line| {
5077 let line = Segment::crop_lines(&line, console.width());
5078 writeln!(output, "{}", console.segments_to_string(&line))
5079 })
5080 .and_then(|()| output.flush());
5081 if let Err(error) = result {
5082 if error.kind() == std::io::ErrorKind::BrokenPipe {
5085 return success(&cli);
5086 }
5087 return fail(
5088 &cli,
5089 ExitClass::Input,
5090 format!("could not write CSV output: {error}"),
5091 );
5092 }
5093 return success(&cli);
5094 }
5095 let fit = measure_rendered(&console, &table);
5096 (Box::new(table), Some(fit))
5097 }
5098 Mode::Syntax if cli.data.transform_option().is_some() => {
5101 let language = cli.source.lexer.as_deref().unwrap_or(&language);
5102 let code = match cli.source.line_range(&content) {
5105 Ok(Some((start, end))) => {
5106 let start = start.max(1) as usize - 1;
5107 let end = end.max(0) as usize;
5108 let lines: Vec<&str> = content.lines().collect();
5109 lines
5110 .get(start.min(lines.len())..end.min(lines.len()))
5111 .unwrap_or_default()
5112 .join("\n")
5113 }
5114 Ok(None) => content.clone(),
5115 Err(err) => return fail(&cli, ExitClass::Usage, err),
5116 };
5117 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
5118 let text = match cli
5119 .data
5120 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5121 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5122 {
5123 Ok(text) => text,
5124 Err(err) => return fail(&cli, ExitClass::Data, err),
5125 };
5126 let fit = text.measurement().1;
5127 let background = match text.base_style() {
5130 rich::style::StyleType::Style(style) => style.clone(),
5131 rich::style::StyleType::Name(_) => rich::Style::new(),
5132 };
5133 let padded =
5136 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
5137 (Box::new(padded), Some(fit))
5138 }
5139 Mode::Syntax => {
5140 let syntax = match cli.source.syntax(&content, &language) {
5141 Ok(syntax) => syntax,
5142 Err(err) => return fail(&cli, ExitClass::Usage, err),
5143 };
5144 let fit = syntax.measure(&console, &console.options()).maximum;
5147 (Box::new(syntax), Some(fit))
5148 }
5149 Mode::View => {
5150 let view: Box<dyn Renderable> = match view_as {
5151 Some(viewers::ViewAs::Patch) => {
5152 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
5153 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
5154 Ok(outcome) => outcome.renderable,
5155 Err(err) => return fail(&cli, ExitClass::Data, err),
5156 }
5157 }
5158 Some(viewers::ViewAs::Binary) => {
5159 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
5160 Ok(view) => view,
5161 Err(err) => return fail(&cli, ExitClass::Usage, err),
5162 }
5163 }
5164 Some(viewers::ViewAs::Source(ref lexer)) => {
5165 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
5166 eprintln!("{summary}");
5167 }
5168 viewers::source(&cli.viewers, &content, lexer)
5169 }
5170 _ => viewers::source(&cli.viewers, &content, "txt"),
5171 };
5172 let fit = view.measure(&console, &console.options()).maximum;
5173 (view, Some(fit))
5174 }
5175 Mode::AnsiExplain => {
5176 let view = tools::ansi_explain(&cli.tools, &content);
5177 let fit = view.measure(&console, &console.options()).maximum;
5178 (view, Some(fit))
5179 }
5180 Mode::Inspect => {
5181 let encoding = cli.extensions.encoding;
5182 let read = |path: &str| {
5183 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
5184 .map_err(|err| format!("cannot read {path}: {err}"))
5185 };
5186 let rules = cli.rules.rules();
5187 let view = match inspect::build_with(
5188 &cli.data,
5189 &content,
5190 cli.resource.as_deref(),
5191 read,
5192 rules,
5193 ) {
5194 Ok(view) => view,
5195 Err(err) => return fail(&cli, ExitClass::Data, err),
5196 };
5197 let fit = view.measure(&console, &console.options()).maximum;
5198 (view, Some(fit))
5199 }
5200 _ => {
5203 #[cfg(feature = "art")]
5206 let micro = (mode == Mode::Print && cli.emoji)
5207 .then(|| micro::print_markup(&console, &content, cli.micro_project))
5208 .flatten();
5209 #[cfg(feature = "art")]
5210 let mut text = match micro {
5211 Some(text) => text,
5212 None if mode == Mode::Print => console.build_text(&content),
5213 None => Text::new(content.as_str()),
5214 };
5215 #[cfg(not(feature = "art"))]
5216 let mut text = if mode == Mode::Print {
5217 console.build_text(&content)
5218 } else {
5219 Text::new(content.as_str())
5220 };
5221 if let Some(justify) = cli.text_justify {
5225 text.set_justify(justify);
5226 }
5227 if cli.source.no_wrap {
5228 text.set_no_wrap(Some(true));
5229 }
5230 let text = match cli
5231 .data
5232 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5233 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5234 {
5235 Ok(text) => text,
5236 Err(err) => return fail(&cli, ExitClass::Data, err),
5237 };
5238 let fit = text.measurement().1;
5239 #[cfg(feature = "art")]
5241 let renderable = micro::rows(text);
5242 #[cfg(not(feature = "art"))]
5243 let renderable: Box<dyn Renderable> = Box::new(text);
5244 (renderable, Some(fit))
5245 }
5246 };
5247 decorate_and_emit(&cli, &console, &export, renderable, fit)
5248}
5249
5250#[allow(dead_code)]
5253fn exit_code(ok: bool) -> ExitCode {
5254 if ok {
5255 ExitCode::SUCCESS
5256 } else {
5257 ExitCode::FAILURE
5258 }
5259}
5260
5261fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
5262 match result {
5263 Ok(()) => success(cli),
5264 Err(message) => fail(cli, ExitClass::Input, message),
5265 }
5266}
5267
5268fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
5269 let output = console.render_export(renderable);
5270 let stdout = std::io::stdout();
5271 let mut lock = stdout.lock();
5272 lock.write_all(output.as_bytes())
5273}
5274
5275fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
5276 if cli.extensions.encoding.is_some() {
5277 return fail(
5278 cli,
5279 ExitClass::Usage,
5280 "--encoding is not supported with streaming JSONL/log mode",
5281 );
5282 }
5283 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
5284 return fail(
5285 cli,
5286 ExitClass::Usage,
5287 "streaming JSONL/log mode reads files or stdin, not URLs",
5288 );
5289 }
5290 if log_mode {
5291 return logs::run(cli, console);
5292 }
5293
5294 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
5295 Some(path) if path != "-" => {
5296 let file = &fs_path(path);
5297 if file.is_dir() {
5298 return fail(
5299 cli,
5300 ExitClass::Input,
5301 format!("cannot read {path}: is a directory, not a file"),
5302 );
5303 }
5304 match std::fs::File::open(file) {
5305 Ok(file) => Box::new(std::io::BufReader::new(file)),
5306 Err(err) => {
5307 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5308 }
5309 }
5310 }
5311 _ => {
5312 if std::io::stdin().is_terminal() {
5313 let eof = if cfg!(windows) {
5314 "Ctrl-Z then Enter"
5315 } else {
5316 "Ctrl-D"
5317 };
5318 eprintln!("rich: reading stdin; finish input with {eof}");
5319 }
5320 Box::new(std::io::BufReader::new(std::io::stdin()))
5321 }
5322 };
5323
5324 let mut line = String::new();
5325 let mut line_number = 0usize;
5326 loop {
5327 line.clear();
5328 let read = match input.read_line(&mut line) {
5329 Ok(read) => read,
5330 Err(err) => {
5331 return fail(
5332 cli,
5333 ExitClass::Input,
5334 format!("cannot read JSONL stream: {err}"),
5335 );
5336 }
5337 };
5338 if read == 0 {
5339 break;
5340 }
5341 line_number += 1;
5342 let record = line.trim_end_matches(['\r', '\n']);
5343 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5344 Ok(value) => value,
5345 Err(err) => {
5346 return fail(
5347 cli,
5348 ExitClass::Data,
5349 format!("invalid JSONL at line {line_number}: {err}"),
5350 );
5351 }
5352 };
5353 if cli.sanitize {
5354 sanitize_json_value(&mut value);
5355 }
5356 let source = serde_json::to_string(&value).expect("JSON value serializes");
5357 let json = Json::new(&source).expect("serialized JSON parses");
5358 let wrote = write_rendered_stdout(console, &json);
5359 if let Err(error) = wrote {
5360 if error.kind() == std::io::ErrorKind::BrokenPipe {
5361 return success(cli);
5362 }
5363 return fail(
5364 cli,
5365 ExitClass::Input,
5366 format!("could not write JSONL output: {error}"),
5367 );
5368 }
5369 }
5370 success(cli)
5371}
5372
5373fn is_number(value: &str) -> bool {
5379 let body = value.strip_prefix('-').unwrap_or(value);
5380 let (integer, fraction) = match body.split_once('.') {
5381 Some((integer, fraction)) => (integer, Some(fraction)),
5382 None => (body, None),
5383 };
5384 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5385 digits(integer) && fraction.is_none_or(digits)
5386}
5387
5388fn build_csv_table(
5407 content: &str,
5408 fallback_delimiter: Option<char>,
5409 title: Option<&str>,
5410 caption: Option<&str>,
5411 window: (Option<usize>, Option<usize>),
5412 infer: bool,
5413) -> Option<Table> {
5414 build_csv_table_with(
5415 content,
5416 fallback_delimiter,
5417 title,
5418 caption,
5419 window,
5420 infer,
5421 None,
5422 )
5423}
5424
5425fn build_csv_table_with(
5428 content: &str,
5429 fallback_delimiter: Option<char>,
5430 title: Option<&str>,
5431 caption: Option<&str>,
5432 window: (Option<usize>, Option<usize>),
5433 infer: bool,
5434 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5435) -> Option<Table> {
5436 let sample: String = content.chars().take(1024).collect();
5439 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5440 let (dialect, header) = match sniffed {
5441 Some(sniffed) => sniffed,
5442 None => (Dialect::excel(fallback_delimiter?), true),
5443 };
5444 let mut rows = read_rows(content, &dialect);
5445 if window.0.is_some() || window.1.is_some() {
5448 let header_row = if header && !rows.is_empty() {
5449 Some(rows.remove(0))
5450 } else {
5451 None
5452 };
5453 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5454 match window {
5455 (Some(head), _) => data.truncate(head),
5456 (None, Some(tail)) => {
5457 data.drain(..data.len().saturating_sub(tail));
5458 }
5459 (None, None) => {}
5460 }
5461 rows = header_row.into_iter().chain(data).collect();
5462 }
5463 let types = infer.then(|| infer_csv_types(&rows, header));
5464 Some(render_csv(rows, header, title, caption, types, rules))
5465}
5466
5467fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5471 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5472 let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5473 for row in rows
5474 .iter()
5475 .skip(usize::from(has_header))
5476 .filter(|row| !row.is_empty())
5477 {
5478 data.push(
5479 (0..width)
5480 .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5481 );
5482 }
5483 Inferrer::new()
5484 .infer(&data)
5485 .columns()
5486 .iter()
5487 .map(|column| column.data_type())
5488 .collect()
5489}
5490
5491fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5498 let name = resource.unwrap_or_default().to_lowercase();
5499 if name.ends_with(".csv") {
5500 Some(',')
5501 } else if name.ends_with(".tsv") {
5502 Some('\t')
5503 } else {
5504 None
5505 }
5506}
5507
5508fn render_csv(
5522 rows: Vec<Vec<String>>,
5523 has_header: bool,
5524 title: Option<&str>,
5525 caption: Option<&str>,
5526 types: Option<Vec<InferredType>>,
5527 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5528) -> Table {
5529 let mut table = Table::new()
5530 .border_style(Style::parse("blue").expect("valid style"))
5531 .show_header(has_header || types.is_some())
5532 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5533 if let Some(title) = title {
5534 table = table.title(title);
5535 }
5536 if let Some(caption) = caption {
5537 table = table.caption(caption);
5538 }
5539
5540 let empty: Vec<String> = Vec::new();
5541 let (header, body) = if has_header {
5542 match rows.split_first() {
5543 Some((header, body)) => (header, body),
5544 None => return table,
5545 }
5546 } else {
5547 (&empty, rows.as_slice())
5548 };
5549 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5551
5552 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5557 let columns = header.len().max(widest);
5558
5559 for index in 0..columns {
5560 let inferred = types
5565 .as_ref()
5566 .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5567 let numeric = match inferred {
5568 Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5569 None => data.iter().all(|row| match row.get(index) {
5570 Some(value) => value.is_empty() || is_number(value),
5571 None => false,
5572 }),
5573 };
5574 let name = header.get(index).map(String::as_str).unwrap_or("");
5575 if let Some(inferred) = inferred {
5576 let mut heading = Text::new(name);
5577 if !name.is_empty() {
5578 heading.append("\n", None);
5579 }
5580 heading.append(
5581 inferred.name(),
5582 Some(Style::parse("dim").expect("valid style").into()),
5583 );
5584 let justify = if numeric {
5585 Justify::Right
5586 } else {
5587 Justify::Left
5588 };
5589 table.add_column_text(heading, justify);
5590 } else if numeric {
5591 table.add_column_justify(name, Justify::Right);
5592 } else {
5593 table.add_column(name);
5594 }
5595 if numeric {
5596 table.column_style(Style::parse("bold green").expect("valid style"));
5597 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5598 }
5599 }
5600 drop(data);
5603 let headers: Vec<String> = match rules {
5604 Some(_) => (0..columns)
5605 .map(|index| header.get(index).cloned().unwrap_or_default())
5606 .collect(),
5607 None => Vec::new(),
5608 };
5609 let body = rows
5610 .into_iter()
5611 .skip(usize::from(has_header))
5612 .filter(|row| !row.is_empty());
5613 match rules.filter(|(_, rules)| !rules.is_empty()) {
5614 None => {
5615 table.extend_owned_rows(body.collect());
5616 }
5617 Some((console, rules)) => {
5618 add_styled_csv_rows(&mut table, console, rules, &headers, body.collect())
5619 }
5620 }
5621 table
5622}
5623
5624fn add_styled_csv_rows(
5628 table: &mut Table,
5629 console: &Console,
5630 rules: &rich_ext::table::StyleRules,
5631 headers: &[String],
5632 rows: Vec<Vec<String>>,
5633) {
5634 use rich_ext::table::{Target, Value};
5635 let headers: Vec<&str> = headers.iter().map(String::as_str).collect();
5636 let values = |row: &[String]| -> Vec<Value> { row.iter().cloned().map(Value::Str).collect() };
5637 let resolved = if rules
5639 .rules()
5640 .iter()
5641 .any(|r| r.target_kind() == Target::Column)
5642 {
5643 rules.resolve(&headers, rows.iter().map(|row| values(row)))
5644 } else {
5645 rules.resolve(&headers, std::iter::empty::<Vec<Value>>())
5646 };
5647 style_rules::warn_unresolved(&resolved, &headers);
5648 for row in rows {
5649 let cells: Vec<Value> = row.into_iter().map(Value::Str).collect();
5650 let row_style = resolved.row_style(&cells);
5651 let styles: Vec<Option<Style>> = (0..cells.len())
5652 .map(|index| resolved.cell_style(&cells, index))
5653 .collect();
5654 let cells = cells
5655 .into_iter()
5656 .zip(styles)
5657 .map(|(cell, style)| {
5658 let markup = match cell {
5659 Value::Str(text) => text,
5660 other => other.plain(),
5661 };
5662 match style {
5663 None => rich::Cell::Markup(markup),
5664 Some(style) => {
5665 let mut text = console.render_str(&markup, Some(false));
5666 let len = text.plain().len();
5667 text.stylize(style, 0, len);
5668 rich::Cell::Text(text)
5669 }
5670 }
5671 })
5672 .collect();
5673 table.add_row_with(cells, row_style.map(Into::into), false);
5674 }
5675}
5676
5677fn join_source(value: &serde_json::Value) -> String {
5680 match value {
5681 serde_json::Value::String(s) => s.clone(),
5682 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5683 _ => String::new(),
5684 }
5685}
5686
5687fn join_traceback(value: &serde_json::Value) -> String {
5691 match value {
5692 serde_json::Value::String(s) => s.clone(),
5693 serde_json::Value::Array(a) => a
5694 .iter()
5695 .filter_map(|v| v.as_str())
5696 .collect::<Vec<_>>()
5697 .join(
5698 "
5699",
5700 ),
5701 _ => String::new(),
5702 }
5703}
5704
5705fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5709 let base_style = Style::parse(base).ok();
5710 let number_style = Style::parse(number).ok();
5711 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5712 let mut text = Text::new("");
5713 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5714 text.append(&n, number_style.map(Into::into));
5715 text.append("]:", base_style.map(Into::into));
5716 text
5717}
5718
5719struct Notebook {
5722 items: Vec<Box<dyn Renderable>>,
5723 justify: Option<Justify>,
5726}
5727
5728impl Renderable for Notebook {
5729 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5730 let mut options = options.clone();
5731 if let Some(justify) = self.justify {
5732 options.justify = justify;
5733 }
5734 let mut segments = Vec::new();
5735 for item in &self.items {
5736 let rendered = item.rich_render(console, &options);
5737 if !rendered.is_empty() {
5738 segments.extend(rendered);
5739 segments.push(Segment::line());
5740 }
5741 }
5742 if !segments.is_empty() {
5744 segments.pop();
5745 }
5746 segments
5747 }
5748
5749 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5750 self.items
5751 .iter()
5752 .map(|item| item.measure(console, options))
5753 .fold(Measurement::new(0, 0), |width, item| {
5754 Measurement::new(
5755 width.minimum.max(item.minimum),
5756 width.maximum.max(item.maximum),
5757 )
5758 })
5759 }
5760}
5761
5762fn notebook_ansi(source: &str) -> Text {
5764 let mut result = Text::new("");
5765 for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5766 if index > 0 {
5767 result.append("\n", None);
5768 }
5769 result = result.append_text(line);
5770 }
5771 result
5772}
5773
5774fn build_ipynb(
5777 notebook: &serde_json::Value,
5778 hyperlinks: bool,
5779 justify: Option<Justify>,
5780 source_options: &SourceOptions,
5781) -> Result<Notebook, String> {
5782 let language = notebook["metadata"]["kernelspec"]["language"]
5783 .as_str()
5784 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5785 .unwrap_or("python");
5786 let empty = Vec::new();
5787 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5788 let mut new_line = true;
5789 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5790 if new_line {
5791 items.push(text(""));
5792 }
5793 if cell.get("execution_count").is_some() {
5794 items.push(Box::new(io_label(
5795 "In ",
5796 cell["execution_count"].as_i64().filter(|n| *n != 0),
5797 "green",
5798 "#66ff00",
5799 )));
5800 }
5801 let source = join_source(&cell["source"]);
5802 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5803 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5804 "code" => Box::new(
5807 Panel::new(Box::new(source_options.syntax(&source, language)?))
5808 .border_style(Style::parse("dim").expect("valid style")),
5809 ),
5810 _ => text(&source),
5811 });
5812 new_line = true;
5813 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5814 let rendered = match output["output_type"].as_str().unwrap_or("") {
5815 "stream" => {
5816 new_line = false;
5817 notebook_ansi(&join_source(&output["text"]))
5818 }
5819 "error" => {
5820 new_line = true;
5821 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5822 }
5823 "execute_result" => {
5824 new_line = true;
5825 let mut label = io_label(
5826 "Out",
5827 output["execution_count"].as_i64().filter(|n| *n != 0),
5828 "red",
5829 "#ee4b2b",
5830 );
5831 label.append("\n", None);
5832 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5833 }
5834 _ => continue,
5835 };
5836 items.push(Box::new(rendered));
5837 }
5838 }
5839 Ok(Notebook { items, justify })
5840}
5841
5842fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5843 let displayed = |value: f32| {
5846 format!("{value:.1}")
5847 .parse::<f32>()
5848 .expect("formatted percentage is a number")
5849 };
5850 displayed(changed) > displayed(limit)
5851}
5852
5853fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5856 let mut contents = Vec::with_capacity(cli.resources.len());
5857 for resource in &cli.resources {
5858 let content = if is_url(resource) {
5859 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5860 } else {
5861 read_resource(Some(resource), cli.extensions.encoding)
5862 .map_err(|err| format!("cannot read {resource}: {err}"))
5863 };
5864 match content {
5865 Ok(content) => contents.push(content),
5866 Err(err) => return fail(cli, ExitClass::Input, err),
5867 }
5868 }
5869 let names: Vec<String> = cli
5870 .resources
5871 .iter()
5872 .map(|r| {
5873 if r == "-" {
5874 "<stdin>".to_string()
5875 } else {
5876 r.clone()
5877 }
5878 })
5879 .collect();
5880 let outcome = match tools::text_diff(
5881 &cli.tools,
5882 &names,
5883 &contents,
5884 cli.diff_threshold,
5885 cli.sanitize,
5886 ) {
5887 Ok(outcome) => outcome,
5888 Err(err) => return fail(cli, ExitClass::Data, err),
5889 };
5890 let fit = outcome
5891 .renderable
5892 .measure(console, &console.options())
5893 .maximum;
5894 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5895 if code != ExitCode::SUCCESS || !outcome.failed {
5896 return code;
5897 }
5898 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5899}
5900
5901#[cfg(feature = "art")]
5917fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5918 if fs_path(path).is_dir() {
5919 return Err(format!("cannot read {path}: is a directory, not a file"));
5920 }
5921 match rich_art::image::open(fs_path(path)) {
5922 Ok(image) => Ok(image),
5923 Err(err) => {
5924 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5925 let hint = match &err {
5926 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5927 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5928 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5929 _ => None,
5930 },
5931 _ => None,
5932 };
5933 Err(format!(
5934 "cannot read {path}: {}",
5935 hint.unwrap_or_else(|| err.to_string())
5936 ))
5937 }
5938 }
5939}
5940
5941#[cfg(feature = "art")]
5947fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5948 match mode {
5949 ImageMode::Auto => rich_art::ImageMode::Auto,
5950 ImageMode::Sixel => rich_art::ImageMode::Sixel,
5951 ImageMode::Blocks => rich_art::ImageMode::Blocks,
5952 ImageMode::Braille => rich_art::ImageMode::Braille,
5953 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5954 ImageMode::Ascii => rich_art::ImageMode::Ascii,
5955 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
5956 }
5957}
5958
5959#[cfg(feature = "art")]
5966fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5967 use rich_art::ImageArt;
5968
5969 let Some(resource) = cli.resource.as_deref() else {
5970 return fail(
5971 cli,
5972 ExitClass::Usage,
5973 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
5974 );
5975 };
5976
5977 let image = if resource == "-" {
5978 let mut buffer = Vec::new();
5979 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
5980 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
5981 }
5982 match rich_art::image::load_from_memory(&buffer) {
5983 Ok(image) => image,
5984 Err(err) => {
5985 return fail(
5986 cli,
5987 ExitClass::Input,
5988 format!("cannot decode image from stdin: {err}"),
5989 )
5990 }
5991 }
5992 } else {
5993 match open_image_path(resource) {
5994 Ok(image) => image,
5995 Err(message) => return fail(cli, ExitClass::Input, message),
5996 }
5997 };
5998
5999 let width = cli.width.unwrap_or_else(|| console.width());
6000 let mut art = ImageArt::new(image)
6001 .transforms(rich_art::ImageTransforms {
6002 rotation: match cli.image_rotate {
6003 Some(90) => rich_art::Rotation::Clockwise90,
6004 Some(180) => rich_art::Rotation::Clockwise180,
6005 Some(270) => rich_art::Rotation::Clockwise270,
6006 _ => rich_art::Rotation::None,
6007 },
6008 flip_horizontal: cli.image_flip_horizontal,
6009 flip_vertical: cli.image_flip_vertical,
6010 grayscale: cli.image_grayscale,
6011 brightness: cli.image_brightness.unwrap_or(1.0),
6012 contrast: cli.image_contrast.unwrap_or(1.0),
6013 gamma: cli.image_gamma.unwrap_or(1.0),
6014 })
6015 .mode(to_art_image_mode(cli.image_mode))
6016 .width(width)
6017 .color(!cli.no_color);
6018 if let Some(height) = cli.height {
6019 art = art.height(height);
6020 }
6021 if let Some(columns) = cli.image_max_width {
6022 art = art.max_width(columns);
6023 }
6024 if let Some(rows) = cli.image_max_height {
6025 art = art.max_height(rows);
6026 }
6027
6028 if let Some(fit) = cli.image_fit.as_deref() {
6029 art = art.fit(match fit {
6030 "cover" => rich_art::ImageFit::Cover,
6031 "stretch" => rich_art::ImageFit::Stretch,
6032 "native" => rich_art::ImageFit::Native,
6033 _ => rich_art::ImageFit::Contain,
6034 });
6035 }
6036 if let Some(anchor) = cli.image_anchor.as_deref() {
6037 use rich_art::ImageAnchor;
6038 art = art.anchor(match anchor {
6039 "top" => ImageAnchor::Top,
6040 "bottom" => ImageAnchor::Bottom,
6041 "left" => ImageAnchor::Left,
6042 "right" => ImageAnchor::Right,
6043 "top-left" => ImageAnchor::TopLeft,
6044 "top-right" => ImageAnchor::TopRight,
6045 "bottom-left" => ImageAnchor::BottomLeft,
6046 "bottom-right" => ImageAnchor::BottomRight,
6047 _ => ImageAnchor::Center,
6048 });
6049 }
6050 match cli.image_background {
6051 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
6052 Some(ImageBackdrop::Terminal) => {
6053 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
6054 }
6055 Some(ImageBackdrop::Checkerboard) => {
6056 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
6057 }
6058 None => {}
6059 }
6060 let (color_mode, dither, distance) = image_color_processing(cli);
6061 art = art
6062 .color_mode(color_mode)
6063 .dither(dither)
6064 .color_distance(distance);
6065
6066 let options = console.options();
6071 if let Err(err) = art.render(console, &options) {
6072 let message = match err {
6076 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
6077 "this terminal (TERM={}) is not known to support Sixel graphics; set \
6078 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
6079 --image-mode blocks, braille or ascii",
6080 std::env::var("TERM").unwrap_or_default()
6081 ),
6082 err => err.to_string(),
6083 };
6084 return fail(cli, ExitClass::Input, message);
6085 }
6086
6087 let terminal_only = Export {
6088 html_path: None,
6089 svg_path: None,
6090 png_path: None,
6091 ..*export
6092 };
6093 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
6094 return fail(cli, ExitClass::Input, message);
6095 }
6096 if export.any_file() {
6097 use rich::protocol::RenderEnvironment;
6098 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
6099 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
6100 caps.color_system = if cli.no_color {
6101 None
6102 } else {
6103 Some(ColorSystem::Truecolor)
6104 };
6105 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
6106 let export_console = target.console();
6107 if let Err(err) =
6108 art.render_with_environment(&export_console, &export_console.options(), &target)
6109 {
6110 return fail(cli, ExitClass::Input, err.to_string());
6111 }
6112 let segments = target.segments(&art);
6113 debug_assert!(!target.capabilities().interactive);
6114 if let Err(message) = save_exports(&export_console, export, &segments) {
6115 return fail(cli, ExitClass::Input, message);
6116 }
6117 }
6118 success(cli)
6119}
6120
6121#[cfg(feature = "art")]
6122fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6123 use rich::Table;
6124 use rich_art::imagediff::{diff, DiffSettings};
6125 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
6126
6127 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
6128 let before = match open_image_path(before_path) {
6129 Ok(image) => image,
6130 Err(message) => return fail(cli, ExitClass::Input, message),
6131 };
6132 let after = match open_image_path(after_path) {
6133 Ok(image) => image,
6134 Err(message) => return fail(cli, ExitClass::Input, message),
6135 };
6136
6137 let report = match diff(&before, &after, &DiffSettings::default()) {
6138 Ok(report) => report,
6139 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
6140 };
6141
6142 let width = cli.width.unwrap_or_else(|| console.width());
6143 let changed = report.changed_fraction * 100.0;
6144 let naive = report.naive_changed_fraction * 100.0;
6145
6146 let failed = cli
6155 .diff_threshold
6156 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
6157
6158 let render_report = |c: &Console, for_export: bool| {
6159 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
6162
6163 let has_color = c.color_system().is_some() && !cli.no_color;
6170 let is_terminal = c.is_terminal();
6171 let mut mode = cli.image_mode;
6172 if mode == ImageMode::Auto {
6173 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
6174 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
6175 }
6176 mode = if !has_color {
6177 ImageMode::Ascii
6178 } else if is_terminal && rich_art::sixel::is_probably_supported() {
6179 ImageMode::Sixel
6180 } else {
6181 ImageMode::Blocks
6182 };
6183 }
6184
6185 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
6189 if !for_export && cli.report_format == ReportFormat::Human {
6190 eprintln!("rich: no colour available, drawing the diff as ASCII art");
6191 }
6192 mode = ImageMode::Ascii;
6193 }
6194 if mode == ImageMode::Sixel && !is_terminal {
6195 if cli.image_mode != ImageMode::Sixel
6196 && !for_export
6197 && cli.report_format == ReportFormat::Human
6198 {
6199 eprintln!(
6200 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
6201 if has_color { "blocks" } else { "ASCII art" }
6202 );
6203 }
6204 mode = if has_color {
6205 ImageMode::Blocks
6206 } else {
6207 ImageMode::Ascii
6208 };
6209 }
6210
6211 match mode {
6212 ImageMode::None => {}
6213 ImageMode::Ascii => c.print(
6214 &AsciiArt::new(report.heatmap())
6215 .width(width)
6216 .color(has_color),
6217 ),
6218 ImageMode::Blocks => c.print(
6219 &BlockArt::new(report.heatmap())
6220 .width(width)
6221 .height(rows_cap),
6222 ),
6223 ImageMode::Quadrants => c.print(
6224 &rich_art::QuadrantArt::new(report.heatmap())
6225 .width(width)
6226 .height(rows_cap),
6227 ),
6228 ImageMode::Braille => c.print(
6229 &BrailleArt::new(report.heatmap())
6230 .width(width)
6231 .height(rows_cap),
6232 ),
6233 ImageMode::Sixel => {
6234 let art = SixelArt::new(report.heatmap())
6235 .width(width)
6236 .height(rows_cap);
6237 if art.encode(width).is_some() {
6240 c.print(&art);
6241 } else {
6242 if cli.report_format == ReportFormat::Human {
6243 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
6244 }
6245 c.print(
6246 &BlockArt::new(report.heatmap())
6247 .width(width)
6248 .height(rows_cap),
6249 );
6250 }
6251 }
6252 ImageMode::Auto => unreachable!("resolved above"),
6253 }
6254 if mode != ImageMode::None {
6258 c.print_str("");
6259 }
6260 c.print_str(&format!(
6261 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
6262 [dim](a plain pixel diff would say {naive:.1}%)[/]"
6263 ));
6264
6265 if report.regions.is_empty() {
6266 c.print_str("[dim]No region large enough to report.[/]");
6267 } else {
6268 let mut table = Table::new().title("Where it changed");
6269 table.add_column("#");
6270 table.add_column_justify("Share", rich::Justify::Right);
6271 table.add_column_justify("Mean ΔE", rich::Justify::Right);
6272 table.add_column_justify("Area px", rich::Justify::Right);
6273 table.add_column("Box (x,y w×h)");
6274 for (rank, r) in report.regions.iter().enumerate() {
6275 table.add_row(&[
6276 &format!("{}", rank + 1),
6277 &format!("{:.0}%", r.share_of_change * 100.0),
6278 &format!("{:.1}", r.mean_delta_e),
6279 &format!("{}", r.area_px),
6280 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
6281 ]);
6282 }
6283 c.print(&table);
6284 }
6285
6286 if let Some(limit) = cli.diff_threshold {
6290 if failed {
6291 c.print_str(&format!(
6292 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
6293 ));
6294 } else {
6295 c.print_str(&format!(
6296 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
6297 ));
6298 }
6299 }
6300 };
6301
6302 let terminal_only = Export {
6303 html_path: None,
6304 svg_path: None,
6305 png_path: None,
6306 ..*export
6307 };
6308 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
6309 if export.any_file() {
6310 let mut export_console = Console::builder()
6314 .force_terminal(false)
6315 .color_system(Some(ColorSystem::Truecolor))
6316 .no_color(cli.no_color)
6317 .width(console.width())
6318 .height(console.height())
6319 .theme(console.theme().clone())
6320 .build();
6321 export_console.install_extensions();
6322 let segments = export_console.record_output(|c| render_report(c, true));
6323 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6324 }
6325
6326 if let Err(message) = wrote {
6327 return fail(cli, ExitClass::Input, message);
6328 }
6329 if failed {
6330 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6331 }
6332 success(cli)
6333}
6334
6335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6338#[cfg_attr(not(feature = "art"), allow(dead_code))]
6339enum ImageBackdrop {
6340 Color([u8; 3]),
6342 Terminal,
6344 Checkerboard,
6346}
6347
6348impl ImageBackdrop {
6349 fn parse(value: &str) -> Option<Self> {
6350 match value {
6351 "default" => return Some(ImageBackdrop::Terminal),
6352 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6353 _ => {}
6354 }
6355 let hex = value.strip_prefix('#')?;
6356 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6357 return None;
6358 }
6359 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6360 Some(ImageBackdrop::Color([
6361 channel(0)?,
6362 channel(1)?,
6363 channel(2)?,
6364 ]))
6365 }
6366}
6367
6368#[cfg(feature = "art")]
6371fn image_color_processing(
6372 cli: &Cli,
6373) -> (
6374 rich_art::ImageColorMode,
6375 rich_art::Dither,
6376 rich_art::ColorDistance,
6377) {
6378 use rich_art::{ColorDistance, Dither, ImageColorMode};
6379 let mode = match cli.image_color.as_deref() {
6380 Some("ansi256") => ImageColorMode::Ansi256,
6381 Some("ansi16") => ImageColorMode::Ansi16,
6382 Some("grayscale") => ImageColorMode::Grayscale,
6383 _ => ImageColorMode::TrueColor,
6384 };
6385 let dither = match cli.image_dither.as_deref() {
6386 Some("floyd-steinberg") => Dither::FloydSteinberg,
6387 Some("bayer4x4") => Dither::Bayer4x4,
6388 Some("atkinson") => Dither::Atkinson,
6389 _ => Dither::None,
6390 };
6391 let distance = match cli.image_color_distance.as_deref() {
6392 Some("oklab") => ColorDistance::Oklab,
6393 _ => ColorDistance::Rgb,
6394 };
6395 (mode, dither, distance)
6396}
6397
6398#[cfg(feature = "art")]
6399fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6400 use rich_art::{AnimatedArt, Repeat, Stage, Until};
6401
6402 if cli.resources.is_empty() {
6403 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6404 }
6405 let count = cli.resources.len();
6406 const GAP: usize = 2;
6407
6408 let total = cli.width.unwrap_or_else(|| console.width());
6410 let per_gif = total
6411 .saturating_sub(GAP * count.saturating_sub(1))
6412 .checked_div(count)
6413 .unwrap_or(total)
6414 .max(8);
6415
6416 let repeat = match cli.loops {
6417 Some(0) => Repeat::Forever,
6418 Some(n) => Repeat::Times(n),
6419 None => Repeat::Times(1),
6420 };
6421
6422 let mut arts = Vec::with_capacity(count);
6423 for path in &cli.resources {
6424 match AnimatedArt::from_path(path) {
6425 Ok(art) => {
6426 let (color_mode, dither, distance) = image_color_processing(cli);
6427 arts.push(
6428 art.width(per_gif)
6429 .blocks(cli.extensions.gif_blocks())
6430 .color(!cli.no_color)
6431 .color_mode(color_mode)
6432 .dither(dither)
6433 .color_distance(distance)
6434 .repeat(repeat),
6435 );
6436 }
6437 Err(err) => {
6438 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6439 }
6440 }
6441 }
6442
6443 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6444 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6445 }
6446
6447 let mut builder = Console::builder().no_color(cli.no_color);
6449 if cli.themed() {
6450 builder = builder.theme(cli_theme(cli));
6451 }
6452 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6455 builder = builder.force_terminal(terminal == "1");
6456 }
6457 if let Some(width) = cli.width {
6458 builder = builder.width(width);
6459 }
6460 let console = builder.build();
6461 let played = if cli.animation.needs_player() && console.is_terminal() {
6462 let passes = match repeat {
6464 Repeat::Forever => None,
6465 Repeat::Times(n) => Some(n),
6466 Repeat::Once => Some(1),
6467 };
6468 animation::play(arts, GAP, passes, &cli.animation, console)
6469 } else {
6470 let fps = cli.animation.fps();
6472 let mut stage = Stage::new().gap(GAP);
6473 for art in arts {
6474 stage = stage.with(art.max_fps(fps));
6475 }
6476 if let Some(limit) = cli.animation.max_duration {
6477 stage = stage.until(Until::Elapsed(limit));
6478 }
6479 stage.play_stdout(console)
6480 };
6481 match played {
6482 Ok(()) => success(cli),
6483 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6484 }
6485}
6486
6487#[cfg(not(feature = "art"))]
6489fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6490 fail(
6491 _cli,
6492 ExitClass::Usage,
6493 "this build has no GIF support (rebuild with the `art` feature)",
6494 )
6495}
6496
6497struct Export<'a> {
6500 html_path: Option<&'a str>,
6502 svg_path: Option<&'a str>,
6504 png_path: Option<&'a str>,
6506 svg_title: &'a str,
6508 pager: bool,
6510 auto_pager: bool,
6511}
6512
6513impl Export<'_> {
6514 fn terminal_only(&self) -> bool {
6517 !watch::capturing() && !self.any_file() && !self.pager && !self.auto_pager
6518 }
6519
6520 fn any_file(&self) -> bool {
6523 self.html_path.is_some() || self.svg_path.is_some() || self.png_path.is_some()
6524 }
6525}
6526
6527fn should_page(
6538 explicit: bool,
6539 automatic: bool,
6540 terminal: bool,
6541 lines: usize,
6542 height: usize,
6543) -> bool {
6544 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6545}
6546
6547fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6548 if export.terminal_only() {
6549 render(console);
6550 return Ok(());
6551 }
6552 emit_segments(console, export, console.record_output(render))
6553}
6554
6555fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6558 if watch::capturing() {
6559 watch::capture_segments(segments);
6561 return Ok(());
6562 }
6563 let mut first_error = None;
6564
6565 let terminal = console.segments_to_string(&segments);
6567 if should_page(
6568 export.pager,
6569 export.auto_pager,
6570 std::io::stdout().is_terminal(),
6571 terminal.lines().count(),
6572 console.height(),
6573 ) {
6574 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6577 first_error = Some(format!("cannot page output: {err}"));
6578 }
6579 } else {
6580 let stdout = std::io::stdout();
6581 let mut lock = stdout.lock();
6582 if let Err(err) = lock.write_all(terminal.as_bytes()) {
6583 first_error = Some(format!("could not write output: {err}"));
6584 }
6585 }
6586
6587 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6588}
6589
6590fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6591 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6592 let segments = filtered.as_slice();
6593 let mut first_error = None;
6594 if let Some(path) = export.html_path {
6595 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6597 if let Err(err) = std::fs::write(fs_path(path), html) {
6598 first_error = Some(format!("failed to save HTML: {err}"));
6599 }
6600 }
6601 if let Some(path) = export.svg_path {
6602 let svg = rich::svg::export_svg(
6603 segments,
6604 &rich::SVG_EXPORT_THEME,
6605 export.svg_title,
6606 "rich-cli",
6609 console.width(),
6610 );
6611 if let Err(err) = std::fs::write(fs_path(path), svg) {
6612 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6613 }
6614 }
6615 if let Some(path) = export.png_path {
6616 if let Err(message) = save_png(path, segments, export.svg_title, console.width()) {
6617 first_error.get_or_insert(message);
6618 }
6619 }
6620 first_error.map_or(Ok(()), Err)
6621}
6622
6623#[cfg(feature = "png")]
6627fn save_png(path: &str, segments: &[Segment], title: &str, width: usize) -> Result<(), String> {
6628 use rich_record::render::png::{segments_png, PngOptions};
6629 let options = PngOptions {
6630 title,
6631 columns: Some(width),
6632 ..PngOptions::default()
6633 };
6634 let fonts = rich_record::render::raster::Fonts::embedded();
6635 let png = segments_png(segments, &fonts, &options)
6636 .map_err(|err| format!("failed to save PNG: {err}"))?;
6637 std::fs::write(fs_path(path), png).map_err(|err| format!("failed to save PNG: {err}"))
6638}
6639
6640#[cfg(not(feature = "png"))]
6642fn save_png(_: &str, _: &[Segment], _: &str, _: usize) -> Result<(), String> {
6643 Err("--export-png needs a build with the png feature".into())
6644}
6645
6646fn print_help(no_color: bool) {
6648 cli_spec::print_help(no_color);
6649}
6650
6651fn build_demo_console(no_color: bool) -> Console {
6652 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6657 let mut console = Console::builder()
6658 .force_terminal(to_terminal)
6659 .no_color(no_color)
6660 .color_system(if to_terminal {
6661 Some(ColorSystem::Truecolor)
6662 } else {
6663 None
6664 })
6665 .theme(rich_ext::extended_theme())
6668 .build();
6669 console.install_extensions();
6670 console
6671}
6672
6673fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6674 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6675 let console = build_demo_console(no_color);
6676
6677 demo::section(&console, delay, "rs-rich-cli");
6678 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6679 console.print_str(
6680 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6681 );
6682
6683 demo::section(&console, delay, "markup · color · theme · extension");
6685 console.print_str(
6686 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6687 );
6688 console.print_str(
6689 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6690 );
6691 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6692 let mut themed = build_demo_console(no_color);
6694 let release =
6695 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6696 themed.use_theme(release).print_str(
6697 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6698 );
6699 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6700 let hl = Console::builder()
6706 .force_terminal(to_terminal)
6707 .no_color(no_color)
6708 .color_system(if to_terminal {
6709 Some(ColorSystem::Truecolor)
6710 } else {
6711 None
6712 })
6713 .highlight(true)
6714 .build();
6715 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6716 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6718 ISO8601Highlighter::new().highlight(&mut stamp);
6719 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6720 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6721
6722 demo::section(&console, delay, "rule");
6724 console.print(&Rule::line());
6725 demo::section(&console, delay, "centered");
6726 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6727 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6728
6729 demo::section(&console, delay, "panel");
6731 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6732 console.print(
6733 &Panel::new(text("square box"))
6734 .box_set(SQUARE)
6735 .title("square"),
6736 );
6737 console.print(
6738 &Panel::new(text("double box"))
6739 .box_set(DOUBLE)
6740 .title("double"),
6741 );
6742 console.print(
6743 &Panel::new(text("title + subtitle"))
6744 .title("top")
6745 .subtitle("bottom"),
6746 );
6747 let legacy = Console::builder()
6749 .force_terminal(to_terminal)
6750 .no_color(no_color)
6751 .color_system(if to_terminal {
6752 Some(ColorSystem::Truecolor)
6753 } else {
6754 None
6755 })
6756 .width(console.width())
6757 .legacy_windows(true)
6758 .build();
6759 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6760
6761 demo::section(&console, delay, "padding");
6763 console.print(
6764 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6765 );
6766
6767 demo::section(&console, delay, "styled");
6769 console.print(&Styled::new(
6770 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6771 Style::parse("green").unwrap(),
6772 ));
6773
6774 demo::section(&console, delay, "align");
6776 console.print(&Align::left(text("← left")));
6777 console.print(&Align::center(text("center")));
6778 console.print(&Align::right(text("right →")));
6779
6780 demo::section(&console, delay, "justify");
6782 console.print_justified("left justified", Justify::Left);
6783 console.print_justified("centered", Justify::Center);
6784 console.print_justified("right justified", Justify::Right);
6785
6786 demo::section(&console, delay, "constrain (width 24)");
6788 console.print(&Constrain::new(
6789 Box::new(Panel::new(text("constrained")).title("≤24")),
6790 Some(24),
6791 ));
6792
6793 demo::section(&console, delay, "table");
6795 let mut table = Table::new().title("ported renderables");
6796 table
6798 .add_column("Renderable")
6799 .column_style(Style::parse("cyan").unwrap());
6800 table.add_column("Module").column_width(12);
6801 table.add_column_justify("Parity", Justify::Center);
6802 for (renderable, module, parity) in [
6803 ("Text / markup", "text, markup", "✓"),
6804 ("Rule", "rule", "✓"),
6805 ("Panel", "panel", "✓"),
6806 ("Padding", "padding", "✓"),
6807 ("Align", "align", "✓"),
6808 ("Constrain", "constrain", "✓"),
6809 ("Columns", "columns", "✓"),
6810 ("Table", "table", "✓"),
6811 ("Tree", "tree", "✓"),
6812 ("ProgressBar", "progress_bar", "✓"),
6813 ] {
6814 table.add_row(&[renderable, module, parity]);
6815 }
6816 console.print(&table);
6817
6818 demo::section(&console, delay, "columns");
6820 let months = [
6821 "January",
6822 "February",
6823 "March",
6824 "April",
6825 "May",
6826 "June",
6827 "July",
6828 "August",
6829 "September",
6830 "October",
6831 "November",
6832 "December",
6833 ];
6834 console.print(&Columns::new(
6835 months.iter().map(|m| m.to_string()).collect(),
6836 ));
6837
6838 demo::section(&console, delay, "tree");
6840 let mut tree = Tree::new("rs-rich-cli");
6841 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6842 core.add("color · style · text · console");
6843 core.add("box · rule · panel · padding · align");
6844 core.add("table · tree · wrap · filesize");
6845 tree.add("crates/rich-ext (our extensions)");
6846 tree.add("crates/rich-cli (this binary)");
6847 console.print(&tree);
6848
6849 demo::section(&console, delay, "progress bar");
6851 for pct in [0.0, 33.0, 66.0, 100.0] {
6852 console.print(&ProgressBar::new(100.0, pct).width(48));
6853 }
6854
6855 demo::section(&console, delay, "progress");
6859 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6860 let set_time =
6861 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6862 let reader = clock.clone();
6863 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6864 let mut progress = Progress::new().clock(now.clone());
6865 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6866 let processing = progress.add_task("Processing", 100.0, 0.0);
6867 progress.add_task("Waiting", 100.0, 0.0);
6868 set_time(4.0);
6869 progress.advance(downloading, 20.0);
6870 progress.advance(processing, 60.0);
6871 set_time(8.0);
6872 progress.advance(downloading, 30.0);
6873 progress.advance(processing, 40.0);
6874 console.print(&progress);
6875
6876 let mut transfer = Progress::new().clock(now).columns(vec![
6878 ProgressColumn::spinner(),
6879 ProgressColumn::Description,
6880 ProgressColumn::Bar,
6881 ProgressColumn::Download,
6882 ProgressColumn::TransferSpeed,
6883 ProgressColumn::TimeElapsed,
6884 ProgressColumn::time_remaining(),
6885 ]);
6886 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6887 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6888 set_time(12.0);
6889 transfer.advance(iso, 1_200_000_000.0);
6890 transfer.advance(notes, 20_000.0);
6891 set_time(20.0);
6892 transfer.advance(iso, 900_000_000.0);
6893 transfer.advance(notes, 22_000.0);
6894 console.print(&transfer);
6895
6896 let mut mofn = Progress::new().columns(vec![
6898 ProgressColumn::Description,
6899 ProgressColumn::Bar,
6900 ProgressColumn::MofN,
6901 ]);
6902 mofn.add_task("Files", 8.0, 3.0);
6903 mofn.add_task("Chunks", 128.0, 128.0);
6904 console.print(&mofn);
6905
6906 let mut download = Progress::new().columns(vec![
6908 ProgressColumn::Description,
6909 ProgressColumn::Bar,
6910 ProgressColumn::Download,
6911 ]);
6912 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6913 console.print(&download);
6914
6915 demo::section(&console, delay, "json");
6917 if let Ok(json) = Json::new(
6918 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6919 ) {
6920 console.print(&json);
6921 }
6922
6923 demo::section(&console, delay, "pretty");
6925 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6926 console.print(&Pretty::new(&value));
6927
6928 demo::section(&console, delay, "log");
6930 let log = LogRender::new().show_level(true);
6931 for (level, message) in [
6932 (LogLevel::Info, "server started on port 8080"),
6933 (LogLevel::Debug, "cache warm: 128 entries"),
6934 (LogLevel::Warn, "disk usage at 85%"),
6935 (LogLevel::Error, "connection reset by peer"),
6936 ] {
6937 console.print(&log.render(
6938 &console,
6939 Text::new(message),
6940 Some(Text::new("[12:00:00]")),
6941 level.text(),
6942 Some("main.rs"),
6943 Some(42),
6944 None,
6945 ));
6946 }
6947
6948 demo::section(&console, delay, "traceback");
6950 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6951 let err = std::io::Error::other(cause);
6952 console.print(&Traceback::new(&err));
6953
6954 demo::section(&console, delay, "bar (range)");
6956 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6957 console.print(&Bar::new(100.0, begin, end).width(48));
6958 }
6959
6960 demo::section(&console, delay, "markdown");
6962 console.print(&Markdown::new(
6963 "# Heading\n\nA paragraph with **bold**, *italic*, `code`, ~~struck~~ text, and a [link](https://example.com).\nTilde runs pair as upstream pairs them: a ~~~x~~~ b.\n\n- bullet item\n- another\n\n1. first\n2. second\n\n> a block quote\n\n| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n---",
6964 ));
6965
6966 demo::section(&console, delay, "syntax");
6968 console.print(&Syntax::new(
6969 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
6970 "rust",
6971 ));
6972
6973 demo::section(&console, delay, "control codes");
6975 let show_escape = |label: &str, control: &Control| {
6976 let escaped = control
6978 .as_str()
6979 .replace('\x1b', "\\x1b")
6980 .replace('\x07', "\\a")
6981 .replace('\r', "\\r");
6982 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
6983 };
6984 show_escape("clear screen", &Control::clear());
6985 show_escape("home", &Control::home());
6986 show_escape("move (2,-1)", &Control::move_(2, -1));
6987 show_escape("move_to (3,4)", &Control::move_to(3, 4));
6988 show_escape("hide cursor", &Control::show_cursor(false));
6989 show_escape("bell", &Control::bell());
6990 let live_render = LiveRender::new(text("a\nb\nc"));
6992 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
6994 let live_console = Console::builder()
6997 .force_terminal(true)
6998 .color_system(Some(ColorSystem::Truecolor))
6999 .width(20)
7000 .build();
7001 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
7002 live.start();
7003 live.update(text("frame two"));
7004 live.stop();
7005 let stream = String::from_utf8_lossy(live.writer())
7006 .replace('\x1b', "\\x1b")
7007 .replace('\r', "\\r")
7008 .replace('\n', "\\n");
7009 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
7010
7011 demo::section(&console, delay, "ansi decoder");
7013 let raw =
7014 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
7015 console.print(&Text::new(format!(
7016 " input: {}",
7017 raw.replace('\x1b', "\\x1b")
7018 )));
7019 let mut decoder = rich::AnsiDecoder::new();
7020 for line in decoder.decode(raw) {
7021 console.print(&Text::new(" output: ").append_text(&line));
7022 }
7023
7024 demo::section(&console, delay, "capture · export_text");
7026 let plain = console.export_text(|c| {
7027 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
7028 });
7029 console.print(&Text::new(" export_text (styles stripped):"));
7030 for line in plain.lines() {
7031 console.print(&Text::new(format!(" {line}")));
7032 }
7033 let html_rule = Style::parse("bold red")
7035 .unwrap()
7036 .get_html_style(&DEFAULT_TERMINAL_THEME);
7037 console.print(&Text::new(format!(
7038 " export_html: [bold red] → <span style=\"{html_rule}\">"
7039 )));
7040
7041 demo::section(&console, delay, "layout");
7044 let panel_leaf = |title: &str, body: &str| {
7045 Layout::with_renderable(Box::new(
7046 Panel::new(text(body))
7047 .box_set(SQUARE)
7048 .title(title.to_string()),
7049 ))
7050 };
7051 let mut header = Layout::new();
7052 header.split_row(vec![
7053 panel_leaf("left", "pane A"),
7054 panel_leaf("right", "pane B"),
7055 ]);
7056 let mut root = Layout::new();
7057 root.split_column(vec![
7058 header,
7059 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
7060 ]);
7061 let grid = Console::builder()
7062 .force_terminal(true)
7063 .color_system(Some(ColorSystem::Truecolor))
7064 .width(48)
7065 .height(7)
7066 .build();
7067 for line in grid.export_text(|c| c.print(&root)).lines() {
7068 console.print(&Text::new(line));
7069 }
7070
7071 demo::section(&console, delay, "spinner (frames)");
7073 for name in ["dots", "line", "arrow", "simpleDots"] {
7074 let spinner = Spinner::new(name);
7075 let frames: String = (0..6)
7076 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
7077 .collect::<Vec<_>>()
7078 .join(" ");
7079 console.print_str(&format!(" {name:>11}: {frames}"));
7080 }
7081 let status = Status::new("Loading…").renderable().render(0.0);
7083 console.print(&Text::new(" status: ").append_text(&status));
7084
7085 demo::section(&console, delay, "csv");
7087 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
7088 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
7089 console.print(&table);
7090 }
7091
7092 demo::section(&console, delay, "filesize");
7094 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
7095 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
7096 }
7097 console.print(&Rule::line());
7098 console
7099}
7100
7101#[cfg(test)]
7102mod tests {
7103 use super::*;
7104 use rich::ColorSystem;
7105
7106 const MODE_FLAGS: &[&str] = &[
7108 "--print",
7109 "-p",
7110 "--markdown",
7111 "-m",
7112 "--json",
7113 "-j",
7114 "-J",
7115 "--syntax",
7116 "-x",
7117 "--csv",
7118 "--ipynb",
7119 "--rst",
7120 "--jsonl",
7121 "--ndjson",
7122 "--log",
7123 "--rule",
7124 "-u",
7125 "--image",
7126 "--gif",
7127 "--diff",
7128 "--inspect",
7129 "--ansi-explain",
7130 ];
7131
7132 #[test]
7135 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
7136 for flag in MODE_FLAGS {
7137 assert!(mode_flag_alias(flag).is_some(), "{flag}");
7138 }
7139 let claimed = |args: &[String]| {
7140 let mut words = Vec::new();
7141 if plugins::requested(args) {
7142 words.push("plugins");
7143 }
7144 if doctor::requested(args) {
7145 words.push("doctor");
7146 }
7147 #[cfg(feature = "art")]
7148 if micro::requested(args) {
7149 words.push("micro");
7150 }
7151 #[cfg(feature = "record")]
7152 if record::requested(args) {
7153 words.push("record");
7154 }
7155 #[cfg(feature = "serve")]
7156 if serve::requested(args) {
7157 words.push("serve");
7158 }
7159 #[cfg(feature = "interact")]
7160 words.extend(interactive::requested(args));
7161 #[cfg(feature = "interact")]
7162 words.extend(approve_jump::requested(args));
7163 if tools::bench_dispatch(args).unwrap_or(true) {
7164 words.push("bench");
7165 }
7166 let roots = ConfigRoots {
7167 home: None,
7168 cwd: std::env::temp_dir(),
7169 no_color_env: false,
7170 };
7171 if !matches!(config::inspect(args, &roots), Ok(None)) {
7172 words.push("config");
7173 }
7174 words
7175 };
7176 let words = [
7177 "plugins", "doctor", "record", "serve", "choose", "filter", "input", "confirm",
7178 "pager", "date", "bench", "config",
7179 ];
7180 for word in words {
7181 let plain = claimed(&[word.to_string(), "--no-color".into()]);
7183 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
7184 for flag in MODE_FLAGS {
7185 let args = [flag.to_string(), word.to_string()];
7186 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
7187 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
7188 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
7189 }
7190 }
7191 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
7192 assert!(doctor::requested(&["doctor".into()]));
7193 }
7194
7195 #[cfg(feature = "art")]
7196 #[test]
7197 fn diff_threshold_uses_the_reports_tie_rounding() {
7198 assert!(!diff_threshold_exceeded(0.25, 0.24));
7200 assert!(diff_threshold_exceeded(0.75, 0.74));
7201 assert!(!diff_threshold_exceeded(0.75, 0.76));
7202 }
7203
7204 #[test]
7205 fn detects_urls() {
7206 assert!(is_url("http://example.com"));
7207 assert!(is_url("https://example.com/x"));
7208 assert!(is_url("HTTPS://example.com"));
7210 assert!(is_url("Http://example.com"));
7211 assert!(!is_url("example.com"));
7212 assert!(!is_url("./file.md"));
7213 assert!(!is_url("-"));
7214 assert!(!is_url("")); assert!(!is_url("ht"));
7216 assert!(!is_url("ftp://example.com"));
7217 }
7218
7219 #[test]
7220 fn uninformative_extensions_defer_to_content_type() {
7221 assert!(is_uninformative_ext("txt"));
7222 assert!(is_uninformative_ext("log"));
7223 assert!(!is_uninformative_ext("py"));
7224 assert!(!is_uninformative_ext("rs"));
7225 }
7226
7227 #[test]
7228 fn resource_ext_uses_basename_without_query() {
7229 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
7230 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
7231 assert_eq!(
7232 resource_ext("https://x.com/c.py?raw=1").as_deref(),
7233 Some("py")
7234 );
7235 assert_eq!(resource_ext("https://api.example.com/data"), None);
7237 assert_eq!(resource_ext("https://x.com/"), None);
7238 assert_eq!(resource_ext("noext"), None);
7239 }
7240
7241 #[test]
7242 fn content_type_maps_mode_and_lexer() {
7243 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
7244 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
7245 assert_eq!(
7246 content_type_mode("application/json; charset=utf-8"),
7247 Some(Mode::Json)
7248 );
7249 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
7250 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
7252 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
7253 assert_eq!(content_type_lexer("text/plain"), None);
7254 }
7255
7256 #[test]
7257 fn join_source_handles_string_and_array() {
7258 use serde_json::json;
7259 assert_eq!(join_source(&json!("a\nb")), "a\nb");
7260 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
7262 assert_eq!(join_source(&json!(null)), "");
7263 }
7264
7265 #[test]
7266 fn ipynb_extension_auto_detects() {
7267 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
7268 }
7269
7270 #[test]
7271 fn io_label_builds_execution_count() {
7272 assert_eq!(
7274 io_label("In ", Some(1), "green", "#66ff00").plain(),
7275 "In [1]:"
7276 );
7277 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
7278 }
7279
7280 #[test]
7281 fn parse_padding_unpacks_like_upstream() {
7282 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
7283 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
7284 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
7285 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
7286 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
7288 }
7289
7290 #[test]
7291 fn parse_box_maps_names() {
7292 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
7293 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
7294 assert!(matches!(parse_box("none"), Ok(None)));
7296 assert!(parse_box("bogus").is_err());
7297 }
7298
7299 #[test]
7300 fn paging_policy_respects_viewport_and_redirection() {
7301 assert!(!should_page(true, true, false, 100, 24));
7302 assert!(!should_page(false, true, true, 23, 24));
7303 assert!(should_page(false, true, true, 24, 24));
7304 assert!(should_page(true, false, true, 1, 24));
7305 assert!(!should_page(false, false, true, 100, 24));
7306 }
7307
7308 #[test]
7309 fn worker_arguments_preserve_resources_named_like_commands() {
7310 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
7311 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
7312 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
7313 }
7314 }
7315
7316 #[test]
7317 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
7318 let args: Vec<String> = [
7319 "--batch",
7320 "--title",
7321 "--batch",
7322 "--jobs",
7323 "2",
7324 "json",
7325 "--export-html",
7326 "out.html",
7327 "--",
7328 "--pager",
7329 ]
7330 .into_iter()
7331 .map(str::to_string)
7332 .collect();
7333 assert_eq!(
7334 worker_args(&args),
7335 ["--no-config", "--title", "--batch", "--json"]
7336 );
7337 }
7338
7339 #[test]
7341 fn worker_arguments_keep_the_code_highlighter() {
7342 let args: Vec<String> = [
7343 "--batch",
7344 "--highlighter",
7345 "syntect",
7346 "--code-theme",
7347 "ansi_dark",
7348 "a.py",
7349 ]
7350 .into_iter()
7351 .map(str::to_string)
7352 .collect();
7353 assert_eq!(
7354 worker_args(&args),
7355 [
7356 "--no-config",
7357 "--highlighter",
7358 "syntect",
7359 "--code-theme",
7360 "ansi_dark"
7361 ]
7362 );
7363 }
7364
7365 #[test]
7366 fn parses_pager_flag() {
7367 let s = |v: &str| v.to_string();
7368 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
7369 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
7370 }
7371
7372 #[test]
7373 fn parses_watch_options_and_rejects_orphans() {
7374 let s = |v: &str| v.to_string();
7375 let cli = parse(&[
7376 s("--watch"),
7377 s("--watch-interval"),
7378 s("0.25"),
7379 s("--watch-cache"),
7380 s("file.md"),
7381 ])
7382 .unwrap()
7383 .unwrap();
7384 assert!(cli.watch);
7385 assert_eq!(cli.watch_interval, 0.25);
7386 assert!(cli.watch_cache);
7387 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7388 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7389 }
7390
7391 #[test]
7392 fn parses_multi_file_watch_options() {
7393 let s = |v: &str| v.to_string();
7394 let parse = parse_inner;
7395 let cli = parse(&[
7396 s("--watch"),
7397 s("--watch-debounce"),
7398 s("0.3"),
7399 s("--watch-poll"),
7400 s("--watch-exit-on-error"),
7401 s("a.md"),
7402 s("b.json"),
7403 ])
7404 .unwrap()
7405 .unwrap();
7406 assert_eq!(cli.resources, ["a.md", "b.json"]);
7407 assert_eq!(cli.watch_debounce, 0.3);
7408 assert!(cli.watch_poll && cli.watch_exit_on_error);
7409 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7410 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7411 assert!(!default.watch_poll && !default.watch_exit_on_error);
7412 for orphan in [
7413 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7414 vec![s("--watch-poll"), s("a.md")],
7415 vec![s("--watch-exit-on-error"), s("a.md")],
7416 ] {
7417 assert!(parse(&orphan)
7418 .err()
7419 .unwrap()
7420 .contains("only has an effect with --watch"));
7421 }
7422 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7423 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7424 }
7425 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7427 }
7428
7429 #[test]
7430 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7431 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7432 let _ = std::fs::remove_file(&path);
7433 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7434 std::fs::write(&path, "first").unwrap();
7435 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7436 std::fs::write(&path, "second content").unwrap();
7437 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7438 let _ = std::fs::remove_file(&path);
7439 assert_eq!(missing, "missing");
7440 assert_ne!(first, missing);
7441 assert_ne!(first, second);
7442 }
7443
7444 #[test]
7445 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7446 let s = |v: &str| v.to_string();
7447 let cli = parse(&[
7448 s("--no-config"),
7449 s("--batch"),
7450 s("--jobs"),
7451 s("3"),
7452 s("-o"),
7453 s("out.html"),
7454 s("--continue-on-error"),
7455 s("--collision"),
7456 s("suffix"),
7457 s("input"),
7458 ])
7459 .unwrap()
7460 .unwrap();
7461 assert!(cli.batch);
7462 assert_eq!(cli.jobs, 3);
7463 assert!(cli.continue_on_error);
7464 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7465 }
7466
7467 #[test]
7468 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7469 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7470 let _ = std::fs::remove_dir_all(&root);
7471 let nested = root.join("nested");
7472 std::fs::create_dir_all(&nested).unwrap();
7473 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7474 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7475
7476 let root_arg = root.to_string_lossy().into_owned();
7477 let glob_arg = root
7478 .join("*richbatchonly.md")
7479 .to_string_lossy()
7480 .into_owned();
7481 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7482 assert_eq!(inputs.len(), 2);
7483 assert!(inputs[0].ends_with("richbatchnested.md"));
7484 assert!(inputs[1].ends_with("richbatchonly.md"));
7485
7486 std::fs::remove_dir_all(root).unwrap();
7487 }
7488
7489 #[test]
7490 fn batch_output_suffixes_are_deterministic_for_collisions() {
7491 let inputs = ["one.md", "two.md"];
7492 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7493 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7494 assert_eq!(first, "one.html");
7495 assert_eq!(second, "two.html");
7496 assert_eq!(
7497 Path::new("one.html")
7498 .with_file_name("one-2.html")
7499 .to_string_lossy(),
7500 "one-2.html"
7501 );
7502 }
7503
7504 #[test]
7505 fn config_profiles_support_profiles_section_and_source_errors() {
7506 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7507 std::fs::create_dir_all(&root).unwrap();
7508 let config = root.join("config.toml");
7509 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7510 let args = vec![
7511 "--config".to_string(),
7512 config.to_string_lossy().into_owned(),
7513 "--profile".to_string(),
7514 "ci".to_string(),
7515 ];
7516 let merged = config_args(
7517 &args,
7518 &ConfigRoots {
7519 home: None,
7520 cwd: root.clone(),
7521 no_color_env: false,
7522 },
7523 )
7524 .unwrap();
7525 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7526 assert!(merged
7527 .windows(2)
7528 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7529
7530 let bad = root.join("bad.toml");
7531 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7532 let error = config_args(
7533 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7534 &ConfigRoots {
7535 home: None,
7536 cwd: root.clone(),
7537 no_color_env: false,
7538 },
7539 )
7540 .unwrap_err();
7541 assert!(error.contains("config "));
7542 assert!(error.contains("unknown key"));
7543 std::fs::remove_dir_all(root).unwrap();
7544 }
7545
7546 #[test]
7547 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7548 let s = |v: &str| v.to_string();
7549 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7550 .err()
7551 .unwrap();
7552 assert!(error.contains("--batch cannot be combined with --diff"));
7553 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7554 .err()
7555 .unwrap();
7556 assert!(error.contains("--batch cannot be combined with --gif"));
7557 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7559 .err()
7560 .unwrap();
7561 assert!(error.contains("--pager cannot be combined with --batch"));
7562 }
7563
7564 #[test]
7565 fn line_counts_follow_python_splitlines() {
7566 assert_eq!(python_line_count(""), 0);
7567 assert_eq!(python_line_count("a"), 1);
7568 assert_eq!(python_line_count("a\n"), 1);
7569 assert_eq!(python_line_count("a\n\n"), 2);
7570 assert_eq!(python_line_count("a\r\nb"), 2);
7571 assert_eq!(
7572 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7573 8
7574 );
7575 }
7576
7577 #[test]
7578 fn glob_matches_metacharacters_rather_than_substrings() {
7579 assert!(glob_match("*.md", "notes.md"));
7580 assert!(!glob_match("*.md", "notes.md.bak"));
7582 assert!(glob_match("a?c.md", "abc.md"));
7583 assert!(!glob_match("a?c.md", "ac.md"));
7584 assert!(glob_match("*", "anything"));
7585 assert!(glob_match("a*b*c", "axxbyyc"));
7586 assert!(!glob_match("a*b*c", "axxbyy"));
7587 }
7588
7589 #[test]
7590 fn batch_expansion_skips_symlinked_directories() {
7591 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7592 let _ = std::fs::remove_dir_all(&root);
7593 std::fs::create_dir_all(&root).unwrap();
7594 std::fs::write(root.join("a.md"), "# a").unwrap();
7595 #[cfg(unix)]
7597 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7598 #[cfg(windows)]
7599 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7600 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7601 if linked {
7602 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7603 }
7604 assert!(inputs[0].ends_with("a.md"));
7605 let _ = std::fs::remove_dir_all(&root);
7606 }
7607
7608 #[test]
7609 fn subcommand_mode_wins_over_config_mode_default() {
7610 let s = |v: &str| v.to_string();
7611 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7612 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7613 assert!(!selects_mode_explicitly(&[
7615 s("--title"),
7616 s("json"),
7617 s("file")
7618 ]));
7619 assert!(!selects_mode_explicitly(&[s("file.md")]));
7620
7621 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7622 let _ = std::fs::remove_dir_all(&root);
7623 std::fs::create_dir_all(&root).unwrap();
7624 let config = root.join("config.toml");
7625 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7626 let merged = config_args(
7627 &[
7628 "--config".to_string(),
7629 config.to_string_lossy().into_owned(),
7630 "json".to_string(),
7631 "file.json".to_string(),
7632 ],
7633 &ConfigRoots {
7634 home: None,
7635 cwd: root.clone(),
7636 no_color_env: false,
7637 },
7638 )
7639 .unwrap();
7640 assert!(
7641 !merged.iter().any(|arg| arg == "--markdown"),
7642 "config mode must not override an explicit subcommand: {merged:?}"
7643 );
7644 let _ = std::fs::remove_dir_all(&root);
7645 }
7646
7647 #[test]
7648 fn suffix_collisions_step_past_files_already_on_disk() {
7649 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7650 let _ = std::fs::remove_dir_all(&root);
7651 std::fs::create_dir_all(&root).unwrap();
7652 let target = root.join("out.html");
7653 std::fs::write(&target, "existing").unwrap();
7654 let target = target.to_string_lossy().into_owned();
7655
7656 let cli = parse(&[
7657 "--no-config".to_string(),
7658 "--batch".to_string(),
7659 "--collision".to_string(),
7660 "suffix".to_string(),
7661 "x".to_string(),
7662 ])
7663 .unwrap()
7664 .unwrap();
7665 let mut taken = std::collections::BTreeSet::new();
7666 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7667 assert!(
7668 first.ends_with("out-2.html"),
7669 "existing file must be kept: {first}"
7670 );
7671 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7672 assert!(second.ends_with("out-3.html"));
7673
7674 let mut cli_error = cli.clone();
7676 cli_error.collision = CollisionPolicy::Error;
7677 let mut taken = std::collections::BTreeSet::new();
7678 let (class, message) =
7679 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7680 assert_eq!(class, ExitClass::Input);
7681 assert!(message.contains("--overwrite"));
7682
7683 let mut cli_overwrite = cli.clone();
7685 cli_overwrite.overwrite = true;
7686 let mut taken = std::collections::BTreeSet::new();
7687 assert_eq!(
7688 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7689 target
7690 );
7691 let _ = std::fs::remove_dir_all(&root);
7692 }
7693
7694 #[test]
7695 fn batch_outputs_keep_their_export_kind() {
7696 let outputs = BatchOutputs {
7698 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7699 ..BatchOutputs::default()
7700 };
7701 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7702 assert!(outputs.svg.is_none());
7703 }
7704
7705 #[test]
7706 fn parses_export_flags() {
7707 let s = |v: &str| v.to_string();
7708 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7710 .unwrap()
7711 .unwrap();
7712 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7713 assert!(cli.export_html.is_none());
7714 assert_eq!(cli.resource.as_deref(), Some("x"));
7715
7716 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7717 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7718
7719 let cli = parse(&[
7721 s("--export-html"),
7722 s("a.html"),
7723 s("--export-svg"),
7724 s("b.svg"),
7725 s("x"),
7726 ])
7727 .unwrap()
7728 .unwrap();
7729 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7730 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7731
7732 assert!(parse(&[s("--export-html")]).is_err());
7734 }
7736
7737 #[test]
7738 fn is_number_matches_pattern() {
7739 for ok in [
7744 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7745 ] {
7746 assert!(is_number(ok), "{ok:?} should be numeric");
7747 }
7748 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7749 assert!(!is_number(no), "{no:?} should not be numeric");
7750 }
7751 }
7752
7753 #[test]
7754 fn render_csv_matches_upstream() {
7755 let table = build_csv_table(
7759 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7760 Some(','),
7761 None,
7762 None,
7763 (None, None),
7764 false,
7765 )
7766 .expect("the sniffer reads this one");
7767 let out = Console::builder()
7768 .force_terminal(true)
7769 .color_system(Some(ColorSystem::Truecolor))
7770 .width(40)
7771 .no_color(false)
7772 .build()
7773 .render_to_string(&table);
7774 let expected = concat!(
7775 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7776 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7777 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7778 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7779 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7780 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7781 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7782 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7783 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7784 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7785 );
7786 assert_eq!(out, expected);
7787 }
7788}