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