1use std::io::{BufRead, IsTerminal, Read, Write};
26use std::path::{Path, PathBuf};
27use std::process::ExitCode;
28
29mod authoring;
30mod batch;
31mod batch_output;
32mod batch_paths;
33#[cfg(test)]
34mod batch_races;
35mod cli_spec;
36mod config;
37mod controls;
38mod demo;
39mod doctor;
40mod inspect;
41#[cfg(feature = "interact")]
42mod interactive;
43#[cfg(feature = "art")]
44mod micro;
45mod plugins;
46#[cfg(feature = "record")]
47mod record;
48mod render_target;
49mod structured_log;
50mod tools;
51mod viewers;
52mod watch;
53use batch::run_batch;
54use config::{config_args, ConfigRoots};
55
56use rich::cells::cell_len;
57use rich::markdown::Markdown;
58use rich::measure::Measurement;
59use rich::protocol::{LineRenderable, OwnedTableRows};
60use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
61use rich::text::Text;
62use rich::{
63 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
64 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
65 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
66 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
67 DEFAULT_TERMINAL_THEME,
68};
69use rich_ext::cli::CliExtensions;
70use rich_ext::encoding::{has_utf16_bom, Encoding};
71use rich_ext::sanitize_terminal_controls;
72use rich_ext::ConsoleExt;
73
74fn text(content: &str) -> Box<dyn Renderable> {
76 Box::new(Text::new(content))
77}
78
79const VERSION: &str = env!("CARGO_PKG_VERSION");
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Mode {
84 Auto,
86 Print,
88 Markdown,
90 Json,
92 Syntax,
94 Csv,
96 Ipynb,
98 Rst,
100 Gif,
102 Diff,
104 Image,
106 Rule,
108 JsonLines,
110 Log,
112 Inspect,
114 AnsiExplain,
116 View,
119 Hex,
121 Unicode,
123 Env,
125 Capture,
127 Mermaid,
129}
130
131impl Mode {
132 fn streams_records(self) -> bool {
133 matches!(self, Self::JsonLines | Self::Log)
134 }
135
136 fn draws_directly(self) -> bool {
137 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
138 }
139
140 fn allows_extensions(self) -> bool {
141 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
142 && !self.streams_records()
143 }
144
145 fn accepts_multiple_resources(self) -> bool {
146 matches!(self, Self::Gif | Self::Diff | Self::Env | Self::Capture)
147 }
148}
149
150struct ModeSpec {
151 mode: Mode,
152 primary: &'static str,
153 aliases: &'static [&'static str],
154}
155
156const MODE_SPECS: &[ModeSpec] = &[
157 ModeSpec {
158 mode: Mode::Auto,
159 primary: "auto",
160 aliases: &[],
161 },
162 ModeSpec {
163 mode: Mode::Print,
164 primary: "print",
165 aliases: &["print"],
166 },
167 ModeSpec {
168 mode: Mode::Markdown,
169 primary: "markdown",
170 aliases: &["markdown", "md"],
171 },
172 ModeSpec {
173 mode: Mode::Json,
174 primary: "json",
175 aliases: &["json"],
176 },
177 ModeSpec {
178 mode: Mode::Syntax,
179 primary: "syntax",
180 aliases: &["syntax", "code"],
181 },
182 ModeSpec {
183 mode: Mode::Csv,
184 primary: "csv",
185 aliases: &["csv", "tsv"],
186 },
187 ModeSpec {
188 mode: Mode::Ipynb,
189 primary: "ipynb",
190 aliases: &["ipynb", "notebook"],
191 },
192 ModeSpec {
193 mode: Mode::Rst,
194 primary: "rst",
195 aliases: &["rst"],
196 },
197 ModeSpec {
198 mode: Mode::Gif,
199 primary: "gif",
200 aliases: &["gif"],
201 },
202 ModeSpec {
203 mode: Mode::Diff,
204 primary: "diff",
205 aliases: &["diff"],
206 },
207 ModeSpec {
208 mode: Mode::Image,
209 primary: "image",
210 aliases: &["image"],
211 },
212 ModeSpec {
213 mode: Mode::Rule,
214 primary: "rule",
215 aliases: &["rule"],
216 },
217 ModeSpec {
218 mode: Mode::JsonLines,
219 primary: "jsonl",
220 aliases: &["jsonl", "ndjson"],
221 },
222 ModeSpec {
223 mode: Mode::Log,
224 primary: "log",
225 aliases: &["log", "logs"],
226 },
227 ModeSpec {
228 mode: Mode::Inspect,
229 primary: "inspect",
230 aliases: &["inspect"],
231 },
232 ModeSpec {
233 mode: Mode::AnsiExplain,
234 primary: "ansi",
235 aliases: &["ansi", "ansi-explain"],
236 },
237 ModeSpec {
238 mode: Mode::View,
239 primary: "view",
240 aliases: &["view"],
241 },
242 ModeSpec {
243 mode: Mode::Hex,
244 primary: "hex",
245 aliases: &["hex", "hexdump"],
246 },
247 ModeSpec {
248 mode: Mode::Unicode,
249 primary: "unicode",
250 aliases: &["unicode"],
251 },
252 ModeSpec {
253 mode: Mode::Env,
254 primary: "env",
255 aliases: &["env"],
256 },
257 ModeSpec {
258 mode: Mode::Capture,
259 primary: "capture",
260 aliases: &["capture"],
261 },
262 ModeSpec {
263 mode: Mode::Mermaid,
264 primary: "mermaid",
265 aliases: &["mermaid", "mmd"],
266 },
267];
268
269const RENDER_MODE_FLAGS: &str =
270 "--print/--markdown/--rst/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273enum ReportFormat {
274 Human,
275 Json,
276}
277
278impl ReportFormat {
279 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
280 match option {
281 "--report" => {
282 *self = match value.ok_or("--report requires one of: human, json")? {
283 "human" => Self::Human,
284 "json" => Self::Json,
285 other => return Err(format!("unknown report format {other:?} (human, json)")),
286 };
287 }
288 "--machine-json" => *self = Self::Json,
289 _ => {}
290 }
291 Ok(())
292 }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum ExitClass {
297 Success,
298 Usage,
299 Input,
300 Data,
301 #[cfg_attr(not(feature = "art"), allow(dead_code))]
302 Gate,
303}
304
305impl ExitClass {
306 fn code(self) -> u8 {
307 match self {
308 Self::Success => 0,
309 Self::Usage => 2,
310 Self::Input => 3,
311 Self::Data => 4,
312 Self::Gate => 5,
313 }
314 }
315
316 fn name(self) -> &'static str {
317 match self {
318 Self::Success => "success",
319 Self::Usage => "usage",
320 Self::Input => "input",
321 Self::Data => "data",
322 Self::Gate => "gate",
323 }
324 }
325
326 fn exit_code(self) -> ExitCode {
327 ExitCode::from(self.code())
328 }
329}
330
331#[derive(Clone, Copy, PartialEq, Eq, Debug)]
338enum ImageMode {
339 Auto,
341 Sixel,
343 Blocks,
345 Braille,
347 Quadrants,
349 Ascii,
351 None,
353}
354
355impl std::str::FromStr for ImageMode {
356 type Err = String;
357
358 fn from_str(value: &str) -> Result<Self, Self::Err> {
359 match value.to_ascii_lowercase().as_str() {
360 "auto" => Ok(Self::Auto),
361 "sixel" => Ok(Self::Sixel),
362 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
363 "braille" => Ok(Self::Braille),
364 "quadrants" | "quadrant" => Ok(Self::Quadrants),
365 "ascii" | "art" => Ok(Self::Ascii),
366 "none" | "off" => Ok(Self::None),
367 other => Err(format!(
368 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
369 )),
370 }
371 }
372}
373
374#[derive(Clone, Debug, Default, PartialEq)]
378struct SourceOptions {
379 head: Option<usize>,
381 tail: Option<usize>,
382 line_numbers: bool,
384 guides: bool,
386 lexer: Option<String>,
388 no_wrap: bool,
390}
391
392impl SourceOptions {
393 fn wants_syntax(&self) -> bool {
395 self.head.is_some()
396 || self.tail.is_some()
397 || self.line_numbers
398 || self.guides
399 || self.lexer.is_some()
400 || self.no_wrap
401 }
402
403 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
408 let lines = python_line_count(code) as i64;
409 match (self.head, self.tail) {
410 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
411 (Some(head), None) => Ok(Some((1, head as i64))),
412 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
413 (None, None) => Ok(None),
414 }
415 }
416
417 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
419 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
420 .line_numbers(self.line_numbers)
421 .indent_guides(self.guides)
422 .word_wrap(!self.no_wrap);
423 if let Some((start, end)) = self.line_range(code)? {
424 syntax = syntax.line_range(Some(start), Some(end));
425 }
426 Ok(syntax)
427 }
428}
429
430fn python_line_count(text: &str) -> usize {
434 let mut count = 0;
435 let mut chars = text.chars().peekable();
436 let mut open = false;
437 while let Some(c) = chars.next() {
438 match c {
439 '\r' => {
440 if chars.peek() == Some(&'\n') {
441 chars.next();
442 }
443 count += 1;
444 open = false;
445 }
446 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
447 | '\u{2029}' => {
448 count += 1;
449 open = false;
450 }
451 _ => open = true,
452 }
453 }
454 count + usize::from(open)
455}
456
457#[derive(Clone)]
459struct Cli {
460 mode: Mode,
461 resource: Option<String>,
462 #[cfg_attr(not(feature = "art"), allow(dead_code))]
466 resources: Vec<String>,
467 #[cfg_attr(not(feature = "art"), allow(dead_code))]
469 loops: Option<usize>,
470 #[cfg_attr(not(feature = "art"), allow(dead_code))]
473 diff_threshold: Option<f32>,
474 #[cfg_attr(not(feature = "art"), allow(dead_code))]
476 image_mode: ImageMode,
477 #[cfg_attr(not(feature = "art"), allow(dead_code))]
478 image_fit: Option<String>,
479 #[cfg_attr(not(feature = "art"), allow(dead_code))]
480 image_anchor: Option<String>,
481 #[cfg_attr(not(feature = "art"), allow(dead_code))]
482 image_background: Option<ImageBackdrop>,
483 #[cfg_attr(not(feature = "art"), allow(dead_code))]
484 image_color: Option<String>,
485 #[cfg_attr(not(feature = "art"), allow(dead_code))]
486 image_dither: Option<String>,
487 #[cfg_attr(not(feature = "art"), allow(dead_code))]
488 image_color_distance: Option<String>,
489 #[cfg_attr(not(feature = "art"), allow(dead_code))]
490 image_rotate: Option<u16>,
491 #[cfg_attr(not(feature = "art"), allow(dead_code))]
492 image_flip_horizontal: bool,
493 #[cfg_attr(not(feature = "art"), allow(dead_code))]
494 image_flip_vertical: bool,
495 #[cfg_attr(not(feature = "art"), allow(dead_code))]
496 image_grayscale: bool,
497 #[cfg_attr(not(feature = "art"), allow(dead_code))]
498 image_max_width: Option<usize>,
499 #[cfg_attr(not(feature = "art"), allow(dead_code))]
500 image_max_height: Option<usize>,
501 #[cfg_attr(not(feature = "art"), allow(dead_code))]
503 image_brightness: Option<f32>,
504 #[cfg_attr(not(feature = "art"), allow(dead_code))]
505 image_contrast: Option<f32>,
506 #[cfg_attr(not(feature = "art"), allow(dead_code))]
507 image_gamma: Option<f32>,
508 log_presentation: String,
509 mermaid_backend: Option<MermaidBackend>,
512 highlighter: Option<String>,
514 code_theme: Option<String>,
516 plugins: Vec<String>,
519 #[cfg_attr(not(feature = "art"), allow(dead_code))]
522 micro_project: bool,
523 theme_styles: std::collections::BTreeMap<String, Style>,
524 theme_file_styles: std::collections::BTreeMap<String, Style>,
527 #[cfg_attr(not(feature = "art"), allow(dead_code))]
530 height: Option<usize>,
531 extensions: CliExtensions,
532 width: Option<usize>,
533 justify: Option<Justify>,
534 text_justify: Option<Justify>,
538 emoji: bool,
541 soft: bool,
543 max_width: Option<usize>,
545 rule_style: Option<Style>,
547 rule_char: Option<String>,
548 force_terminal: bool,
550 source: SourceOptions,
552 no_color: bool,
553 export_html: Option<String>,
556 export_svg: Option<String>,
558 panel: Option<BoxSet>,
562 padding: Option<(usize, usize, usize, usize)>,
564 title: Option<String>,
566 caption: Option<String>,
568 panel_style: Option<Style>,
570 style: Option<Style>,
573 expand: bool,
576 pager: bool,
578 auto_pager: bool,
579 hyperlinks: bool,
583 sanitize: bool,
585 report_format: ReportFormat,
588 watch: bool,
590 watch_interval: f64,
592 watch_cache: bool,
594 watch_debounce: f64,
597 watch_poll: bool,
599 watch_exit_on_error: bool,
601 batch: bool,
602 progress: bool,
603 continue_on_error: bool,
604 jobs: usize,
605 dry_run: bool,
606 batch_paths: batch_paths::BatchPathOptions,
607 worker_args: Vec<String>,
608 overwrite: bool,
609 collision: CollisionPolicy,
610 data: inspect::DataOptions,
611 tools: tools::ToolOptions,
612 viewers: viewers::ViewerOptions,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617enum MermaidBackend {
618 Text,
620 Mmdc,
623 Off,
625}
626
627impl MermaidBackend {
628 fn parse(value: &str) -> Result<Self, String> {
629 match value {
630 "text" => Ok(Self::Text),
631 "off" => Ok(Self::Off),
632 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
633 "mmdc" => Err(
634 "--mermaid-backend mmdc needs a build with the mmdc feature \
635 (cargo install rs-rich-cli --features mmdc)"
636 .into(),
637 ),
638 other => Err(format!(
639 "unknown Mermaid backend {other:?} (text, mmdc, off)"
640 )),
641 }
642 }
643}
644
645#[cfg(feature = "mermaid")]
647fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
648 rich_mermaid::MermaidOptions {
649 backend: match backend {
650 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
651 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
652 },
653 ..rich_mermaid::MermaidOptions::default()
654 }
655}
656
657fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
660 let mut registry = builtin_registry(mermaid);
661 plugins::add_to(&mut registry);
662 registry
663}
664
665fn builtin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
667 #[cfg_attr(not(feature = "mermaid"), allow(unused_mut))]
668 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
669 #[cfg(feature = "mermaid")]
670 if mermaid != MermaidBackend::Off {
671 registry
672 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
673 .expect("the Mermaid plugin registers cleanly");
674 }
675 #[cfg(not(feature = "mermaid"))]
676 let _ = mermaid;
677 #[cfg(feature = "lumis")]
678 registry
679 .add_plugin(&rich_lumis::LumisPlugin)
680 .expect("the lumis plugin registers cleanly");
681 registry
682}
683
684fn code_highlighting(
688 highlighter: Option<&str>,
689 theme: Option<&str>,
690) -> Result<Option<rich::CodeHighlighting>, String> {
691 if highlighter.is_none() && theme.is_none() {
692 return Ok(None);
693 }
694 let mut registry = plugin_registry(MermaidBackend::Off);
695 let name = highlighter.unwrap_or("syntect");
696 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
697 return Err(match error {
698 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
699 if name == "lumis" && !cfg!(feature = "lumis") =>
700 {
701 format!(
702 "{error} (lumis needs a build with the lumis feature: cargo install \
703 rs-rich-cli --features lumis)"
704 )
705 }
706 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
707 let themes = registry
708 .code_highlighter(name)
709 .map(|engine| engine.themes())
710 .unwrap_or_default();
711 if themes.len() <= 40 {
712 format!("{error}; its themes: {}", themes.join(", "))
713 } else {
714 format!(
715 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
716 lists them all",
717 themes.len(),
718 themes[..12].join(", ")
719 )
720 }
721 }
722 other => other.to_string(),
723 });
724 }
725 Ok(registry.code_highlighting())
726}
727
728fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
730 use rich::ConsoleCodeHighlighting;
731 if let Ok(Some(highlighting)) =
732 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
733 {
734 console.set_code_highlighting(Some(highlighting));
735 }
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739enum CollisionPolicy {
740 Error,
741 Overwrite,
742 Suffix,
743}
744
745fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
757 Markdown::new(source).hyperlinks(hyperlinks)
758}
759
760pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
775 dispatch(remember_raw_args(args))
776}
777
778static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
782 std::sync::Mutex::new(Vec::new());
783
784fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
793 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
794 let args = args
795 .into_iter()
796 .map(|arg| match arg.into_string() {
797 Ok(arg) => arg,
798 Err(arg) => {
799 let spelling = escaped_path(&arg);
800 match raw.iter_mut().find(|(known, _)| *known == spelling) {
801 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
802 Some(_) => {}
803 None => raw.push((spelling.clone(), Some(arg))),
804 }
805 spelling
806 }
807 })
808 .collect();
809 if let Ok(mut slot) = RAW_ARGS.lock() {
810 *slot = raw;
811 }
812 args
813}
814
815pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
819 RAW_ARGS
820 .lock()
821 .ok()
822 .and_then(|raw| {
823 raw.iter()
824 .find(|(spelling, _)| spelling == arg)
825 .and_then(|(_, original)| original.clone())
826 })
827 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
828}
829
830pub(crate) fn text_arg(arg: &str) -> String {
834 if !arg.starts_with('\0') {
835 return arg.to_string();
836 }
837 RAW_ARGS
838 .lock()
839 .ok()
840 .and_then(|raw| {
841 raw.iter()
842 .find(|(spelling, _)| spelling == arg)
843 .and_then(|(_, original)| original.clone())
844 })
845 .map_or_else(
846 || arg.to_string(),
847 |original| original.to_string_lossy().into_owned(),
848 )
849}
850
851pub(crate) fn path_arg(path: &Path) -> String {
859 if let Some(text) = path.to_str() {
860 return text.to_string();
861 }
862 let spelling = escaped_path(path.as_os_str());
863 if let Ok(mut raw) = RAW_ARGS.lock() {
864 let original = path.as_os_str().to_owned();
865 match raw.iter_mut().find(|(known, _)| *known == spelling) {
866 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
867 Some(_) => {}
868 None => raw.push((spelling.clone(), Some(original))),
869 }
870 }
871 spelling
872}
873
874#[cfg(unix)]
877fn escaped_path(path: &std::ffi::OsStr) -> String {
878 use std::os::unix::ffi::OsStrExt;
879 let mut out = String::from('\0');
880 for chunk in path.as_bytes().utf8_chunks() {
881 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
882 for byte in chunk.invalid() {
883 out.push_str(&format!("\\x{byte:02X}"));
884 }
885 }
886 out
887}
888
889#[cfg(not(unix))]
890fn escaped_path(path: &std::ffi::OsStr) -> String {
891 format!("\0{}", path.to_string_lossy())
892}
893
894pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
904 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
907 if let Ok(mut slot) = SELF_PROGRAM.lock() {
908 *slot = program;
909 }
910 run(args)
911}
912
913static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
915
916static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
918
919fn dispatch(args: Vec<String>) -> ExitCode {
920 plugins::install(Vec::new());
923 if demo::requested(&args) {
924 return demo::dispatch(&args);
925 }
926 if doctor::requested(&args) {
927 return doctor::dispatch(&args);
928 }
929 if plugins::requested(&args) {
930 return plugins::dispatch(&args);
931 }
932 #[cfg(feature = "art")]
933 if micro::requested(&args) {
934 return micro::dispatch(&args);
935 }
936 #[cfg(feature = "interact")]
937 if let Some(command) = interactive::requested(&args) {
938 return interactive::dispatch(command, &args);
939 }
940 #[cfg(feature = "record")]
941 if record::requested(&args) {
942 return record::dispatch(&args);
943 }
944 match parse(&args) {
945 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
947 if let Err(message) = plugins::check_linked() {
948 return fail(&cli, ExitClass::Usage, message);
949 }
950 if !cli.plugins.is_empty() {
951 match plugins::load(&cli.plugins) {
952 Ok(loaded) => plugins::install(loaded),
953 Err((class, message)) => return fail(&cli, class, message),
954 }
955 }
956 if let Some(message) = cli
958 .data
959 .unknown_transform(&plugin_registry(MermaidBackend::Text))
960 {
961 return fail(&cli, ExitClass::Usage, message);
962 }
963 if cli.batch {
964 if let Err(error) = batch::install_interrupt_handler() {
965 return fail(
966 &cli,
967 ExitClass::Input,
968 format!("cannot install batch interruption handler: {error}"),
969 );
970 }
971 }
972 run_cli(cli)
973 }
974 Err(message) => emit_error(
975 wants_json_report(&args),
976 ExitClass::Usage,
977 &format!("{message} (try --help)"),
978 ),
979 }
980}
981
982fn self_command() -> std::io::Result<std::process::Command> {
986 let program = SELF_PROGRAM
987 .lock()
988 .map(|program| program.clone())
989 .unwrap_or_default();
990 Ok(match program.split_first() {
991 Some((executable, rest)) => {
992 let mut command = std::process::Command::new(executable);
993 command.args(rest);
994 command
995 }
996 None => std::process::Command::new(std::env::current_exe()?),
997 })
998}
999
1000const VALUE_OPTIONS: &[&str] = &[
1003 "--batch-input-root",
1004 "--batch-name-template",
1005 "--log-presentation",
1006 "--mermaid-backend",
1007 "--highlighter",
1008 "--code-theme",
1009 "--plugin",
1010 "-w",
1011 "--width",
1012 "-o",
1013 "--export-html",
1014 "--export-svg",
1015 "-a",
1016 "--panel",
1017 "-d",
1018 "--padding",
1019 "-h",
1020 "--head",
1021 "-t",
1022 "--tail",
1023 "--lexer",
1024 "-W",
1025 "--max-width",
1026 "--rule-style",
1027 "--rule-char",
1028 "--title",
1029 "--caption",
1030 "-s",
1031 "--style",
1032 "-S",
1033 "--panel-style",
1034 "--report",
1035 "--image-mode",
1036 "--image-fit",
1037 "--image-anchor",
1038 "--image-background",
1039 "--height",
1040 "--theme",
1041 "--theme-style",
1042 "--theme-file",
1043 "--image-color",
1044 "--image-dither",
1045 "--image-color-distance",
1046 "--image-rotate",
1047 "--image-max-width",
1048 "--image-max-height",
1049 "--image-brightness",
1050 "--image-contrast",
1051 "--image-gamma",
1052 "--demo-section",
1053 "--demo-delay",
1054 "--watch-interval",
1055 "--interval",
1056 "--watch-debounce",
1057 "--gif-mode",
1058 "--encoding",
1059 "--threshold",
1060 "--loop",
1061 "--jobs",
1062 "--collision",
1063 "--format",
1064 "--select",
1065 "--filter",
1066 "--highlight",
1067 "--transform",
1068 "--find",
1069 "--max-depth",
1070 "--max-length",
1071 "--compare",
1072 "--context",
1073 "--language",
1074 "--search",
1075 "--offset",
1076 "--length",
1077 "--bytes-per-line",
1078 "--group",
1079 "--limit",
1080 "--cast",
1081 "--redact-pattern",
1082 "--config",
1083 "--profile",
1084];
1085
1086fn selects_mode_explicitly(args: &[String]) -> bool {
1090 explicit_mode(args).is_some()
1091}
1092
1093fn explicit_mode(args: &[String]) -> Option<Mode> {
1095 let mut iter = args.iter();
1096 let mut seen_positional = false;
1097 while let Some(arg) = iter.next() {
1098 if arg == "--" {
1099 break;
1100 }
1101 if VALUE_OPTIONS.contains(&arg.as_str()) {
1102 iter.next();
1103 continue;
1104 }
1105 if arg.starts_with('-') && arg.len() > 1 {
1106 if let Some(flag) = mode_flag_alias(arg) {
1107 return command_mode(&flag[2..]);
1108 }
1109 continue;
1110 }
1111 if !seen_positional {
1112 seen_positional = true;
1113 if let Some(mode) = command_mode(arg) {
1114 return Some(mode);
1115 }
1116 }
1117 }
1118 None
1119}
1120
1121fn command_word(args: &[String]) -> Option<&str> {
1124 let mut iter = args.iter();
1125 while let Some(arg) = iter.next() {
1126 if arg == "--" {
1127 return None;
1128 }
1129 if VALUE_OPTIONS.contains(&arg.as_str()) {
1130 iter.next();
1131 } else if !arg.starts_with('-') || arg == "-" {
1132 return command_mode(arg).map(|_| arg.as_str());
1133 }
1134 }
1135 None
1136}
1137
1138pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1144 let mut iter = args.iter();
1145 while let Some(arg) = iter.next() {
1146 if arg == "--" || mode_flag_alias(arg).is_some() {
1147 return None;
1148 }
1149 if VALUE_OPTIONS.contains(&arg.as_str()) {
1150 iter.next();
1151 } else if !arg.starts_with('-') || arg == "-" {
1152 return Some(arg);
1153 }
1154 }
1155 None
1156}
1157
1158fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1160 Some(match arg {
1161 "--print" | "-p" => "--print",
1162 "--markdown" | "-m" => "--markdown",
1163 "--json" | "-j" | "-J" => "--json",
1164 "--syntax" | "-x" => "--syntax",
1165 "--csv" => "--csv",
1166 "--ipynb" => "--ipynb",
1167 "--rst" => "--rst",
1168 "--jsonl" | "--ndjson" => "--jsonl",
1169 "--log" => "--log",
1170 "--rule" | "-u" => "--rule",
1171 "--image" => "--image",
1172 "--gif" => "--gif",
1173 "--diff" => "--diff",
1174 "--inspect" => "--inspect",
1175 "--ansi-explain" => "--ansi-explain",
1176 _ => return None,
1177 })
1178}
1179
1180fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1181 let message = &controls::shown(message);
1184 if json {
1185 eprintln!(
1186 "{}",
1187 serde_json::json!({
1188 "ok": false,
1189 "code": class.name(),
1190 "exit_code": class.code(),
1191 "message": message,
1192 "error": {
1193 "message": message,
1194 },
1195 })
1196 );
1197 } else {
1198 eprintln!("rich: {message}");
1199 }
1200 class.exit_code()
1201}
1202
1203fn emit_success_report(format: ReportFormat) {
1204 if format == ReportFormat::Json {
1205 eprintln!(
1206 "{}",
1207 serde_json::json!({
1208 "ok": true,
1209 "code": ExitClass::Success.name(),
1210 "exit_code": ExitClass::Success.code(),
1211 "result": {},
1212 })
1213 );
1214 }
1215}
1216
1217fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1218 let message = controls::shown(message.as_ref());
1219 if watch::capture_error(&message) {
1221 return class.exit_code();
1222 }
1223 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1224}
1225
1226fn success(cli: &Cli) -> ExitCode {
1227 if !watch::capturing() {
1228 emit_success_report(cli.report_format);
1229 }
1230 ExitClass::Success.exit_code()
1231}
1232
1233#[cfg(test)]
1240fn glob_match(pattern: &str, text: &str) -> bool {
1241 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1242 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1243 glob_match_units(&pattern, &text)
1244}
1245
1246#[derive(Clone, Copy, PartialEq, Eq)]
1249enum GlobUnit {
1250 Char(char),
1251 Byte(u8),
1252}
1253
1254fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1257 #[cfg(unix)]
1258 {
1259 use std::os::unix::ffi::OsStrExt;
1260 let mut units = Vec::new();
1261 for chunk in name.as_bytes().utf8_chunks() {
1262 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1263 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1264 }
1265 units
1266 }
1267 #[cfg(not(unix))]
1268 {
1269 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1270 }
1271}
1272
1273fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1274 const ANY: GlobUnit = GlobUnit::Char('?');
1275 const STAR: GlobUnit = GlobUnit::Char('*');
1276 let (mut p, mut t) = (0usize, 0usize);
1277 let (mut star, mut retry) = (None, 0usize);
1278 while t < text.len() {
1279 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1280 p += 1;
1281 t += 1;
1282 } else if p < pattern.len() && pattern[p] == STAR {
1283 star = Some(p);
1284 retry = t;
1285 p += 1;
1286 } else if let Some(open) = star {
1287 p = open + 1;
1288 retry += 1;
1289 t = retry;
1290 } else {
1291 return false;
1292 }
1293 }
1294 while p < pattern.len() && pattern[p] == STAR {
1295 p += 1;
1296 }
1297 p == pattern.len()
1298}
1299
1300fn has_glob_meta(value: &str) -> bool {
1301 value.contains('*') || value.contains('?')
1302}
1303
1304fn split_glob(resource: &str) -> (PathBuf, String) {
1306 let meta = resource.find(['*', '?']).unwrap_or(0);
1307 match resource[..meta].rfind(['/', '\\']) {
1308 Some(cut) => (
1309 PathBuf::from(&resource[..cut]),
1310 resource[cut + 1..].to_string(),
1311 ),
1312 None => (PathBuf::from("."), resource.to_string()),
1313 }
1314}
1315
1316fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1324 let mut stack = vec![dir.to_path_buf()];
1325 while let Some(current) = stack.pop() {
1326 batch::check_interrupted()?;
1327 let entries = std::fs::read_dir(¤t)
1328 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1329 for entry in entries {
1330 batch::check_interrupted()?;
1331 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1332 let path = entry.path();
1333 let kind = std::fs::symlink_metadata(&path)
1334 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1335 .file_type();
1336 if kind.is_symlink() {
1337 continue;
1338 }
1339 if kind.is_dir() {
1340 stack.push(path);
1341 } else if kind.is_file() {
1342 out.push(path_arg(&path));
1343 }
1344 }
1345 }
1346 Ok(())
1347}
1348
1349fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1350 let mut out = Vec::new();
1351 for resource in resources {
1352 batch::check_interrupted()?;
1353 if resource == "-" || is_url(resource) {
1354 return Err("batch resources must be local files or directories".into());
1355 }
1356 if has_glob_meta(resource) {
1357 let raw = fs_path(resource);
1361 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1362 let (dir, pattern) = if escaped {
1363 let parent = raw.parent().unwrap_or(Path::new(""));
1364 if has_glob_meta(&parent.to_string_lossy()) {
1365 return Err(format!(
1366 "batch glob {} may only use * and ? in the final path segment",
1367 controls::shown(resource)
1368 ));
1369 }
1370 let dir = if parent.as_os_str().is_empty() {
1371 PathBuf::from(".")
1372 } else {
1373 parent.to_path_buf()
1374 };
1375 (dir, glob_units(raw.file_name().unwrap_or_default()))
1376 } else {
1377 let (dir, pattern) = split_glob(resource);
1378 if pattern.contains('/') || pattern.contains('\\') {
1379 return Err(format!(
1380 "batch glob {resource:?} may only use * and ? in the final path segment"
1381 ));
1382 }
1383 (dir, pattern.chars().map(GlobUnit::Char).collect())
1384 };
1385 let entries = std::fs::read_dir(&dir)
1386 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1387 for entry in entries {
1388 batch::check_interrupted()?;
1389 let entry =
1390 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1391 let path = entry.path();
1392 if !path.is_file() {
1393 continue;
1394 }
1395 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1396 out.push(path_arg(&path));
1397 }
1398 }
1399 continue;
1400 }
1401 let path = fs_path(resource);
1402 if path.is_file() {
1403 out.push(resource.clone());
1404 } else if path.is_dir() {
1405 collect_files(&path, &mut out)?;
1406 } else {
1407 return Err(format!("batch resource does not exist: {resource}"));
1408 }
1409 }
1410 batch::check_interrupted()?;
1411 out.sort();
1412 out.dedup();
1413 batch::check_interrupted()?;
1414 if out.is_empty() {
1415 return Err("batch plan contains no files".into());
1416 }
1417 Ok(out)
1418}
1419
1420#[derive(Default, Clone)]
1426struct BatchOutputs {
1427 html: Option<String>,
1428 svg: Option<String>,
1429}
1430
1431fn batch_export_path(
1432 path: Option<&str>,
1433 input: &str,
1434 index: usize,
1435 total: usize,
1436) -> Option<String> {
1437 let path = path?;
1438 if total == 1 {
1439 return Some(path.into());
1440 }
1441 let p = fs_path(path);
1442 let input = fs_path(input);
1444 let stem = input
1445 .file_stem()
1446 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1447 let mut name = stem.to_owned();
1448 match p.extension() {
1449 Some(ext) if !ext.is_empty() => {
1450 name.push(".");
1451 name.push(ext);
1452 }
1453 _ => name.push(format!("-{index}")),
1454 }
1455 Some(path_arg(&if p.is_dir() {
1456 p.join(name)
1457 } else {
1458 p.with_file_name(name)
1459 }))
1460}
1461
1462fn suffixed_path(path: &Path, suffix: usize) -> String {
1464 let mut name = path
1465 .file_stem()
1466 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1467 .to_owned();
1468 name.push(format!("-{suffix}"));
1469 if let Some(ext) = path.extension() {
1470 name.push(".");
1471 name.push(ext);
1472 }
1473 path_arg(&path.with_file_name(name))
1474}
1475
1476fn resolve_destination(
1482 cli: &Cli,
1483 candidate: String,
1484 taken: &mut std::collections::BTreeSet<String>,
1485) -> Result<String, (ExitClass, String)> {
1486 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1487 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1488 };
1489 match cli.collision {
1490 CollisionPolicy::Overwrite => {
1491 taken.insert(candidate.clone());
1492 Ok(candidate)
1493 }
1494 CollisionPolicy::Suffix => {
1495 let mut chosen = candidate.clone();
1496 let mut suffix = 2;
1497 while occupied(&chosen, taken) {
1498 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1499 chosen = suffixed_path(&fs_path(&candidate), suffix);
1500 suffix += 1;
1501 }
1502 taken.insert(chosen.clone());
1503 Ok(chosen)
1504 }
1505 CollisionPolicy::Error => {
1506 if taken.contains(&candidate) {
1507 return Err((
1508 ExitClass::Usage,
1509 format!("batch output collision: {candidate}"),
1510 ));
1511 }
1512 if !cli.overwrite && fs_path(&candidate).exists() {
1513 return Err((
1514 ExitClass::Input,
1515 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1516 ));
1517 }
1518 taken.insert(candidate.clone());
1519 Ok(candidate)
1520 }
1521 }
1522}
1523
1524fn worker_args(args: &[String]) -> Vec<String> {
1527 let mut result = vec!["--no-config".into()];
1528 let mut iter = args.iter();
1529 let mut seen_resource = false;
1530 let mut selected_mode = false;
1531 while let Some(arg) = iter.next() {
1532 if arg == "--" {
1533 break;
1534 }
1535 if VALUE_OPTIONS.contains(&arg.as_str()) {
1536 let value = iter.next();
1537 if !matches!(
1538 arg.as_str(),
1539 "--jobs"
1540 | "--batch-input-root"
1541 | "--batch-name-template"
1542 | "--collision"
1543 | "--report"
1544 | "-o"
1545 | "--export-html"
1546 | "--export-svg"
1547 | "--config"
1548 | "--profile"
1549 ) {
1550 result.push(arg.clone());
1551 if let Some(value) = value {
1552 result.push(value.clone());
1553 }
1554 }
1555 } else if arg.starts_with('-') && arg != "-" {
1556 selected_mode |= mode_flag_alias(arg).is_some();
1557 if !matches!(
1558 arg.as_str(),
1559 "--batch"
1560 | "--batch-preserve-dirs"
1561 | "--no-batch-preserve-dirs"
1562 | "--no-batch"
1563 | "--dry-run"
1564 | "--progress"
1565 | "--no-progress"
1566 | "--continue-on-error"
1567 | "--no-continue-on-error"
1568 | "--overwrite"
1569 | "--no-overwrite"
1570 | "--machine-json"
1571 ) {
1572 result.push(arg.clone());
1573 }
1574 } else if !seen_resource {
1575 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1576 selected_mode = true;
1577 result.push(format!("--{}", mode_name(mode)));
1578 } else {
1579 seen_resource = true;
1580 }
1581 }
1582 }
1583 result
1584}
1585
1586fn wants_json_report(args: &[String]) -> bool {
1587 let mut format = ReportFormat::Human;
1588 let mut iter = args.iter();
1589 while let Some(arg) = iter.next() {
1590 match arg.as_str() {
1591 "--report" => {
1592 if let Some(value) = iter.next() {
1593 let _ = format.apply_option("--report", Some(value));
1594 }
1595 }
1596 "--machine-json" => {
1597 let _ = format.apply_option("--machine-json", None);
1598 }
1599 "--" => break,
1600 _ => {}
1601 }
1602 }
1603 format == ReportFormat::Json
1604}
1605
1606fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1618 let name = name.to_ascii_lowercase();
1619 if name == "none" {
1620 return Ok(None);
1621 }
1622 PANEL_BOXES
1623 .iter()
1624 .find(|(known, _)| *known == name)
1625 .map(|(_, set)| Some(*set))
1626 .ok_or_else(|| {
1627 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1628 format!(
1629 "unknown panel box {name:?} (use none or {})",
1630 names.join(", ")
1631 )
1632 })
1633}
1634
1635pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1637 use rich::r#box as b;
1638 &[
1639 ("ascii", b::ASCII),
1640 ("ascii2", b::ASCII2),
1641 ("square", b::SQUARE),
1642 ("rounded", b::ROUNDED),
1643 ("heavy", b::HEAVY),
1644 ("double", b::DOUBLE),
1645 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1646 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1647 ("minimal", b::MINIMAL),
1648 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1649 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1650 ("simple", b::SIMPLE),
1651 ("simple_head", b::SIMPLE_HEAD),
1652 ("simple_heavy", b::SIMPLE_HEAVY),
1653 ("horizontals", b::HORIZONTALS),
1654 ("heavy_edge", b::HEAVY_EDGE),
1655 ("heavy_head", b::HEAVY_HEAD),
1656 ("double_edge", b::DOUBLE_EDGE),
1657 ("markdown", b::MARKDOWN),
1658 ]
1659};
1660
1661fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1665 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1666 let parts: Result<Vec<usize>, _> = value
1667 .split(',')
1668 .map(|p| p.trim().parse::<usize>())
1669 .collect();
1670 let parts = parts.map_err(|_| error())?;
1671 match parts.as_slice() {
1672 [p] => Ok((*p, *p, *p, *p)),
1673 [v, h] => Ok((*v, *h, *v, *h)),
1674 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1675 _ => Err(error()),
1676 }
1677}
1678
1679fn mode_name(mode: Mode) -> &'static str {
1681 MODE_SPECS
1682 .iter()
1683 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1684 .unwrap_or("unknown")
1685}
1686
1687fn command_mode(command: &str) -> Option<Mode> {
1688 MODE_SPECS
1689 .iter()
1690 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1691}
1692
1693fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1695 let flag = match flag {
1696 "-h" | "--head" => "--head",
1697 _ => "--tail",
1698 };
1699 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1700 match value.parse::<i64>() {
1701 Ok(lines) if lines >= 1 => Ok(lines as usize),
1702 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1703 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1704 }
1705}
1706
1707fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1708 if *current != Mode::Auto && *current != mode {
1709 return Err(format!(
1710 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1711 ));
1712 }
1713 *current = mode;
1714 Ok(())
1715}
1716
1717fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1719 let roots = ConfigRoots::default();
1720 if authoring::dispatch(args)? {
1721 return Ok(None);
1722 }
1723 if tools::bench_dispatch(args)? {
1724 return Ok(None);
1725 }
1726 if let Some(output) = config::inspect(args, &roots)? {
1727 authoring::out(&format!("{output}\n"));
1728 return Ok(None);
1729 }
1730 let merged = config_args(args, &roots)?;
1731 parse_inner(&merged)
1732}
1733
1734fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1735 let mut mode = Mode::Auto;
1736 let mut resources: Vec<String> = Vec::new();
1737 let mut loops = None;
1738 let mut diff_threshold = None;
1739 let mut image_mode = ImageMode::Auto;
1740 let mut image_fit = None;
1741 let mut image_anchor = None;
1742 let mut image_background = None;
1743 let mut image_color = None;
1744 let mut image_dither = None;
1745 let mut image_color_distance = None;
1746 let mut image_rotate = None;
1747 let mut image_flip_horizontal = false;
1748 let mut image_flip_vertical = false;
1749 let mut image_grayscale = false;
1750 let mut image_max_width = None;
1751 let mut image_max_height = None;
1752 let mut image_brightness = None;
1753 let mut image_contrast = None;
1754 let mut image_gamma = None;
1755 let mut log_presentation = String::from("plain");
1756 let mut mermaid_backend = None;
1757 let mut highlighter: Option<String> = None;
1758 let mut plugins: Vec<String> = Vec::new();
1759 let mut code_theme: Option<String> = None;
1760 let mut theme_styles = std::collections::BTreeMap::new();
1761 let mut theme_file_styles = std::collections::BTreeMap::new();
1762 let mut height = None;
1763 let mut extensions = CliExtensions::default();
1764 let mut data = inspect::DataOptions::default();
1765 let mut tool_options = tools::ToolOptions::default();
1766 let mut viewer_options = viewers::ViewerOptions::default();
1767 let mut paging_explicit = false;
1769 let mut ansi_word = false;
1771 let mut width = None;
1772 let mut justify = None;
1773 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1774 let mut export_html: Option<String> = None;
1775 let mut export_svg: Option<String> = None;
1776 let mut panel = None;
1777 let mut padding = None;
1778 let mut title = None;
1779 let mut caption = None;
1780 let mut panel_style = None;
1781 let mut style = None;
1782 let mut expand = false;
1783 let mut pager = false;
1784 let mut auto_pager = false;
1785 let mut hyperlinks = false;
1786 let mut source = SourceOptions::default();
1787 let mut text_flags = [false; 4];
1790 let mut emoji = false;
1791 let mut soft = false;
1792 let mut max_width = None;
1793 let mut rule_style = None;
1794 let mut rule_char = None;
1795 let mut force_terminal = false;
1796 let mut sanitize = None;
1799 let mut report_format = ReportFormat::Human;
1800 let mut watch = false;
1801 let mut watch_interval = 1.0;
1802 let mut watch_cache = false;
1803 let mut watch_debounce: Option<f64> = None;
1804 let mut watch_poll = false;
1805 let mut watch_exit_on_error = false;
1806 let mut batch = false;
1807 let mut progress = true;
1808 let mut micro_project = false;
1809 let mut continue_on_error = false;
1810 let mut jobs = 1usize;
1811 let mut dry_run = false;
1812 let mut batch_paths = batch_paths::BatchPathOptions::default();
1813 let mut overwrite = false;
1814 let mut collision = CollisionPolicy::Error;
1815 let mut end_of_options = false;
1820
1821 let mut iter = args.iter();
1822 while let Some(arg) = iter.next() {
1823 if end_of_options {
1824 resources.push(arg.to_string());
1825 continue;
1826 }
1827 if extensions.parse_option(arg, &mut iter)? {
1828 continue;
1829 }
1830 if data.parse_option(arg, &mut iter)? {
1831 continue;
1832 }
1833 if tool_options.parse_option(arg, &mut iter)? {
1834 continue;
1835 }
1836 if viewer_options.parse_option(arg, &mut iter)? {
1837 continue;
1838 }
1839 if matches!(
1840 arg.as_str(),
1841 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
1842 ) {
1843 paging_explicit = true;
1844 }
1845 match arg.as_str() {
1846 "--" => end_of_options = true,
1847 "--help" => {
1849 let no_color = no_color || cli_spec::no_color_requested(args);
1850 let help = command_word(args)
1852 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
1853 match help {
1854 Some(help) => authoring::out(&format!("{help}\n")),
1855 None => print_help(no_color),
1856 }
1857 return Ok(None);
1858 }
1859 "-V" | "-v" | "--version" => {
1860 println!("rich (rs-rich-cli) {VERSION}");
1861 return Ok(None);
1862 }
1863 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
1864 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
1865 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
1868 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
1869 "--csv" => set_mode(&mut mode, Mode::Csv)?,
1870 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
1871 "--rst" => set_mode(&mut mode, Mode::Rst)?,
1872 "--gif" => set_mode(&mut mode, Mode::Gif)?,
1873 "--diff" => set_mode(&mut mode, Mode::Diff)?,
1874 "--image" => set_mode(&mut mode, Mode::Image)?,
1875 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
1876 "--log" => set_mode(&mut mode, Mode::Log)?,
1877 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
1878 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
1879 "--highlighter" => {
1880 let value = iter
1881 .next()
1882 .ok_or("--highlighter requires a code highlighter's name")?;
1883 highlighter = Some(value.clone());
1884 }
1885 "--code-theme" => {
1886 let value = iter.next().ok_or("--code-theme requires a theme name")?;
1887 code_theme = Some(value.clone());
1888 }
1889 "--plugin" => {
1890 let path = iter.next().ok_or("--plugin requires PATH")?;
1891 plugins.push(path.clone());
1892 }
1893 "--mermaid-backend" => {
1894 let value = iter
1895 .next()
1896 .ok_or("--mermaid-backend requires text, mmdc or off")?;
1897 mermaid_backend = Some(MermaidBackend::parse(value)?);
1898 }
1899 "--log-presentation" => {
1900 let value = iter
1901 .next()
1902 .ok_or("--log-presentation requires plain or rich")?;
1903 if !matches!(value.as_str(), "plain" | "rich") {
1904 return Err("--log-presentation requires plain or rich".into());
1905 }
1906 log_presentation = value.clone();
1907 }
1908 "--report" => {
1909 let value = iter.next().map(String::as_str);
1910 report_format.apply_option("--report", value)?;
1911 }
1912 "--machine-json" => {
1913 report_format.apply_option("--machine-json", None)?;
1914 }
1915 "--progress" => progress = true,
1916 "--no-progress" => progress = false,
1917 "--micro-project" => micro_project = true,
1918 "--no-micro-project" => micro_project = false,
1919 "--theme-style" => {
1920 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
1921 let (name, value) = binding
1922 .split_once('=')
1923 .ok_or("--theme-style requires NAME=STYLE")?;
1924 config::validate_theme_binding(name, value)?;
1925 let style = Style::parse(value)
1926 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
1927 theme_styles.insert(name.to_owned(), style);
1928 }
1929 "--theme-file" => {
1930 let path = iter.next().ok_or("--theme-file requires PATH")?;
1931 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
1932 }
1933 "--image-color" => {
1934 const USAGE: &str =
1935 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
1936 let value = iter.next().ok_or(USAGE)?;
1937 if !matches!(
1938 value.as_str(),
1939 "truecolor" | "ansi256" | "ansi16" | "grayscale"
1940 ) {
1941 return Err(USAGE.into());
1942 }
1943 image_color = Some(value.clone());
1944 }
1945 flag @ ("--image-max-width" | "--image-max-height") => {
1946 let usage = format!("{flag} requires a positive integer");
1947 let value = iter
1948 .next()
1949 .and_then(|v| v.parse::<usize>().ok())
1950 .filter(|v| *v > 0)
1951 .ok_or(usage)?;
1952 if flag == "--image-max-width" {
1953 image_max_width = Some(value);
1954 } else {
1955 image_max_height = Some(value);
1956 }
1957 }
1958 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
1959 let gamma = flag == "--image-gamma";
1960 let usage = if gamma {
1961 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
1962 } else {
1963 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
1964 };
1965 let value = iter
1966 .next()
1967 .and_then(|v| v.parse::<f32>().ok())
1968 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
1969 .ok_or(usage)?;
1970 match flag {
1971 "--image-brightness" => image_brightness = Some(value),
1972 "--image-contrast" => image_contrast = Some(value),
1973 _ => image_gamma = Some(value),
1974 }
1975 }
1976 "--image-flip-horizontal" => image_flip_horizontal = true,
1977 "--no-image-flip-horizontal" => image_flip_horizontal = false,
1978 "--image-flip-vertical" => image_flip_vertical = true,
1979 "--no-image-flip-vertical" => image_flip_vertical = false,
1980 "--image-grayscale" => image_grayscale = true,
1981 "--no-image-grayscale" => image_grayscale = false,
1982 "--image-rotate" => {
1983 let value = iter
1984 .next()
1985 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
1986 image_rotate = Some(match value.as_str() {
1987 "0" => 0,
1988 "90" => 90,
1989 "180" => 180,
1990 "270" => 270,
1991 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
1992 });
1993 }
1994 "--image-dither" => {
1995 const USAGE: &str =
1996 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
1997 let value = iter.next().ok_or(USAGE)?;
1998 if !matches!(
1999 value.as_str(),
2000 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2001 ) {
2002 return Err(USAGE.into());
2003 }
2004 image_dither = Some(value.clone());
2005 }
2006 "--image-color-distance" => {
2007 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2008 let value = iter.next().ok_or(USAGE)?;
2009 if !matches!(value.as_str(), "rgb" | "oklab") {
2010 return Err(USAGE.into());
2011 }
2012 image_color_distance = Some(value.clone());
2013 }
2014 "--image-mode" => {
2015 let value = iter.next().ok_or(
2016 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2017 )?;
2018 image_mode = value.parse()?;
2019 }
2020 "--image-fit" => {
2021 let value = iter
2022 .next()
2023 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2024 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2025 return Err("--image-fit requires contain, cover, stretch or native".into());
2026 }
2027 image_fit = Some(value.clone());
2028 }
2029 "--image-anchor" => {
2030 let value = iter.next().ok_or("--image-anchor requires a position")?;
2031 if !matches!(
2032 value.as_str(),
2033 "center"
2034 | "top"
2035 | "bottom"
2036 | "left"
2037 | "right"
2038 | "top-left"
2039 | "top-right"
2040 | "bottom-left"
2041 | "bottom-right"
2042 ) {
2043 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2044 }
2045 image_anchor = Some(value.clone());
2046 }
2047 "--image-background" => {
2048 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2049 let value = iter.next().ok_or(USAGE)?;
2050 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2051 }
2052 "--height" => {
2053 let value = iter.next().ok_or("--height requires a number")?;
2054 let rows: usize = value
2055 .parse()
2056 .map_err(|_| format!("invalid height '{value}'"))?;
2057 if rows == 0 {
2058 return Err("--height must be at least 1".into());
2059 }
2060 height = Some(rows);
2061 }
2062 "--threshold" => {
2063 let value = iter
2064 .next()
2065 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2066 let percent: f32 = value
2067 .parse()
2068 .map_err(|_| format!("invalid threshold '{value}'"))?;
2069 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2074 return Err(format!(
2075 "threshold must be a percentage between 0 and 100, got '{value}'"
2076 ));
2077 }
2078 diff_threshold = Some(percent);
2079 }
2080 "--loop" => {
2081 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2082 loops = Some(
2083 value
2084 .parse()
2085 .map_err(|_| format!("invalid loop count {value:?}"))?,
2086 );
2087 }
2088 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2089 "-l" | "--left" => justify = Some(Justify::Left),
2090 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2091 "-r" | "--right" => {}
2092 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2093 "-c" | "--center" => {}
2094 "-L" | "--text-left" => text_flags[0] = true,
2095 "-R" | "--text-right" => text_flags[1] = true,
2096 "-C" | "--text-center" => text_flags[2] = true,
2097 "-F" | "--text-full" => text_flags[3] = true,
2098 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2099 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2100 "-n" | "--line-numbers" => source.line_numbers = true,
2101 "-g" | "--guides" => source.guides = true,
2102 "--lexer" => {
2103 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2104 }
2105 "--no-wrap" => source.no_wrap = true,
2106 "--emoji" => emoji = true,
2107 "--soft" => soft = true,
2108 "-W" | "--max-width" => {
2109 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2110 let size: i64 = value
2112 .parse()
2113 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2114 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2115 }
2116 "--rule-style" => {
2117 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2118 rule_style = Some(
2119 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2120 );
2121 }
2122 "--rule-char" => {
2123 rule_char = Some(
2124 iter.next()
2125 .ok_or("--rule-char requires a CHARACTER")?
2126 .clone(),
2127 );
2128 }
2129 "--force-terminal" => force_terminal = true,
2130 "--no-color" => no_color = true,
2131 "--pager" => {
2132 pager = true;
2133 auto_pager = false;
2134 }
2135 "--auto-pager" => {
2136 auto_pager = true;
2137 pager = false;
2138 }
2139 "--no-pager" => {
2140 pager = false;
2141 auto_pager = false;
2142 }
2143 "--no-auto-pager" => auto_pager = false,
2144 "--color" => no_color = false,
2145 "--no-watch" => watch = false,
2146 "--no-watch-cache" => watch_cache = false,
2147 "--no-watch-poll" => watch_poll = false,
2148 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2149 "--no-batch" => batch = false,
2150 "--no-continue-on-error" => continue_on_error = false,
2151 "--no-overwrite" => {
2152 overwrite = false;
2153 collision = CollisionPolicy::Error;
2154 }
2155 "--no-sanitize" => sanitize = Some(false),
2156 "--dry-run" => dry_run = true,
2157 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2158 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2159 "--batch-input-root" => {
2160 batch_paths.input_root = Some(fs_path(
2161 iter.next().ok_or("--batch-input-root requires a path")?,
2162 ))
2163 }
2164 "--batch-name-template" => {
2165 batch_paths.template = Some(
2166 batch_paths::FilenameTemplate::parse(
2167 iter.next()
2168 .ok_or("--batch-name-template requires a template")?,
2169 )
2170 .map_err(|e| e.to_string())?,
2171 )
2172 }
2173 "--watch" => watch = true,
2174 "--watch-cache" => watch_cache = true,
2175 "--watch-poll" => watch_poll = true,
2176 "--watch-exit-on-error" => watch_exit_on_error = true,
2177 "--watch-debounce" => {
2178 let value = iter
2179 .next()
2180 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2181 let seconds = value
2182 .parse::<f64>()
2183 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2184 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2185 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2186 }
2187 watch_debounce = Some(seconds);
2188 }
2189 "--watch-interval" | "--interval" => {
2190 let value = iter
2191 .next()
2192 .ok_or("--watch-interval requires seconds greater than zero")?;
2193 watch_interval = value
2194 .parse::<f64>()
2195 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2196 if !watch_interval.is_finite() || watch_interval <= 0.0 {
2197 return Err("--watch-interval must be greater than zero".into());
2198 }
2199 }
2200 "--batch" => batch = true,
2201 "--continue-on-error" => continue_on_error = true,
2202 "--overwrite" => {
2203 overwrite = true;
2204 collision = CollisionPolicy::Overwrite;
2205 }
2206 "--collision" => {
2207 collision = match iter
2208 .next()
2209 .ok_or("--collision requires error, overwrite, or suffix")?
2210 .as_str()
2211 {
2212 "error" => CollisionPolicy::Error,
2213 "overwrite" => CollisionPolicy::Overwrite,
2214 "suffix" => CollisionPolicy::Suffix,
2215 other => return Err(format!("unknown collision policy {other:?}")),
2216 };
2217 overwrite = collision == CollisionPolicy::Overwrite;
2218 }
2219 "--jobs" => {
2220 jobs = iter
2221 .next()
2222 .ok_or("--jobs requires a positive number")?
2223 .parse()
2224 .map_err(|_| "--jobs requires a positive number".to_string())?;
2225 if jobs == 0 {
2226 return Err("--jobs must be at least 1".into());
2227 }
2228 }
2229 "--sanitize" => sanitize = Some(true),
2230 "-y" | "--hyperlinks" => hyperlinks = true,
2234 "--export-html" | "-o" => {
2238 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2239 }
2240 "--export-svg" => {
2241 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2242 }
2243 "-a" | "--panel" => {
2244 let value = iter.next().ok_or("--panel requires a box name")?;
2245 panel = parse_box(value)?;
2246 }
2247 "-d" | "--padding" => {
2248 let value = iter.next().ok_or("--padding requires a value")?;
2249 padding = Some(parse_padding(value)?);
2250 }
2251 "-e" | "--expand" => expand = true,
2252 "--title" => {
2253 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2254 }
2255 "--caption" => {
2256 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2257 }
2258 "-s" | "--style" => {
2263 let value = iter.next().ok_or("--style requires a value")?;
2264 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2265 }
2266 "-S" | "--panel-style" => {
2267 let value = iter.next().ok_or("--panel-style requires a value")?;
2268 panel_style =
2269 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2270 }
2271 "-w" | "--width" => {
2272 let value = iter.next().ok_or("--width requires a number")?;
2273 let columns: usize = value.parse().map_err(|_| {
2274 if std::path::Path::new(value).exists() {
2278 format!("--width needs a number, but got the file '{value}' — a value is missing")
2279 } else {
2280 format!("invalid width '{value}'")
2281 }
2282 })?;
2283 if columns == 0 {
2284 return Err("--width must be at least 1".into());
2285 }
2286 width = Some(columns);
2287 }
2288 other if other.starts_with('-') && other != "-" => {
2289 return Err(format!("unknown option {other:?}"));
2290 }
2291 other
2292 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2293 {
2294 ansi_word = other == "ansi";
2295 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2296 }
2297 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2298 other => resources.push(other.to_string()),
2299 }
2300 }
2301
2302 if data.format.is_some() {
2305 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2306 return Err(format!(
2307 "--format cannot be combined with --{}",
2308 mode_name(mode)
2309 ));
2310 }
2311 if mode == Mode::Auto && resources.is_empty() && !batch {
2312 resources.push("-".into());
2313 }
2314 }
2315 if let Some(flag) = data
2316 .inspect_only_option(mode == Mode::Capture)
2317 .filter(|_| mode != Mode::Inspect)
2318 {
2319 if flag == "--redact" {
2320 return Err("--redact only has an effect with --inspect or `rich capture`".into());
2321 }
2322 return Err(format!("{flag} only has an effect with --inspect"));
2323 }
2324 if let Some(flag) = data.transform_option() {
2325 if !transforms_apply(mode) {
2326 return Err(format!(
2327 "{flag} only has an effect on {}",
2328 transform_modes(flag)
2329 ));
2330 }
2331 data.check_transforms(mode == Mode::Inspect)?;
2332 }
2333 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2334 return Err(format!("{flag} only has an effect with --diff"));
2335 }
2336 if let Some(flag) = tool_options
2337 .ansi_option()
2338 .filter(|_| mode != Mode::AnsiExplain)
2339 {
2340 return Err(format!("{flag} only has an effect with --ansi-explain"));
2341 }
2342 for (flag, commands) in viewer_options.given() {
2343 if !commands.contains(&mode_name(mode)) {
2344 return Err(format!(
2345 "{flag} only has an effect with `rich {}`",
2346 commands.join("` or `rich ")
2347 ));
2348 }
2349 }
2350 if mode == Mode::Capture && resources.is_empty() {
2351 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2352 }
2353 if mode == Mode::View && !paging_explicit {
2354 auto_pager = true;
2355 }
2356 data.validate()?;
2357
2358 if watch && (batch || pager || auto_pager) {
2359 return Err("--watch cannot be combined with --batch or paging".into());
2360 }
2361
2362 if batch {
2366 if mode.accepts_multiple_resources() {
2367 return Err(format!(
2368 "--batch cannot be combined with {}",
2369 if mode == Mode::Diff {
2370 "--diff"
2371 } else {
2372 "--gif"
2373 }
2374 ));
2375 }
2376 if resources.is_empty() {
2377 return Err("--batch needs at least one file, directory or glob".into());
2378 }
2379 if pager || auto_pager {
2381 return Err("--pager cannot be combined with --batch".into());
2382 }
2383 }
2384
2385 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2388 return Err(
2389 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2390 );
2391 }
2392 if mode == Mode::Image {
2393 if resources.is_empty() {
2394 return Err(
2395 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2396 );
2397 }
2398 if image_mode == ImageMode::None {
2399 return Err(
2400 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2401 .into(),
2402 );
2403 }
2404 }
2405 let quantized = matches!(
2406 image_color.as_deref(),
2407 Some("ansi256" | "ansi16" | "grayscale")
2408 );
2409 if quantized && image_mode == ImageMode::Braille {
2410 return Err(format!(
2411 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2412 image_color.as_deref().unwrap()
2413 ));
2414 }
2415 for (flag, value, neutral) in [
2416 ("--image-dither", image_dither.as_deref(), "none"),
2417 (
2418 "--image-color-distance",
2419 image_color_distance.as_deref(),
2420 "rgb",
2421 ),
2422 ] {
2423 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2424 return Err(format!(
2425 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2426 ));
2427 }
2428 }
2429 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2430 return Err("--image-anchor requires --image-fit cover".into());
2431 }
2432 if (batch_paths.preserve_dirs
2433 || batch_paths.template.is_some()
2434 || batch_paths.input_root.is_some())
2435 && !batch
2436 {
2437 return Err("batch path options require --batch".into());
2438 }
2439 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2440 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2441 }
2442 if dry_run && !batch {
2443 return Err("--dry-run requires --batch".into());
2444 }
2445 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2446 return Err("--jobs greater than 1 requires --batch with a file export".into());
2447 }
2448 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2450 return Err("--image-fit requires --height to define the target rectangle".into());
2451 }
2452 let effective_mode = if mode == Mode::Auto {
2457 detect_mode(resources.first().map(String::as_str))
2458 } else {
2459 mode
2460 };
2461 extensions.validate(
2462 effective_mode == Mode::Gif,
2463 effective_mode.allows_extensions()
2464 && !(mode == Mode::Auto && resources.is_empty())
2465 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2466 )?;
2467 let exporting = export_html.is_some() || export_svg.is_some();
2468 if exporting {
2469 let unsupported = if effective_mode == Mode::Gif {
2470 Some("--gif")
2471 } else if mode == Mode::Auto && resources.is_empty() {
2472 Some("the capability demo")
2473 } else {
2474 None
2475 };
2476 if let Some(what) = unsupported {
2477 return Err(format!("--export-html/--export-svg cannot capture {what}"));
2478 }
2479 }
2480
2481 let text_justify = [
2487 Justify::Left,
2488 Justify::Right,
2489 Justify::Center,
2490 Justify::Full,
2491 ]
2492 .into_iter()
2493 .zip(text_flags)
2494 .find_map(|(justify, given)| given.then_some(justify));
2495 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2498 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2499 let orphans = [
2500 (
2501 "--head/--tail",
2502 source.head.is_some() || source.tail.is_some(),
2503 "--syntax, --csv or --ipynb",
2504 source_mode || effective_mode == Mode::Csv,
2505 ),
2506 (
2507 "--line-numbers",
2508 source.line_numbers,
2509 "--syntax or --ipynb",
2510 source_mode,
2511 ),
2512 (
2513 "--guides",
2514 source.guides,
2515 "--syntax or --ipynb",
2516 source_mode,
2517 ),
2518 (
2519 "--lexer",
2520 source.lexer.is_some(),
2521 "--syntax, --ipynb or --rst",
2522 source_mode || effective_mode == Mode::Rst,
2523 ),
2524 (
2525 "--no-wrap",
2526 source.no_wrap,
2527 "--syntax, --ipynb, --print or --rule",
2528 source_mode || print_mode,
2529 ),
2530 (
2531 "--text-left/--text-right/--text-center/--text-full",
2532 text_justify.is_some(),
2533 "--print or --rule",
2534 print_mode,
2535 ),
2536 (
2537 "--rule-style",
2538 rule_style.is_some(),
2539 "--rule",
2540 effective_mode == Mode::Rule,
2541 ),
2542 (
2543 "--rule-char",
2544 rule_char.is_some(),
2545 "--rule",
2546 effective_mode == Mode::Rule,
2547 ),
2548 (
2551 "--soft",
2552 soft,
2553 "a mode other than --jsonl or --log",
2554 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2555 ),
2556 (
2557 "--max-width",
2558 max_width.is_some(),
2559 "a mode other than --jsonl or --log",
2560 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2561 ),
2562 (
2563 "--threshold",
2564 diff_threshold.is_some(),
2565 "--diff",
2566 mode == Mode::Diff,
2567 ),
2568 (
2569 "--image-mode",
2570 image_mode != ImageMode::Auto,
2571 "--diff/--image",
2572 mode == Mode::Diff || mode == Mode::Image,
2573 ),
2574 ("--height", height.is_some(), "--image", mode == Mode::Image),
2575 (
2576 "--image-rotate",
2577 image_rotate.is_some(),
2578 "--image",
2579 mode == Mode::Image,
2580 ),
2581 (
2582 "--image-flip-horizontal",
2583 image_flip_horizontal,
2584 "--image",
2585 mode == Mode::Image,
2586 ),
2587 (
2588 "--image-flip-vertical",
2589 image_flip_vertical,
2590 "--image",
2591 mode == Mode::Image,
2592 ),
2593 (
2594 "--image-grayscale",
2595 image_grayscale,
2596 "--image",
2597 mode == Mode::Image,
2598 ),
2599 (
2600 "--image-color",
2601 image_color.is_some(),
2602 "--image or --gif",
2603 mode == Mode::Image || effective_mode == Mode::Gif,
2604 ),
2605 (
2606 "--image-max-width",
2607 image_max_width.is_some(),
2608 "--image",
2609 mode == Mode::Image,
2610 ),
2611 (
2612 "--image-max-height",
2613 image_max_height.is_some(),
2614 "--image",
2615 mode == Mode::Image,
2616 ),
2617 (
2618 "--image-brightness",
2619 image_brightness.is_some(),
2620 "--image",
2621 mode == Mode::Image,
2622 ),
2623 (
2624 "--image-contrast",
2625 image_contrast.is_some(),
2626 "--image",
2627 mode == Mode::Image,
2628 ),
2629 (
2630 "--image-gamma",
2631 image_gamma.is_some(),
2632 "--image",
2633 mode == Mode::Image,
2634 ),
2635 (
2636 "--image-dither",
2637 image_dither.is_some(),
2638 "--image or --gif",
2639 mode == Mode::Image || effective_mode == Mode::Gif,
2640 ),
2641 (
2642 "--image-color-distance",
2643 image_color_distance.is_some(),
2644 "--image or --gif",
2645 mode == Mode::Image || effective_mode == Mode::Gif,
2646 ),
2647 (
2648 "--image-fit",
2649 image_fit.is_some(),
2650 "--image",
2651 mode == Mode::Image,
2652 ),
2653 (
2654 "--image-background",
2655 image_background.is_some(),
2656 "--image",
2657 mode == Mode::Image,
2658 ),
2659 (
2660 "--loop",
2661 loops.is_some(),
2662 "--gif",
2663 effective_mode == Mode::Gif,
2664 ),
2665 (
2670 "--panel-style",
2671 panel_style.is_some(),
2672 "--panel",
2673 panel.is_some(),
2674 ),
2675 (
2676 "--expand",
2677 expand,
2678 "--panel/--padding",
2679 panel.is_some() || padding.is_some(),
2680 ),
2681 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
2682 ("--watch-cache", watch_cache, "--watch", watch),
2683 (
2684 "--watch-debounce",
2685 watch_debounce.is_some(),
2686 "--watch",
2687 watch,
2688 ),
2689 ("--watch-poll", watch_poll, "--watch", watch),
2690 (
2691 "--watch-exit-on-error",
2692 watch_exit_on_error,
2693 "--watch",
2694 watch,
2695 ),
2696 ];
2697 if let Some((flag, _, needs, _)) = orphans
2698 .iter()
2699 .find(|(_, given, _, mode_present)| *given && !*mode_present)
2700 {
2701 return Err(format!("{flag} only has an effect with {needs}"));
2702 }
2703
2704 let demo = mode == Mode::Auto && resources.is_empty();
2707 if effective_mode.draws_directly() || demo {
2708 let mode_name = if effective_mode == Mode::Gif {
2709 "--gif"
2710 } else if effective_mode == Mode::Image {
2711 "--image"
2712 } else if effective_mode.streams_records() {
2713 mode_name(effective_mode)
2714 } else if demo {
2715 "the capability demo"
2716 } else {
2717 "--diff"
2718 };
2719 let unsupported = [
2720 (
2721 "--pager",
2722 (pager || auto_pager)
2723 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
2724 ),
2725 (
2726 "--export-html/--export-svg",
2727 (export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
2728 ),
2729 ("--width", demo && width.is_some()),
2730 ("--theme-style", demo && !theme_styles.is_empty()),
2731 ("--theme-file", demo && !theme_file_styles.is_empty()),
2732 (
2733 "--hyperlinks",
2734 hyperlinks && (effective_mode.streams_records() || demo),
2735 ),
2736 ("--panel", panel.is_some()),
2737 ("--padding", padding.is_some()),
2738 ("--title", title.is_some()),
2739 ("--caption", caption.is_some()),
2740 ("--style", style.is_some()),
2741 ("--panel-style", panel_style.is_some()),
2742 ("--expand", expand),
2743 ("--left/--center/--right", justify.is_some()),
2744 ];
2745 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
2746 return Err(format!("{flag} cannot be combined with {mode_name}"));
2747 }
2748 }
2749 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
2752 return Err("only one resource may be given (except with --gif or --watch)".into());
2753 }
2754 let sanitize = sanitize.unwrap_or_else(|| {
2758 mode == Mode::View
2759 || (mode == Mode::Diff
2760 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
2761 });
2762 let resource = resources.first().map(|resource| {
2764 if print_mode {
2765 text_arg(resource)
2766 } else {
2767 resource.clone()
2768 }
2769 });
2770 let title = title.map(|title| text_arg(&title));
2771 let caption = caption.map(|caption| text_arg(&caption));
2772 let rule_char = rule_char.map(|characters| text_arg(&characters));
2773
2774 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
2776 Ok(Some(Cli {
2777 mode,
2778 resource,
2779 resources,
2780 loops,
2781 diff_threshold,
2782 image_mode,
2783 image_fit,
2784 image_anchor,
2785 image_background,
2786 image_color,
2787 image_dither,
2788 image_color_distance,
2789 image_rotate,
2790 image_flip_horizontal,
2791 image_flip_vertical,
2792 image_grayscale,
2793 image_max_width,
2794 image_max_height,
2795 image_brightness,
2796 image_contrast,
2797 image_gamma,
2798 log_presentation,
2799 mermaid_backend,
2800 highlighter,
2801 code_theme,
2802 plugins,
2803 micro_project,
2804 theme_styles,
2805 theme_file_styles,
2806 height,
2807 extensions,
2808 width,
2809 justify,
2810 text_justify,
2811 emoji,
2812 soft,
2813 max_width,
2814 rule_style,
2815 rule_char,
2816 force_terminal,
2817 source,
2818 no_color,
2819 export_html,
2820 export_svg,
2821 panel,
2822 padding,
2823 title,
2824 caption,
2825 panel_style,
2826 style,
2827 expand,
2828 pager,
2829 auto_pager,
2830 hyperlinks,
2831 sanitize,
2832 report_format,
2833 watch,
2834 watch_interval,
2835 watch_cache,
2836 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
2837 watch_poll,
2838 watch_exit_on_error,
2839 batch,
2840 progress,
2841 continue_on_error,
2842 jobs,
2843 dry_run,
2844 batch_paths,
2845 worker_args: worker_args(args),
2846 overwrite,
2847 collision,
2848 data,
2849 tools: tool_options,
2850 viewers: viewer_options,
2851 }))
2852}
2853
2854fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
2864 use std::io::{Seek, SeekFrom};
2865 let resource = cli.resource.as_deref();
2866 let name = resource.unwrap_or("<stdin>");
2867 let window = |bytes: Vec<u8>| {
2868 let start = usize::try_from(offset)
2869 .unwrap_or(usize::MAX)
2870 .min(bytes.len());
2871 let mut rest = bytes[start..].to_vec();
2872 let more = rest.len() as u64 > max;
2873 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
2874 (rest, more)
2875 };
2876 if let Some(url) = resource.filter(|r| is_url(r)) {
2877 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
2878 }
2879 if cli.extensions.encoding.is_some() {
2880 return read_resource_limited(
2881 resource,
2882 cli.extensions.encoding,
2883 Some(controls::INSPECT_LIMIT),
2884 )
2885 .map(|text| window(text.into_bytes()))
2886 .map_err(|err| format!("cannot read {name}: {err}"));
2887 }
2888 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
2889 let take = max.saturating_add(1);
2891 let mut bytes = Vec::new();
2892 match resource.filter(|r| *r != "-") {
2893 Some(path) if fs_path(path).is_dir() => {
2894 return Err(format!("cannot read {path}: it is a directory"));
2895 }
2896 Some(path) => {
2897 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
2898 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
2900 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
2901 .map_err(error)?;
2902 }
2903 file.take(take).read_to_end(&mut bytes).map_err(error)?;
2904 }
2905 None => {
2906 let mut stdin = std::io::stdin().lock();
2907 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
2908 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
2909 }
2910 }
2911 let more = bytes.len() as u64 > max;
2912 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
2913 Ok((bytes, more))
2914}
2915
2916fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
2918 let mut bytes = Vec::new();
2919 match limit {
2920 None => {
2921 let mut reader = reader;
2922 reader.read_to_end(&mut bytes)?;
2923 }
2924 Some(limit) => {
2925 reader
2926 .take(limit.saturating_add(1))
2927 .read_to_end(&mut bytes)?;
2928 if bytes.len() as u64 > limit {
2929 return Err(std::io::Error::other(format!(
2930 "larger than {}, the most this command reads",
2931 controls::size(limit)
2932 )));
2933 }
2934 }
2935 }
2936 Ok(bytes)
2937}
2938
2939fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
2952 read_resource_limited(resource, encoding, None)
2953}
2954
2955fn read_resource_limited(
2959 resource: Option<&str>,
2960 encoding: Option<Encoding>,
2961 limit: Option<u64>,
2962) -> std::io::Result<String> {
2963 let content = match resource {
2964 Some(path) if path != "-" => {
2965 let file = &fs_path(path);
2966 if file.is_dir() {
2969 return Err(std::io::Error::new(
2970 std::io::ErrorKind::InvalidInput,
2971 "is a directory, not a file",
2972 ));
2973 }
2974 let bytes = match limit {
2975 None => std::fs::read(file)?,
2976 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
2977 };
2978 if let Some(encoding) = encoding {
2979 return encoding.decode(&bytes).map(normalize_newlines);
2980 }
2981 if has_utf16_bom(&bytes) {
2982 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
2983 }
2984 match String::from_utf8(bytes) {
2985 Ok(text) => text,
2986 Err(_) if looks_like_image(path) => {
2991 return Err(std::io::Error::new(
2992 std::io::ErrorKind::InvalidData,
2993 "looks like an image; use `rich --image <path>` to view it, or \
2994 `rich --diff <before> <after>` to compare two images",
2995 ))
2996 }
2997 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
2998 }
2999 }
3000 _ => {
3001 if std::io::stdin().is_terminal() {
3002 let eof = if cfg!(windows) {
3003 "Ctrl-Z then Enter"
3004 } else {
3005 "Ctrl-D"
3006 };
3007 eprintln!("rich: reading stdin; finish input with {eof}");
3008 }
3009 let buffer = read_limited(std::io::stdin(), limit)?;
3010 decode_strict(buffer, encoding)?
3011 }
3012 };
3013 Ok(normalize_newlines(if encoding.is_some() {
3014 content
3015 } else {
3016 strip_bom(content)
3017 }))
3018}
3019
3020fn sanitize_json_value(value: &mut serde_json::Value) {
3021 match value {
3022 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3023 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3024 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3025 _ => {}
3026 }
3027}
3028
3029fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3030 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3031 sanitize_json_value(&mut value);
3032 serde_json::to_string(&value)
3033}
3034
3035fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3037 if let Some(encoding) = encoding {
3038 return encoding.decode(&bytes);
3039 }
3040 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3041 std::io::ErrorKind::InvalidData,
3042 if has_utf16_bom(err.as_bytes()) {
3043 "input has a UTF-16 BOM; use --encoding utf-16"
3044 } else {
3045 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3046 },
3047 ))
3048}
3049
3050fn normalize_newlines(text: String) -> String {
3059 const CR: char = '\r';
3060 const LF: char = '\n';
3061 if !text.contains(CR) {
3062 return text;
3063 }
3064 let mut out = String::with_capacity(text.len());
3065 let mut chars = text.chars().peekable();
3066 while let Some(c) = chars.next() {
3067 if c == CR {
3068 if chars.peek() == Some(&LF) {
3071 chars.next();
3072 }
3073 out.push(LF);
3074 } else {
3075 out.push(c);
3076 }
3077 }
3078 out
3079}
3080
3081fn looks_like_image(path: &str) -> bool {
3091 std::path::Path::new(path)
3092 .extension()
3093 .and_then(|e| e.to_str())
3094 .map(|e| e.to_ascii_lowercase())
3095 .is_some_and(|e| {
3096 matches!(
3097 e.as_str(),
3098 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3099 )
3100 })
3101}
3102
3103fn strip_bom(text: String) -> String {
3104 match text.strip_prefix('\u{feff}') {
3105 Some(rest) => rest.to_string(),
3106 None => text,
3107 }
3108}
3109
3110fn is_url(resource: &str) -> bool {
3113 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3114 head.starts_with("http://") || head.starts_with("https://")
3115}
3116
3117fn resource_ext(resource: &str) -> Option<String> {
3121 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3122 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3123 basename
3124 .rsplit_once('.')
3125 .map(|(_, ext)| ext.to_ascii_lowercase())
3126 .filter(|ext| !ext.is_empty())
3127}
3128
3129#[cfg(feature = "fetch")]
3132const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3133
3134#[cfg(feature = "fetch")]
3142fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3143 use std::time::Duration;
3144 let mut response = ureq::get(url)
3145 .config()
3146 .timeout_global(Some(Duration::from_secs(30)))
3147 .http_status_as_error(false)
3150 .build()
3151 .call()
3152 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3153 let content_type = response
3154 .headers()
3155 .get("content-type")
3156 .and_then(|value| value.to_str().ok())
3157 .map(str::to_string);
3158
3159 let mut buffer = Vec::new();
3165 response
3166 .body_mut()
3167 .with_config()
3168 .reader()
3169 .take(MAX_BODY_BYTES + 1)
3170 .read_to_end(&mut buffer)
3171 .map_err(|err| format!("cannot read {url}: {err}"))?;
3172 if buffer.len() as u64 > MAX_BODY_BYTES {
3173 return Err(format!(
3174 "{url} exceeds the {} MiB limit",
3175 MAX_BODY_BYTES / (1024 * 1024)
3176 ));
3177 }
3178 let body =
3179 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3180 Ok((body, content_type))
3181}
3182
3183#[cfg(not(feature = "fetch"))]
3185fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3186 Err(format!(
3187 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3188 ))
3189}
3190
3191fn content_type_mode(content_type: &str) -> Option<Mode> {
3195 let mime = mime_of(content_type);
3196 match mime.as_str() {
3197 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3198 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3199 "application/json" | "text/json" => Some(Mode::Json),
3200 "text/csv" => Some(Mode::Csv),
3201 _ => None,
3202 }
3203}
3204
3205fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3209 let mime = mime_of(content_type);
3210 Some(match mime.as_str() {
3211 "text/html" | "application/xhtml+xml" => "html",
3212 "text/css" => "css",
3213 "application/javascript" | "text/javascript" => "javascript",
3214 "application/xml" | "text/xml" => "xml",
3215 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3216 "text/x-rust" => "rust",
3217 "text/x-c" | "text/x-csrc" => "c",
3218 "application/x-sh" | "text/x-shellscript" => "bash",
3219 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3220 "application/toml" | "text/x-toml" => "toml",
3221 _ => return None,
3222 })
3223}
3224
3225fn is_uninformative_ext(ext: &str) -> bool {
3228 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3229}
3230
3231fn mime_of(content_type: &str) -> String {
3234 content_type
3235 .split(';')
3236 .next()
3237 .unwrap_or("")
3238 .trim()
3239 .to_ascii_lowercase()
3240}
3241
3242fn transforms_apply(mode: Mode) -> bool {
3246 matches!(
3247 mode,
3248 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3249 )
3250}
3251
3252fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3256 if let Some(flag) = cli
3257 .data
3258 .transform_option()
3259 .filter(|_| !transforms_apply(mode))
3260 {
3261 return Some(transforms_misplaced(flag, mode));
3262 }
3263 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3264 .then(|| cli.data.check_transforms(true).unwrap_err())
3265}
3266
3267fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3268 format!(
3269 "{flag} only has an effect on {}, not {}",
3270 transform_modes(flag),
3271 mode_name(mode)
3272 )
3273}
3274
3275fn transform_modes(flag: &str) -> &'static str {
3277 if flag == "--transform" {
3278 "text, --print or --syntax"
3279 } else {
3280 "text, --print, --syntax or --inspect"
3281 }
3282}
3283
3284fn detect_mode(resource: Option<&str>) -> Mode {
3285 match resource.and_then(resource_ext).as_deref() {
3286 Some("md") | Some("markdown") => Mode::Markdown,
3287 Some("json") => Mode::Json,
3288 Some("csv") | Some("tsv") => Mode::Csv,
3289 Some("ipynb") => Mode::Ipynb,
3290 Some("rst") => Mode::Rst,
3291 Some("gif") => Mode::Gif,
3292 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3293 Some(_) => Mode::Syntax,
3301 None => Mode::Auto,
3305 }
3306}
3307
3308struct ForceWidth {
3316 child: Box<dyn Renderable>,
3317 width: usize,
3318}
3319
3320impl Renderable for ForceWidth {
3321 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3322 self.child
3323 .rich_render(console, &options.update_width(self.width))
3324 }
3325
3326 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3327 Measurement::new(self.width, self.width)
3328 }
3329}
3330
3331struct Aligned {
3341 child: Box<dyn Renderable>,
3342 width: usize,
3348 justify: Justify,
3349}
3350
3351impl Renderable for Aligned {
3352 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3353 let width = self.width.clamp(1, options.max_width);
3354 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3364 let widths: Vec<usize> = lines
3365 .iter()
3366 .map(|line| line.iter().map(Segment::cell_length).sum())
3367 .collect();
3368 let shape = widths.iter().copied().max().unwrap_or(0);
3372
3373 let excess = options.max_width.saturating_sub(shape);
3374 let (left, right) = match self.justify {
3375 Justify::Right => (excess, 0),
3376 Justify::Center => (excess / 2, excess - excess / 2),
3377 _ => (0, excess),
3378 };
3379 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3380
3381 let mut segments = Vec::new();
3382 let last = lines.len().saturating_sub(1);
3383 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3384 if left > 0 {
3385 segments.push(blank(left));
3386 }
3387 segments.extend(line);
3388 let pad = shape - line_width + right;
3389 if pad > 0 {
3390 segments.push(blank(pad));
3391 }
3392 if index != last {
3393 segments.push(Segment::line());
3394 }
3395 }
3396 segments
3397 }
3398}
3399
3400fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3412 console
3413 .render_lines(renderable, &console.options(), false)
3414 .iter()
3415 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3416 .max()
3417 .unwrap_or(0)
3418}
3419
3420fn validate_labels(cli: &Cli) -> Result<(), String> {
3423 for label in cli.title.iter().chain(cli.caption.iter()) {
3424 Text::from_markup(label).map_err(|err| err.to_string())?;
3425 }
3426 Ok(())
3427}
3428
3429fn decorate_and_emit(
3435 cli: &Cli,
3436 console: &Console,
3437 export: &Export,
3438 renderable: Box<dyn Renderable>,
3439 fit: Option<usize>,
3440) -> ExitCode {
3441 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3442}
3443
3444fn decorate_and_emit_with(
3447 cli: &Cli,
3448 console: &Console,
3449 export: &Export,
3450 renderable: Box<dyn Renderable>,
3451 fit: Option<usize>,
3452 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3453) -> ExitCode {
3454 if cli.panel.is_some() {
3455 if let Err(err) = validate_labels(cli) {
3456 return fail(cli, ExitClass::Data, err);
3457 }
3458 }
3459 let max_width = console.width();
3460 let expand = cli.expand || cli.width.is_some();
3462 let mut renderable = renderable;
3463 let mut fit = fit;
3464
3465 if let Some((top, right, bottom, left)) = cli.padding {
3471 let padded: Box<dyn Renderable> =
3472 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3473 fit = fit.map(|width| (width + left + right).min(max_width));
3476 renderable = if expand {
3477 padded
3478 } else {
3479 Box::new(Constrain::new(padded, fit))
3480 };
3481 }
3482
3483 if let Some(box_set) = cli.panel {
3484 let mut panel = Panel::new(renderable).box_set(box_set);
3485 if let Some(title) = cli.title.clone() {
3486 panel = panel.title(title);
3487 }
3488 if let Some(caption) = cli.caption.clone() {
3489 panel = panel.subtitle(caption);
3490 }
3491 if let Some(style) = cli.panel_style.clone() {
3492 panel = panel.border_style(style);
3493 }
3494 let inner = max_width.saturating_sub(4);
3503 let child = fit.unwrap_or(inner).min(inner);
3504 let title_width = cli
3505 .title
3506 .as_deref()
3507 .map(|title| {
3508 let title = rich::emoji::replace(title);
3511 let mut title = Text::from_markup(&title.replace('\n', " "))
3512 .expect("title markup validated before rendering");
3513 title.expand_tabs(8);
3514 cell_len(title.plain()) + 2
3515 })
3516 .unwrap_or(0);
3517 fit = Some(child.max(title_width) + 4);
3518 let panel: Box<dyn Renderable> = Box::new(panel);
3519 renderable = if expand {
3520 panel
3521 } else {
3522 Box::new(Constrain::new(panel, fit))
3523 };
3524 }
3525
3526 if let Some(style) = cli.style.clone() {
3529 renderable = Box::new(Styled::new(renderable, style));
3530 }
3531
3532 if let Some(width) = cli.width.filter(|_| !cli.pager) {
3534 renderable = Box::new(ForceWidth {
3535 child: renderable,
3536 width,
3537 });
3538 fit = Some(width);
3539 }
3540
3541 if let Some(justify) = cli.justify {
3542 renderable = Box::new(Aligned {
3543 child: renderable,
3544 width: fit.unwrap_or(max_width),
3545 justify,
3546 });
3547 }
3548
3549 let mut options = console.options();
3552 if let Some(max_width) = cli.max_width {
3553 options.max_width = max_width.min(options.max_width);
3554 }
3555 let shown = if cli.soft {
3556 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
3557 if !segments.is_empty() {
3558 segments.push(Segment::line());
3559 }
3560 emit_segments(console, export, segments)
3561 } else {
3562 emit(console, export, |c| {
3563 c.print_with(renderable.as_ref(), &options)
3564 })
3565 };
3566 finish(cli, shown)
3567}
3568
3569fn soft_wrap_segments(
3575 console: &Console,
3576 renderable: &dyn Renderable,
3577 options: &ConsoleOptions,
3578) -> Vec<Segment> {
3579 let mut options = options.clone();
3580 options.no_wrap.get_or_insert(true);
3581 options
3582 .overflow
3583 .get_or_insert(rich::console::Overflow::Ignore);
3584 let joined = renderable.printed_text();
3585 let renderable: &dyn Renderable = match &joined {
3586 Some(text) => text,
3587 None => renderable,
3588 };
3589 if renderable.fit_to_measurement() {
3590 let measurement = renderable.measure(console, &options);
3591 options.max_width = measurement.maximum.min(options.max_width).max(1);
3592 }
3593 console.render(renderable, Some(&options))
3594}
3595
3596fn run_cli(cli: Cli) -> ExitCode {
3597 if cli.watch {
3598 return run_watch(cli);
3599 }
3600 run_once(cli)
3601}
3602
3603fn run_watch(mut cli: Cli) -> ExitCode {
3604 let Some(resource) = cli.resource.clone() else {
3605 return fail(
3606 &cli,
3607 ExitClass::Usage,
3608 "--watch requires a file path or URL",
3609 );
3610 };
3611 let several = cli.resources.len() > 1;
3612 for resource in &cli.resources {
3613 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
3614 return fail(
3615 &cli,
3616 ExitClass::Usage,
3617 "--watch requires a local file or URL, not stdin or literal markup",
3618 );
3619 }
3620 if several && is_url(resource) {
3621 return fail(
3622 &cli,
3623 ExitClass::Usage,
3624 "--watch with several resources requires local files; watch a URL on its own",
3625 );
3626 }
3627 let effective_mode = if cli.mode == Mode::Auto {
3628 detect_mode(Some(resource))
3629 } else {
3630 cli.mode
3631 };
3632 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
3633 return fail(
3634 &cli,
3635 ExitClass::Usage,
3636 "--watch requires a resource-backed render mode",
3637 );
3638 }
3639 }
3640 if several && (cli.export_html.is_some() || cli.export_svg.is_some()) {
3641 return fail(
3642 &cli,
3643 ExitClass::Usage,
3644 "--export-html/--export-svg cannot be combined with watching several files",
3645 );
3646 }
3647 if !std::io::stdout().is_terminal() {
3651 cli.watch = false;
3652 if !several {
3653 return run_once(cli);
3654 }
3655 let mut first_failure = None;
3656 for resource in cli.resources.clone() {
3657 let mut single = cli.clone();
3658 single.resource = Some(resource.clone());
3659 single.resources = vec![resource];
3660 let status = run_once(single);
3661 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
3662 first_failure = Some(status);
3663 }
3664 }
3665 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
3666 }
3667 let resource = resource.as_str();
3668 if !is_url(resource) {
3669 return watch::watch_files(cli);
3670 }
3671 if is_url(resource) {
3673 #[cfg(not(feature = "fetch"))]
3674 {
3675 cli.watch = false;
3676 return run_once(cli);
3677 }
3678 }
3679
3680 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
3681 let mut previous = None;
3682 loop {
3683 #[cfg(feature = "fetch")]
3684 let cached = if is_url(resource) && cli.watch_cache {
3685 Some(fetch_url(resource, cli.extensions.encoding))
3686 } else {
3687 None
3688 };
3689 #[cfg(not(feature = "fetch"))]
3690 let cached: Option<Result<(String, Option<String>), String>> = None;
3691 let fingerprint = match cached.as_ref() {
3692 Some(Ok((body, content_type))) => {
3693 use std::hash::{Hash, Hasher};
3694 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3695 body.hash(&mut hasher);
3696 content_type.hash(&mut hasher);
3697 format!("url:{:x}", hasher.finish())
3698 }
3699 Some(Err(error)) => format!("url-error:{error}"),
3700 None => watch_fingerprint(resource, false, cli.extensions.encoding),
3701 };
3702 let changed = previous.as_ref() != Some(&fingerprint);
3703 if changed || (is_url(resource) && !cli.watch_cache) {
3704 previous = Some(fingerprint);
3705 let console = Console::new();
3708 console.clear();
3709 console.control(&Control::home());
3710 let _ = std::io::stdout().flush();
3711 let mut iteration = cli.clone();
3712 iteration.watch = false;
3713 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
3714 if status != ExitClass::Success.exit_code() {
3715 eprintln!("rich: watch will retry after the next change");
3718 }
3719 }
3720 std::thread::sleep(interval);
3721 }
3722}
3723
3724fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
3725 if is_url(resource) {
3726 #[cfg(feature = "fetch")]
3727 if cache_url {
3728 return match fetch_url(resource, encoding) {
3729 Ok((body, content_type)) => {
3730 use std::hash::{Hash, Hasher};
3731 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3732 body.hash(&mut hasher);
3733 content_type.hash(&mut hasher);
3734 format!("url:{:x}", hasher.finish())
3735 }
3736 Err(error) => format!("url-error:{error}"),
3737 };
3738 }
3739 #[cfg(not(feature = "fetch"))]
3740 let _ = (cache_url, encoding);
3741 return "url".to_string();
3742 }
3743 match std::fs::metadata(fs_path(resource)) {
3744 Ok(metadata) if metadata.is_file() => {
3745 use std::hash::Hasher;
3746 let mut file = match std::fs::File::open(fs_path(resource)) {
3750 Ok(file) => file,
3751 Err(error) => return format!("file-error:{error}"),
3752 };
3753 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3754 let mut buffer = [0u8; 64 * 1024];
3755 loop {
3756 match file.read(&mut buffer) {
3757 Ok(0) => break,
3758 Ok(count) => hasher.write(&buffer[..count]),
3759 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3760 Err(error) => return format!("file-error:{error}"),
3761 }
3762 }
3763 format!("file:{:x}", hasher.finish())
3764 }
3765 Ok(_) => "not-file".to_string(),
3766 Err(_) => "missing".to_string(),
3767 }
3768}
3769
3770fn run_once(cli: Cli) -> ExitCode {
3771 run_once_with_fetch(cli, None)
3772}
3773
3774fn cli_theme(cli: &Cli) -> rich::Theme {
3777 let mut theme = rich::Theme::default_theme();
3778 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
3779 theme.insert(name.clone(), style.clone());
3780 }
3781 theme
3782}
3783
3784impl Cli {
3785 fn themed(&self) -> bool {
3787 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
3788 }
3789}
3790
3791fn read_theme_file(
3799 path: &str,
3800 label: &str,
3801) -> Result<std::collections::BTreeMap<String, Style>, String> {
3802 let limit = controls::THEME_FILE_LIMIT;
3803 let path = &fs_path(path);
3805 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
3807 if !metadata.is_file() {
3808 return Err(format!("{label}: not a regular file"));
3809 }
3810 let too_large = || {
3811 format!(
3812 "{label}: larger than {}, the most read from a theme file",
3813 controls::size(limit)
3814 )
3815 };
3816 if metadata.len() > limit {
3817 return Err(too_large());
3818 }
3819 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
3820 let mut bytes = Vec::new();
3821 file.take(limit + 1)
3822 .read_to_end(&mut bytes)
3823 .map_err(|err| format!("{label}: {err}"))?;
3824 if bytes.len() as u64 > limit {
3825 return Err(too_large());
3826 }
3827 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
3828 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
3829 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
3831 _ => match bad_style_line(&text) {
3832 Some(line) => format!("{label}: line {line}: {err}"),
3833 None => format!("{label}: {err}"),
3834 },
3835 })?;
3836 Ok(theme
3837 .names()
3838 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
3839 .collect())
3840}
3841
3842fn bad_style_line(text: &str) -> Option<usize> {
3845 let mut in_styles = false;
3846 for (index, line) in text.lines().enumerate() {
3847 let trimmed = line.trim();
3848 if trimmed.starts_with('[') {
3849 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
3850 continue;
3851 }
3852 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
3853 continue;
3854 }
3855 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
3856 continue;
3857 };
3858 if Style::parse(value.trim()).is_err() {
3859 return Some(index + 1);
3860 }
3861 }
3862 None
3863}
3864
3865fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
3866 if cli.sanitize {
3867 cli.title = cli
3868 .title
3869 .take()
3870 .map(|title| sanitize_terminal_controls(&title));
3871 cli.caption = cli
3872 .caption
3873 .take()
3874 .map(|caption| sanitize_terminal_controls(&caption));
3875 }
3876
3877 if cli.batch {
3878 return run_batch(&cli);
3879 }
3880
3881 if cli.mode == Mode::Auto && cli.resource.is_none() {
3883 run_demo(cli.no_color, std::time::Duration::ZERO);
3884 return success(&cli);
3885 }
3886
3887 let mut mode = match cli.mode {
3888 Mode::Auto => detect_mode(cli.resource.as_deref()),
3889 other => other,
3890 };
3891 if let Some(message) = transform_misplaced_in(&cli, mode) {
3892 return fail(&cli, ExitClass::Usage, message);
3893 }
3894
3895 let mut prefetched = prefetched;
3899 let mut view_as = None;
3900 let mut view_bytes = Vec::new();
3901 if mode == Mode::View {
3902 let ext = cli
3903 .resource
3904 .as_deref()
3905 .filter(|r| *r != "-")
3906 .and_then(resource_ext);
3907 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
3910 if !matches!(
3911 by_name,
3912 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
3913 ) {
3914 let limit = if by_name == viewers::ViewAs::Notebook {
3917 controls::INSPECT_LIMIT
3918 } else {
3919 controls::VIEW_LIMIT
3920 };
3921 let more;
3922 (view_bytes, more) = match read_window(&cli, 0, limit) {
3923 Ok(read) => read,
3924 Err(err) => return fail(&cli, ExitClass::Input, err),
3925 };
3926 let name = cli.resource.as_deref().unwrap_or("<stdin>");
3927 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
3928 if kind == viewers::ViewAs::Binary {
3929 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
3930 view_bytes.truncate(controls::BYTES_LIMIT as usize);
3931 viewers::notice(
3932 name,
3933 &controls::size(controls::BYTES_LIMIT),
3934 "use rich hex with --offset and --length for the rest",
3935 );
3936 }
3937 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
3938 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
3939 {
3940 view_bytes.truncate(end);
3942 viewers::notice(
3943 name,
3944 &format!("{} lines", controls::LINE_LIMIT),
3945 "use a pager or rich --syntax for the whole file",
3946 );
3947 } else if more {
3948 viewers::notice(
3949 name,
3950 &controls::size(limit),
3951 "use a pager or rich --syntax for the whole file",
3952 );
3953 }
3954 }
3955 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
3956 mode = match kind {
3957 viewers::ViewAs::Markdown => Mode::Markdown,
3958 viewers::ViewAs::Csv => Mode::Csv,
3959 viewers::ViewAs::Notebook => Mode::Ipynb,
3960 viewers::ViewAs::Image => Mode::Image,
3961 viewers::ViewAs::Gif => Mode::Gif,
3962 viewers::ViewAs::Log => Mode::Log,
3963 _ => Mode::View,
3964 };
3965 let text = String::from_utf8_lossy(&view_bytes).into_owned();
3966 prefetched = Some((text, None));
3967 view_as = Some(kind);
3968 }
3969
3970 let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
3975 if cli.themed() {
3976 builder = builder.theme(cli_theme(&cli));
3977 }
3978 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
3981 builder = builder.force_terminal(terminal == "1");
3982 }
3983 if cli.force_terminal {
3984 builder = builder.force_terminal(true);
3985 }
3986 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
3987 builder = builder.width(width);
3988 }
3989 if let Some(width) = watch::capture_width() {
3992 builder = builder.width(width);
3993 }
3994 let mut console = builder.build();
3995 render_target::attach(&mut console);
3996 console.install_extensions();
3997 plugins::install_highlighters(&mut console);
3998 apply_code_highlighting(&mut console, &cli);
3999
4000 if mode == Mode::Rule {
4001 if let Some(title) = &cli.resource {
4002 if let Err(err) = Text::from_markup(title) {
4003 return fail(&cli, ExitClass::Data, err.to_string());
4004 }
4005 }
4006 }
4007
4008 let literal = mode == Mode::Rule
4014 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4015 let mut svg_title = cli
4016 .resource
4017 .as_deref()
4018 .filter(|r| *r != "-" && !literal)
4019 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4020 .unwrap_or_else(|| "rich".to_string());
4021 if cli.sanitize {
4022 svg_title = sanitize_terminal_controls(&svg_title);
4023 }
4024 let export = Export {
4025 html_path: cli.export_html.as_deref(),
4026 svg_path: cli.export_svg.as_deref(),
4027 svg_title: &svg_title,
4028 pager: cli.pager,
4029 auto_pager: cli.auto_pager,
4030 };
4031
4032 if mode == Mode::Gif {
4035 return play_gifs(&cli, &console);
4036 }
4037
4038 if mode == Mode::Diff {
4041 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4042 if !images {
4043 return run_text_diff(&cli, &console, &export);
4044 }
4045 if let Some(flag) = cli.tools.diff_option() {
4046 return fail(
4047 &cli,
4048 ExitClass::Usage,
4049 format!("{flag} applies to text diffs, not images"),
4050 );
4051 }
4052 #[cfg(feature = "art")]
4053 return run_diff(&cli, &console, &export);
4054 #[cfg(not(feature = "art"))]
4055 return fail(
4056 &cli,
4057 ExitClass::Usage,
4058 "this build has no image support (rebuild with the `art` feature)",
4059 );
4060 }
4061
4062 #[cfg(feature = "art")]
4065 if mode == Mode::Image {
4066 return run_image(&cli, &console, &export);
4067 }
4068 #[cfg(not(feature = "art"))]
4069 if mode == Mode::Image {
4070 return fail(
4071 &cli,
4072 ExitClass::Usage,
4073 "this build has no image support (rebuild with the `art` feature)",
4074 );
4075 }
4076
4077 if mode.streams_records() {
4078 return run_json_lines(&cli, &console, mode == Mode::Log);
4079 }
4080
4081 if mode == Mode::Rule {
4085 let mut rule = match cli.resource.as_deref() {
4086 Some(title) if title != "-" && cli.sanitize => {
4087 Rule::new(sanitize_terminal_controls(title))
4088 }
4089 Some(title) if title != "-" => Rule::new(title),
4090 _ => Rule::line(),
4091 };
4092 let style = cli
4097 .rule_style
4098 .clone()
4099 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4100 rule = rule.style(style);
4101 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4102 if cli.sanitize {
4105 rule = rule.characters(sanitize_terminal_controls(characters));
4106 } else {
4107 rule = rule.characters(characters);
4108 }
4109 }
4110 match cli.text_justify {
4111 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4112 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4113 _ => {}
4114 }
4115 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4118 }
4119
4120 if mode == Mode::Capture {
4122 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4123 Ok(redactor) => redactor,
4124 Err(err) => return fail(&cli, ExitClass::Usage, err),
4125 };
4126 let width = cli
4129 .width
4130 .unwrap_or_else(|| console.width())
4131 .saturating_sub(4)
4132 .max(1);
4133 let cast = match viewers::cast_path(&cli.viewers) {
4136 Some(path) => match std::fs::File::create(path) {
4137 Ok(file) => Some((path, file)),
4138 Err(err) => {
4139 return fail(
4140 &cli,
4141 ExitClass::Input,
4142 format!("cannot write {path}: {err}"),
4143 )
4144 }
4145 },
4146 None => None,
4147 };
4148 let mut captured = match viewers::capture(&cli.resources, width) {
4149 Ok(captured) => captured,
4150 Err(err) => return fail(&cli, ExitClass::Input, err),
4151 };
4152 if let Some(notice) = &captured.notice {
4153 eprintln!("rich: capture: {notice}");
4154 }
4155 if let Some(redactor) = &redactor {
4157 captured.redact(redactor);
4158 }
4159 if cli.sanitize {
4160 captured.sanitize();
4161 }
4162 if let Some((path, mut file)) = cast {
4163 let cast = viewers::asciicast(&captured, width, console.height());
4164 if let Err(err) = file.write_all(cast.as_bytes()) {
4165 return fail(
4166 &cli,
4167 ExitClass::Input,
4168 format!("cannot write {path}: {err}"),
4169 );
4170 }
4171 }
4172 let view = viewers::capture_view(&captured);
4173 let fit = view.measure(&console, &console.options()).maximum;
4174 let code = captured.exit_code();
4177 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4178 if code == 0 || shown.is_err() {
4179 return emit_exit_code(cli, shown);
4180 }
4181 if cli.report_format == ReportFormat::Json {
4183 eprintln!("{}", viewers::capture_report(&captured));
4184 }
4185 ExitCode::from(code)
4186 });
4187 }
4188 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4189 return fail(
4190 &cli,
4191 ExitClass::Usage,
4192 "this build has no Mermaid support (the mermaid feature)",
4193 );
4194 }
4195 if mode == Mode::Env {
4196 let view = viewers::env(&cli.viewers, &cli.resources);
4197 let fit = view.measure(&console, &console.options()).maximum;
4198 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4199 }
4200 if matches!(mode, Mode::Hex | Mode::Unicode) {
4201 let hex = mode == Mode::Hex;
4204 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4205 let length = cli.viewers.length().filter(|_| hex);
4206 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4207 let (bytes, more) = match read_window(&cli, offset, max) {
4208 Ok(read) => read,
4209 Err(err) => return fail(&cli, ExitClass::Input, err),
4210 };
4211 if more && length.is_none() {
4212 let hint = if hex {
4213 "use --offset and --length for the rest"
4214 } else {
4215 "pass a shorter input"
4216 };
4217 viewers::notice(
4218 cli.resource.as_deref().unwrap_or("<stdin>"),
4219 &controls::size(controls::BYTES_LIMIT),
4220 hint,
4221 );
4222 }
4223 let view = if hex {
4224 viewers::hex(&cli.viewers, bytes, offset)
4225 } else {
4226 viewers::unicode(&cli.viewers, &bytes)
4227 };
4228 let view = match view {
4229 Ok(view) => view,
4230 Err(err) => return fail(&cli, ExitClass::Usage, err),
4231 };
4232 let fit = view.measure(&console, &console.options()).maximum;
4233 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4234 }
4235
4236 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4240 let (mut content, content_type) = if let Some(fetched) = prefetched {
4241 fetched
4242 } else if resource_is_url {
4243 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4244 Ok(fetched) => fetched,
4245 Err(err) => {
4246 return fail(&cli, ExitClass::Input, err);
4247 }
4248 }
4249 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4250 (cli.resource.clone().unwrap(), None)
4251 } else {
4252 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4254 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4255 Ok(content) => (content, None),
4256 Err(err) => {
4257 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4258 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4259 }
4260 }
4261 };
4262 if cli.sanitize && mode != Mode::AnsiExplain {
4265 content = sanitize_terminal_controls(&content);
4266 }
4267
4268 let mut routed_language = None;
4271 let mut routed = false;
4272 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4273 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4274 routed = true;
4275 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4276 inspect::Route::Json => Mode::Json,
4277 inspect::Route::Syntax(lexer) => {
4278 routed_language = Some(lexer);
4279 Mode::Syntax
4280 }
4281 inspect::Route::Text => Mode::Auto,
4282 };
4283 }
4284 }
4285
4286 if resource_is_url && mode == Mode::Auto && !routed {
4290 mode = content_type
4291 .as_deref()
4292 .and_then(content_type_mode)
4293 .unwrap_or(Mode::Syntax);
4294 }
4295
4296 if mode == Mode::Auto && cli.source.wants_syntax() {
4299 mode = Mode::Syntax;
4300 }
4301
4302 let json_content = if mode == Mode::Json && cli.sanitize {
4304 match sanitize_json_source(content.trim()) {
4305 Ok(content) => Some(content),
4306 Err(err) => {
4307 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4308 }
4309 }
4310 } else {
4311 None
4312 };
4313
4314 let json = if mode == Mode::Json {
4315 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4316 match Json::new(source) {
4317 Ok(json) => Some(json),
4318 Err(err) => {
4319 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4320 }
4321 }
4322 } else {
4323 None
4324 };
4325
4326 if mode == Mode::Ipynb {
4331 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4332 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4333 if cli.sanitize {
4334 sanitize_json_value(&mut value);
4335 }
4336 value
4337 }
4338 Ok(_) => {
4339 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4340 }
4341 Err(err) => {
4342 return fail(
4343 &cli,
4344 ExitClass::Data,
4345 format!("invalid notebook JSON: {err}"),
4346 );
4347 }
4348 };
4349 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4350 Ok(renderable) => renderable,
4351 Err(err) => return fail(&cli, ExitClass::Usage, err),
4352 };
4353 let fit = renderable.measure(&console, &console.options()).maximum;
4354 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4355 }
4356
4357 if mode == Mode::Print {
4361 if let Err(err) = console.try_build_text(&content) {
4362 return fail(&cli, ExitClass::Data, err.to_string());
4363 }
4364 }
4365
4366 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4371 .filter(|ext| !is_uninformative_ext(ext));
4372 let language = routed_language
4373 .map(str::to_string)
4374 .or(extension)
4375 .or_else(|| {
4376 content_type
4377 .as_deref()
4378 .and_then(content_type_lexer)
4379 .map(str::to_string)
4380 })
4381 .unwrap_or_default();
4382
4383 if let Some(message) = transform_misplaced_in(&cli, mode) {
4385 return fail(&cli, ExitClass::Usage, message);
4386 }
4387
4388 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4391 Mode::Markdown => {
4394 let mut markdown = build_markdown(&content, cli.hyperlinks);
4395 if let Some(fences) =
4397 plugin_registry(cli.mermaid_backend.unwrap_or(MermaidBackend::Text)).fences()
4398 {
4399 markdown = markdown.fence_renderer(fences);
4400 }
4401 (Box::new(markdown), None)
4402 }
4403 Mode::Rst => {
4407 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4408 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4409 (Box::new(document), None)
4410 }
4411 #[cfg(feature = "mermaid")]
4412 Mode::Mermaid => {
4413 let default = if cfg!(feature = "mmdc") {
4415 MermaidBackend::Mmdc
4416 } else {
4417 MermaidBackend::Text
4418 };
4419 let backend = match cli.mermaid_backend.unwrap_or(default) {
4420 MermaidBackend::Off => MermaidBackend::Text,
4421 chosen => chosen,
4422 };
4423 let diagram =
4424 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4425 (Box::new(diagram), None)
4426 }
4427 Mode::Json => {
4428 let nested = cli.padding.is_some()
4445 || cli.panel.is_some()
4446 || cli.style.is_some()
4447 || cli.width.is_some()
4448 || cli.pager
4449 || cli.auto_pager;
4450 let json = json.expect("json parsed above").no_wrap(nested);
4451 #[cfg(feature = "json-escape-safe")]
4452 let json = json.escape_safe(true);
4453 let fit = measure_rendered(&console, &json);
4454 (Box::new(json), Some(fit))
4455 }
4456 Mode::Csv => {
4457 if let Err(err) = validate_labels(&cli) {
4458 return fail(&cli, ExitClass::Data, err);
4459 }
4460 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
4463 let table = match build_csv_table(
4464 &content,
4465 fallback,
4466 cli.title.as_deref(),
4467 cli.caption.as_deref(),
4468 (cli.source.head, cli.source.tail),
4469 ) {
4470 Some(table) => table,
4471 None => {
4474 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
4475 }
4476 };
4477 if cli.panel.is_none()
4481 && cli.padding.is_none()
4482 && cli.style.is_none()
4483 && cli.justify.is_none()
4484 && cli.max_width.is_none()
4485 && !cli.soft
4486 && !cli.pager
4487 && !cli.auto_pager
4488 && cli.export_html.is_none()
4489 && cli.export_svg.is_none()
4490 && !watch::capturing()
4491 {
4492 let mut options = console.options();
4493 options.max_width = cli.width.unwrap_or_else(|| {
4494 table
4495 .measure(&console, &options)
4496 .maximum
4497 .min(options.max_width)
4498 .max(1)
4499 });
4500 let stdout = std::io::stdout();
4501 let mut output = std::io::BufWriter::new(stdout.lock());
4502 let result = table
4503 .try_for_each_line(&console, &options, |line| {
4504 let line = Segment::crop_lines(&line, console.width());
4505 writeln!(output, "{}", console.segments_to_string(&line))
4506 })
4507 .and_then(|()| output.flush());
4508 if let Err(error) = result {
4509 if error.kind() == std::io::ErrorKind::BrokenPipe {
4512 return success(&cli);
4513 }
4514 return fail(
4515 &cli,
4516 ExitClass::Input,
4517 format!("could not write CSV output: {error}"),
4518 );
4519 }
4520 return success(&cli);
4521 }
4522 let fit = measure_rendered(&console, &table);
4523 (Box::new(table), Some(fit))
4524 }
4525 Mode::Syntax if cli.data.transform_option().is_some() => {
4528 let language = cli.source.lexer.as_deref().unwrap_or(&language);
4529 let code = match cli.source.line_range(&content) {
4532 Ok(Some((start, end))) => {
4533 let start = start.max(1) as usize - 1;
4534 let end = end.max(0) as usize;
4535 let lines: Vec<&str> = content.lines().collect();
4536 lines
4537 .get(start.min(lines.len())..end.min(lines.len()))
4538 .unwrap_or_default()
4539 .join("\n")
4540 }
4541 Ok(None) => content.clone(),
4542 Err(err) => return fail(&cli, ExitClass::Usage, err),
4543 };
4544 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
4545 let text = match cli
4546 .data
4547 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4548 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4549 {
4550 Ok(text) => text,
4551 Err(err) => return fail(&cli, ExitClass::Data, err),
4552 };
4553 let fit = text.measurement().1;
4554 let background = match text.base_style() {
4557 rich::style::StyleType::Style(style) => style.clone(),
4558 rich::style::StyleType::Name(_) => rich::Style::new(),
4559 };
4560 let padded =
4563 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
4564 (Box::new(padded), Some(fit))
4565 }
4566 Mode::Syntax => {
4567 let syntax = match cli.source.syntax(&content, &language) {
4568 Ok(syntax) => syntax,
4569 Err(err) => return fail(&cli, ExitClass::Usage, err),
4570 };
4571 let fit = syntax.measure(&console, &console.options()).maximum;
4574 (Box::new(syntax), Some(fit))
4575 }
4576 Mode::View => {
4577 let view: Box<dyn Renderable> = match view_as {
4578 Some(viewers::ViewAs::Patch) => {
4579 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
4580 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
4581 Ok(outcome) => outcome.renderable,
4582 Err(err) => return fail(&cli, ExitClass::Data, err),
4583 }
4584 }
4585 Some(viewers::ViewAs::Binary) => {
4586 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
4587 Ok(view) => view,
4588 Err(err) => return fail(&cli, ExitClass::Usage, err),
4589 }
4590 }
4591 Some(viewers::ViewAs::Source(ref lexer)) => {
4592 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
4593 eprintln!("{summary}");
4594 }
4595 viewers::source(&cli.viewers, &content, lexer)
4596 }
4597 _ => viewers::source(&cli.viewers, &content, "txt"),
4598 };
4599 let fit = view.measure(&console, &console.options()).maximum;
4600 (view, Some(fit))
4601 }
4602 Mode::AnsiExplain => {
4603 let view = tools::ansi_explain(&cli.tools, &content);
4604 let fit = view.measure(&console, &console.options()).maximum;
4605 (view, Some(fit))
4606 }
4607 Mode::Inspect => {
4608 let encoding = cli.extensions.encoding;
4609 let read = |path: &str| {
4610 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
4611 .map_err(|err| format!("cannot read {path}: {err}"))
4612 };
4613 let view = match inspect::build(&cli.data, &content, cli.resource.as_deref(), read) {
4614 Ok(view) => view,
4615 Err(err) => return fail(&cli, ExitClass::Data, err),
4616 };
4617 let fit = view.measure(&console, &console.options()).maximum;
4618 (view, Some(fit))
4619 }
4620 _ => {
4623 #[cfg(feature = "art")]
4626 let micro = (mode == Mode::Print && cli.emoji)
4627 .then(|| micro::print_markup(&console, &content, cli.micro_project))
4628 .flatten();
4629 #[cfg(feature = "art")]
4630 let (mut text, micro) = match micro {
4631 Some((text, registry)) => (text, Some(registry)),
4632 None if mode == Mode::Print => (console.build_text(&content), None),
4633 None => (Text::new(content.as_str()), None),
4634 };
4635 #[cfg(not(feature = "art"))]
4636 let mut text = if mode == Mode::Print {
4637 console.build_text(&content)
4638 } else {
4639 Text::new(content.as_str())
4640 };
4641 if let Some(justify) = cli.text_justify {
4645 text.set_justify(justify);
4646 }
4647 if cli.source.no_wrap {
4648 text.set_no_wrap(Some(true));
4649 }
4650 let text = match cli
4651 .data
4652 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4653 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4654 {
4655 Ok(text) => text,
4656 Err(err) => return fail(&cli, ExitClass::Data, err),
4657 };
4658 let fit = text.measurement().1;
4659 #[cfg(feature = "art")]
4664 if let Some(registry) = micro.filter(|_| export.terminal_only()) {
4665 return decorate_and_emit(
4666 &cli,
4667 &console,
4668 &export,
4669 micro::drawn(&console, registry, text),
4670 Some(fit),
4671 );
4672 }
4673 (Box::new(text), Some(fit))
4674 }
4675 };
4676 decorate_and_emit(&cli, &console, &export, renderable, fit)
4677}
4678
4679#[allow(dead_code)]
4682fn exit_code(ok: bool) -> ExitCode {
4683 if ok {
4684 ExitCode::SUCCESS
4685 } else {
4686 ExitCode::FAILURE
4687 }
4688}
4689
4690fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
4691 match result {
4692 Ok(()) => success(cli),
4693 Err(message) => fail(cli, ExitClass::Input, message),
4694 }
4695}
4696
4697fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
4698 let output = console.render_export(renderable);
4699 let stdout = std::io::stdout();
4700 let mut lock = stdout.lock();
4701 lock.write_all(output.as_bytes())
4702}
4703
4704fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
4705 if cli.extensions.encoding.is_some() {
4706 return fail(
4707 cli,
4708 ExitClass::Usage,
4709 "--encoding is not supported with streaming JSONL/log mode",
4710 );
4711 }
4712 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
4713 return fail(
4714 cli,
4715 ExitClass::Usage,
4716 "streaming JSONL/log mode reads files or stdin, not URLs",
4717 );
4718 }
4719
4720 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
4721 Some(path) if path != "-" => {
4722 let file = &fs_path(path);
4723 if file.is_dir() {
4724 return fail(
4725 cli,
4726 ExitClass::Input,
4727 format!("cannot read {path}: is a directory, not a file"),
4728 );
4729 }
4730 match std::fs::File::open(file) {
4731 Ok(file) => Box::new(std::io::BufReader::new(file)),
4732 Err(err) => {
4733 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
4734 }
4735 }
4736 }
4737 _ => {
4738 if std::io::stdin().is_terminal() {
4739 let eof = if cfg!(windows) {
4740 "Ctrl-Z then Enter"
4741 } else {
4742 "Ctrl-D"
4743 };
4744 eprintln!("rich: reading stdin; finish input with {eof}");
4745 }
4746 Box::new(std::io::BufReader::new(std::io::stdin()))
4747 }
4748 };
4749
4750 let mut line = String::new();
4751 let mut line_number = 0usize;
4752 loop {
4753 line.clear();
4754 let read = match input.read_line(&mut line) {
4755 Ok(read) => read,
4756 Err(err) => {
4757 return fail(
4758 cli,
4759 ExitClass::Input,
4760 format!("cannot read JSONL stream: {err}"),
4761 );
4762 }
4763 };
4764 if read == 0 {
4765 break;
4766 }
4767 line_number += 1;
4768 let record = line.trim_end_matches(['\r', '\n']);
4769 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
4770 Ok(value) => value,
4771 Err(err) => {
4772 return fail(
4773 cli,
4774 ExitClass::Data,
4775 format!("invalid JSONL at line {line_number}: {err}"),
4776 );
4777 }
4778 };
4779 if cli.sanitize {
4780 sanitize_json_value(&mut value);
4781 }
4782 let wrote = if log_mode {
4783 if cli.log_presentation == "rich" {
4784 write_rendered_stdout(console, &structured_log::event(&value))
4785 } else {
4786 let text = Text::new(format_log_record(&value));
4787 write_rendered_stdout(console, &text)
4788 }
4789 } else {
4790 let source = serde_json::to_string(&value).expect("JSON value serializes");
4791 let json = Json::new(&source).expect("serialized JSON parses");
4792 write_rendered_stdout(console, &json)
4793 };
4794 if let Err(error) = wrote {
4795 if error.kind() == std::io::ErrorKind::BrokenPipe {
4796 return success(cli);
4797 }
4798 return fail(
4799 cli,
4800 ExitClass::Input,
4801 format!("could not write JSONL output: {error}"),
4802 );
4803 }
4804 }
4805 success(cli)
4806}
4807
4808fn format_log_record(value: &serde_json::Value) -> String {
4809 let Some(object) = value.as_object() else {
4810 return serde_json::to_string(value).expect("JSON value serializes");
4811 };
4812 let take_value = |keys: &[&'static str]| {
4813 keys.iter().find_map(|key| {
4814 object.get(*key).map(|value| {
4815 let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
4816 serde_json::to_string(value).expect("JSON value serializes")
4817 });
4818 (*key, display, value.is_string())
4819 })
4820 })
4821 };
4822 let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
4823 let level = take_value(&["level", "severity"]);
4824 let message = take_value(&["message", "msg"]);
4825 let mut rest = serde_json::Map::new();
4826 for (key, value) in object {
4827 let consumed = timestamp
4828 .as_ref()
4829 .is_some_and(|(consumed, _, _)| consumed == key)
4830 || level
4831 .as_ref()
4832 .is_some_and(|(consumed, _, _)| consumed == key)
4833 || message
4834 .as_ref()
4835 .is_some_and(|(consumed, _, _)| consumed == key);
4836 if !consumed {
4837 rest.insert(key.clone(), value.clone());
4838 }
4839 }
4840 let mut parts = Vec::new();
4841 if let Some((_, timestamp, _)) = timestamp {
4842 parts.push(timestamp);
4843 }
4844 if let Some((_, level, is_string)) = level {
4845 parts.push(if is_string {
4846 level.to_ascii_uppercase()
4847 } else {
4848 level
4849 });
4850 }
4851 if let Some((_, message, _)) = message {
4852 parts.push(message);
4853 }
4854 if !rest.is_empty() {
4855 parts.push(
4856 serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
4857 );
4858 }
4859 if parts.is_empty() {
4860 serde_json::to_string(value).expect("JSON value serializes")
4861 } else {
4862 parts.join(" ")
4863 }
4864}
4865
4866#[derive(Debug, Clone, Copy)]
4868struct Dialect {
4869 delimiter: char,
4870 quotechar: char,
4871 doublequote: bool,
4872 skipinitialspace: bool,
4873}
4874
4875impl Dialect {
4876 fn excel(delimiter: char) -> Self {
4879 Dialect {
4880 delimiter,
4881 quotechar: '"',
4882 doublequote: true,
4883 skipinitialspace: false,
4884 }
4885 }
4886}
4887
4888fn is_word_char(c: char) -> bool {
4890 c.is_alphanumeric() || c == '_'
4891}
4892
4893fn is_delimiter_char(c: char) -> bool {
4895 !is_word_char(c) && c != '\n' && c != '"' && c != '\''
4896}
4897
4898fn is_quote_char(c: char) -> bool {
4900 c == '"' || c == '\''
4901}
4902
4903struct QuoteHit {
4907 quote: char,
4908 delim: Option<char>,
4909 space: bool,
4910}
4911
4912fn sniff(sample: &str, delimiters: Option<&[char]>) -> Option<Dialect> {
4925 let data: Vec<char> = sample.chars().collect();
4926 let (quote, mut delimiter, mut skipinitialspace) = guess_quote_and_delimiter(&data, delimiters);
4927 if delimiter.is_none() {
4928 let (guessed, spaced) = guess_delimiter(sample, delimiters);
4929 delimiter = guessed;
4930 skipinitialspace = spaced;
4931 }
4932 Some(Dialect {
4933 delimiter: delimiter?,
4934 quotechar: quote.unwrap_or('"'),
4937 doublequote: true,
4938 skipinitialspace,
4939 })
4940}
4941
4942fn guess_quote_and_delimiter(
4950 data: &[char],
4951 delimiters: Option<&[char]>,
4952) -> (Option<char>, Option<char>, bool) {
4953 let hits = quote_hits(data);
4954 if hits.is_empty() {
4955 return (None, None, false);
4956 }
4957
4958 let mut quotes: Vec<(char, usize)> = Vec::new();
4962 let mut delims: Vec<(char, usize)> = Vec::new();
4963 let mut spaces = 0usize;
4964 fn bump(table: &mut Vec<(char, usize)>, key: char) {
4965 match table.iter_mut().find(|(existing, _)| *existing == key) {
4966 Some(entry) => entry.1 += 1,
4967 None => table.push((key, 1)),
4968 }
4969 }
4970 for hit in &hits {
4971 bump(&mut quotes, hit.quote);
4972 let Some(delim) = hit.delim else { continue };
4975 if delimiters.is_none_or(|allowed| allowed.contains(&delim)) {
4976 bump(&mut delims, delim);
4977 }
4978 if hit.space {
4979 spaces += 1;
4980 }
4981 }
4982
4983 let first_max = |table: &[(char, usize)]| -> Option<(char, usize)> {
4984 let mut best: Option<(char, usize)> = None;
4985 for &(key, count) in table {
4986 if best.is_none_or(|(_, seen)| count > seen) {
4987 best = Some((key, count));
4988 }
4989 }
4990 best
4991 };
4992 let quotechar = first_max("es).map(|(key, _)| key);
4993 match first_max(&delims) {
4994 Some((delim, count)) => (quotechar, Some(delim), count == spaces),
4995 None => (quotechar, None, false),
4997 }
4998}
4999
5000type QuoteScan = fn(&[char]) -> Vec<QuoteHit>;
5002
5003fn quote_hits(data: &[char]) -> Vec<QuoteHit> {
5006 let scans: [QuoteScan; 4] = [
5007 scan_delim_quote_delim,
5008 scan_line_quote_delim,
5009 scan_delim_quote_line,
5010 scan_line_quote_line,
5011 ];
5012 for scan in scans {
5013 let hits = scan(data);
5014 if !hits.is_empty() {
5015 return hits;
5016 }
5017 }
5018 Vec::new()
5019}
5020
5021fn scan_delim_quote_delim(data: &[char]) -> Vec<QuoteHit> {
5026 scan_delim_quote(data, |data, quote, delim, from| {
5027 let mut k = from;
5028 while k + 1 < data.len() {
5029 if data[k] == quote && data[k + 1] == delim {
5030 return Some(k + 2);
5031 }
5032 k += 1;
5033 }
5034 None
5035 })
5036}
5037
5038fn scan_delim_quote_line(data: &[char]) -> Vec<QuoteHit> {
5041 scan_delim_quote(data, |data, quote, _delim, from| {
5042 let mut k = from;
5043 while k < data.len() {
5044 if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
5045 return Some(k + 1);
5048 }
5049 k += 1;
5050 }
5051 None
5052 })
5053}
5054
5055fn scan_delim_quote(
5058 data: &[char],
5059 close: fn(&[char], char, char, usize) -> Option<usize>,
5060) -> Vec<QuoteHit> {
5061 let mut hits = Vec::new();
5062 let mut i = 0;
5063 while i < data.len() {
5064 if is_delimiter_char(data[i]) {
5065 let delim = data[i];
5066 let mut j = i + 1;
5067 let mut space = false;
5068 if j < data.len() && data[j] == ' ' {
5069 space = true;
5070 j += 1;
5071 }
5072 if j < data.len() && is_quote_char(data[j]) {
5073 let quote = data[j];
5074 if let Some(end) = close(data, quote, delim, j + 1) {
5075 hits.push(QuoteHit {
5076 quote,
5077 delim: Some(delim),
5078 space,
5079 });
5080 i = end;
5081 continue;
5082 }
5083 }
5084 }
5085 i += 1;
5086 }
5087 hits
5088}
5089
5090fn scan_line_quote_delim(data: &[char]) -> Vec<QuoteHit> {
5093 scan_line_quote(data, |data, quote, from| {
5094 let mut k = from;
5095 while k + 1 < data.len() {
5096 if data[k] == quote && is_delimiter_char(data[k + 1]) {
5097 let space = k + 2 < data.len() && data[k + 2] == ' ';
5098 return Some((Some(data[k + 1]), space, if space { k + 3 } else { k + 2 }));
5099 }
5100 k += 1;
5101 }
5102 None
5103 })
5104}
5105
5106fn scan_line_quote_line(data: &[char]) -> Vec<QuoteHit> {
5109 scan_line_quote(data, |data, quote, from| {
5110 let mut k = from;
5111 while k < data.len() {
5112 if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
5113 return Some((None, false, k + 1));
5114 }
5115 k += 1;
5116 }
5117 None
5118 })
5119}
5120
5121#[allow(clippy::type_complexity)]
5124fn scan_line_quote(
5125 data: &[char],
5126 close: fn(&[char], char, usize) -> Option<(Option<char>, bool, usize)>,
5127) -> Vec<QuoteHit> {
5128 let mut hits = Vec::new();
5129 let mut i = 0;
5130 while i < data.len() {
5131 let mut starts: Vec<usize> = Vec::new();
5135 if i == 0 || data[i - 1] == '\n' {
5136 starts.push(i);
5137 }
5138 if data[i] == '\n' {
5139 starts.push(i + 1);
5140 }
5141 let mut advanced = false;
5142 for quote_at in starts {
5143 if quote_at >= data.len() || !is_quote_char(data[quote_at]) {
5144 continue;
5145 }
5146 let quote = data[quote_at];
5147 if let Some((delim, space, end)) = close(data, quote, quote_at + 1) {
5148 hits.push(QuoteHit {
5149 quote,
5150 delim,
5151 space,
5152 });
5153 i = end;
5154 advanced = true;
5155 break;
5156 }
5157 }
5158 if !advanced {
5159 i += 1;
5160 }
5161 }
5162 hits
5163}
5164
5165fn guess_delimiter(sample: &str, delimiters: Option<&[char]>) -> (Option<char>, bool) {
5168 let data: Vec<&str> = sample.split('\n').filter(|line| !line.is_empty()).collect();
5170 if data.is_empty() {
5171 return (None, false);
5172 }
5173 const ASCII: usize = 127;
5175
5176 let chunk_length = 10.min(data.len());
5177 let mut iteration = 0usize;
5178 let mut char_frequency: Vec<Vec<(usize, usize)>> = vec![Vec::new(); ASCII];
5181 let mut modes: Vec<Option<(usize, isize)>> = vec![None; ASCII];
5184 let mut delims: Vec<(char, (usize, isize))> = Vec::new();
5185
5186 let (mut start, mut end) = (0usize, chunk_length);
5187 while start < data.len() {
5188 iteration += 1;
5189 for line in &data[start..end.min(data.len())] {
5190 for (code, table) in char_frequency.iter_mut().enumerate() {
5191 let c = code as u8 as char;
5192 let freq = line.matches(c).count();
5195 match table.iter_mut().find(|(seen, _)| *seen == freq) {
5196 Some(entry) => entry.1 += 1,
5197 None => table.push((freq, 1)),
5198 }
5199 }
5200 }
5201
5202 for (code, items) in char_frequency.iter().enumerate() {
5203 if items.len() == 1 && items[0].0 == 0 {
5204 continue;
5205 }
5206 if items.len() > 1 {
5207 let mut best = 0usize;
5210 for (index, item) in items.iter().enumerate() {
5211 if item.1 > items[best].1 {
5212 best = index;
5213 }
5214 }
5215 let others: usize = items
5216 .iter()
5217 .enumerate()
5218 .filter(|(index, _)| *index != best)
5219 .map(|(_, item)| item.1)
5220 .sum();
5221 modes[code] = Some((items[best].0, items[best].1 as isize - others as isize));
5222 } else if let Some(&(freq, count)) = items.first() {
5223 modes[code] = Some((freq, count as isize));
5224 }
5225 }
5226
5227 let total = (chunk_length * iteration).min(data.len()) as f64;
5228 let mut consistency = 1.0f64;
5229 let threshold = 0.9f64;
5230 while delims.is_empty() && consistency >= threshold {
5231 for (code, mode) in modes.iter().enumerate() {
5232 let Some((freq, count)) = *mode else { continue };
5233 let c = code as u8 as char;
5234 if freq > 0
5235 && count > 0
5236 && (count as f64 / total) >= consistency
5237 && delimiters.is_none_or(|allowed| allowed.contains(&c))
5238 {
5239 delims.push((c, (freq, count)));
5240 }
5241 }
5242 consistency -= 0.01;
5243 }
5244
5245 if delims.len() == 1 {
5246 let delim = delims[0].0;
5247 return (Some(delim), saw_space_after(data[0], delim));
5248 }
5249
5250 start = end;
5251 end += chunk_length;
5252 }
5253
5254 if delims.is_empty() {
5255 return (None, false);
5256 }
5257 if delims.len() > 1 {
5258 for preferred in [',', '\t', ';', ' ', ':'] {
5259 if delims.iter().any(|(c, _)| *c == preferred) {
5260 return (Some(preferred), saw_space_after(data[0], preferred));
5261 }
5262 }
5263 }
5264 let best = delims
5266 .iter()
5267 .max_by_key(|(c, mode)| (*mode, *c))
5268 .expect("delims is non-empty");
5269 (Some(best.0), saw_space_after(data[0], best.0))
5270}
5271
5272fn saw_space_after(line: &str, delimiter: char) -> bool {
5275 line.matches(delimiter).count() == line.matches(&format!("{delimiter} ")).count()
5276}
5277
5278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5280enum ColumnType {
5281 Number,
5283 Length(usize),
5285}
5286
5287#[derive(Debug, Clone, Copy)]
5289enum ColumnVote {
5290 Untyped,
5292 Typed(ColumnType),
5293 Dropped,
5295}
5296
5297fn has_header(sample: &str) -> Option<bool> {
5309 let dialect = sniff(sample, None)?;
5312 let mut rows = read_csv_rows(sample, &dialect).into_iter();
5313 let header = rows.next()?;
5314 let columns = header.len();
5315 let mut votes = vec![ColumnVote::Untyped; columns];
5316
5317 for (checked, row) in rows.enumerate() {
5318 if checked > 20 {
5321 break;
5322 }
5323 if row.len() != columns {
5324 continue;
5325 }
5326 for (col, vote) in votes.iter_mut().enumerate() {
5327 if matches!(vote, ColumnVote::Dropped) {
5328 continue;
5329 }
5330 let this = if parses_as_complex(&row[col]) {
5331 ColumnType::Number
5332 } else {
5333 ColumnType::Length(row[col].chars().count())
5334 };
5335 match vote {
5336 ColumnVote::Untyped => *vote = ColumnVote::Typed(this),
5337 ColumnVote::Typed(known) if *known != this => *vote = ColumnVote::Dropped,
5338 _ => {}
5339 }
5340 }
5341 }
5342
5343 let mut tally = 0isize;
5344 for (col, vote) in votes.iter().enumerate() {
5345 match vote {
5346 ColumnVote::Dropped => {}
5347 ColumnVote::Untyped => tally += 1,
5350 ColumnVote::Typed(ColumnType::Length(len)) => {
5351 if header[col].chars().count() == *len {
5352 tally -= 1;
5353 } else {
5354 tally += 1;
5355 }
5356 }
5357 ColumnVote::Typed(ColumnType::Number) => {
5358 if parses_as_complex(&header[col]) {
5359 tally -= 1;
5360 } else {
5361 tally += 1;
5362 }
5363 }
5364 }
5365 }
5366 Some(tally > 0)
5367}
5368
5369fn parses_as_complex(value: &str) -> bool {
5379 let trimmed = value.trim();
5380 let body = match trimmed.strip_prefix('(') {
5381 Some(inner) => match inner.strip_suffix(')') {
5382 Some(inner) => inner.trim(),
5383 None => return false,
5384 },
5385 None if trimmed.ends_with(')') => return false,
5386 None => trimmed,
5387 };
5388 if body.is_empty() {
5389 return false;
5390 }
5391 let chars: Vec<char> = body.chars().collect();
5394 let split = (1..chars.len())
5395 .find(|&i| matches!(chars[i], '+' | '-') && !matches!(chars[i - 1], 'e' | 'E'));
5396 match split {
5397 Some(index) => {
5398 let at: usize = chars[..index].iter().map(|c| c.len_utf8()).sum();
5399 let (real, imaginary) = body.split_at(at);
5400 is_float_literal(real) && is_imaginary_literal(imaginary)
5401 }
5402 None => is_float_literal(body) || is_imaginary_literal(body),
5403 }
5404}
5405
5406fn is_float_literal(value: &str) -> bool {
5409 let body = value.strip_prefix(['+', '-']).unwrap_or(value);
5410 if matches!(
5411 body.to_ascii_lowercase().as_str(),
5412 "inf" | "infinity" | "nan"
5413 ) {
5414 return true;
5415 }
5416 let chars: Vec<char> = body.chars().collect();
5417 let mut index = 0usize;
5418 let mut digits = 0usize;
5419 let take_digits = |index: &mut usize, digits: &mut usize| {
5420 while *index < chars.len() && (chars[*index].is_ascii_digit() || chars[*index] == '_') {
5421 if chars[*index].is_ascii_digit() {
5422 *digits += 1;
5423 }
5424 *index += 1;
5425 }
5426 };
5427 take_digits(&mut index, &mut digits);
5428 if index < chars.len() && chars[index] == '.' {
5429 index += 1;
5430 take_digits(&mut index, &mut digits);
5431 }
5432 if digits == 0 {
5433 return false;
5434 }
5435 if index < chars.len() && (chars[index] == 'e' || chars[index] == 'E') {
5436 index += 1;
5437 if index < chars.len() && matches!(chars[index], '+' | '-') {
5438 index += 1;
5439 }
5440 let mut exponent = 0usize;
5441 take_digits(&mut index, &mut exponent);
5442 if exponent == 0 {
5443 return false;
5444 }
5445 }
5446 index == chars.len()
5447}
5448
5449fn is_imaginary_literal(value: &str) -> bool {
5451 let Some(body) = value.strip_suffix(['j', 'J']) else {
5452 return false;
5453 };
5454 matches!(body, "" | "+" | "-") || is_float_literal(body)
5455}
5456
5457enum State {
5460 StartRecord,
5461 StartField,
5462 InField,
5463 InQuotedField,
5464 QuoteInQuotedField,
5465}
5466
5467fn read_csv_rows(content: &str, dialect: &Dialect) -> Vec<Vec<String>> {
5476 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
5478 let mut rows: Vec<Vec<String>> = Vec::new();
5479 let mut row: Vec<String> = Vec::new();
5480 let mut field = String::new();
5481 let mut state = State::StartRecord;
5482
5483 for c in content.chars() {
5484 match state {
5485 State::StartRecord => {
5486 if c == '\n' {
5487 rows.push(Vec::new());
5488 } else {
5489 state = State::StartField;
5490 read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows);
5492 }
5493 }
5494 _ => read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows),
5495 }
5496 }
5497 if !matches!(state, State::StartRecord) {
5498 row.push(std::mem::take(&mut field));
5499 row.shrink_to_fit();
5500 rows.push(std::mem::take(&mut row));
5501 }
5502 rows
5503}
5504
5505fn read_csv_char(
5508 c: char,
5509 dialect: &Dialect,
5510 state: &mut State,
5511 field: &mut String,
5512 row: &mut Vec<String>,
5513 rows: &mut Vec<Vec<String>>,
5514) {
5515 let end_record = |field: &mut String, row: &mut Vec<String>, rows: &mut Vec<Vec<String>>| {
5516 row.push(std::mem::take(field));
5517 row.shrink_to_fit();
5519 rows.push(std::mem::take(row));
5520 };
5521 match state {
5522 State::StartRecord => unreachable!("handled by the caller"),
5523 State::StartField => {
5524 if c == '\n' {
5525 end_record(field, row, rows);
5526 *state = State::StartRecord;
5527 } else if c == dialect.quotechar {
5528 *state = State::InQuotedField;
5529 } else if c == ' ' && dialect.skipinitialspace {
5530 } else if c == dialect.delimiter {
5532 row.push(std::mem::take(field));
5533 } else {
5534 field.push(c);
5535 *state = State::InField;
5536 }
5537 }
5538 State::InField => {
5539 if c == '\n' {
5540 end_record(field, row, rows);
5541 *state = State::StartRecord;
5542 } else if c == dialect.delimiter {
5543 row.push(std::mem::take(field));
5544 *state = State::StartField;
5545 } else {
5546 field.push(c);
5547 }
5548 }
5549 State::InQuotedField => {
5550 if c == dialect.quotechar {
5551 *state = if dialect.doublequote {
5552 State::QuoteInQuotedField
5553 } else {
5554 State::InField
5557 };
5558 } else {
5559 field.push(c);
5560 }
5561 }
5562 State::QuoteInQuotedField => {
5563 if c == dialect.quotechar {
5564 field.push(c);
5565 *state = State::InQuotedField;
5566 } else if c == dialect.delimiter {
5567 row.push(std::mem::take(field));
5568 *state = State::StartField;
5569 } else if c == '\n' {
5570 end_record(field, row, rows);
5571 *state = State::StartRecord;
5572 } else {
5573 field.push(c);
5574 *state = State::InField;
5575 }
5576 }
5577 }
5578}
5579
5580fn is_number(value: &str) -> bool {
5586 let body = value.strip_prefix('-').unwrap_or(value);
5587 let (integer, fraction) = match body.split_once('.') {
5588 Some((integer, fraction)) => (integer, Some(fraction)),
5589 None => (body, None),
5590 };
5591 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5592 digits(integer) && fraction.is_none_or(digits)
5593}
5594
5595fn build_csv_table(
5610 content: &str,
5611 fallback_delimiter: Option<char>,
5612 title: Option<&str>,
5613 caption: Option<&str>,
5614 window: (Option<usize>, Option<usize>),
5615) -> Option<Table> {
5616 let sample: String = content.chars().take(1024).collect();
5619 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5620 let (dialect, header) = match sniffed {
5621 Some(sniffed) => sniffed,
5622 None => (Dialect::excel(fallback_delimiter?), true),
5623 };
5624 let mut rows = read_csv_rows(content, &dialect);
5625 if window.0.is_some() || window.1.is_some() {
5628 let header_row = if header && !rows.is_empty() {
5629 Some(rows.remove(0))
5630 } else {
5631 None
5632 };
5633 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5634 match window {
5635 (Some(head), _) => data.truncate(head),
5636 (None, Some(tail)) => {
5637 data.drain(..data.len().saturating_sub(tail));
5638 }
5639 (None, None) => {}
5640 }
5641 rows = header_row.into_iter().chain(data).collect();
5642 }
5643 Some(render_csv(rows, header, title, caption))
5644}
5645
5646fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5653 let name = resource.unwrap_or_default().to_lowercase();
5654 if name.ends_with(".csv") {
5655 Some(',')
5656 } else if name.ends_with(".tsv") {
5657 Some('\t')
5658 } else {
5659 None
5660 }
5661}
5662
5663fn render_csv(
5668 rows: Vec<Vec<String>>,
5669 has_header: bool,
5670 title: Option<&str>,
5671 caption: Option<&str>,
5672) -> Table {
5673 let mut table = Table::new()
5674 .border_style(Style::parse("blue").expect("valid style"))
5675 .show_header(has_header)
5676 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5677 if let Some(title) = title {
5678 table = table.title(title);
5679 }
5680 if let Some(caption) = caption {
5681 table = table.caption(caption);
5682 }
5683
5684 let empty: Vec<String> = Vec::new();
5685 let (header, body) = if has_header {
5686 match rows.split_first() {
5687 Some((header, body)) => (header, body),
5688 None => return table,
5689 }
5690 } else {
5691 (&empty, rows.as_slice())
5692 };
5693 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5695
5696 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5701 let columns = header.len().max(widest);
5702
5703 for index in 0..columns {
5704 let numeric = data.iter().all(|row| match row.get(index) {
5709 Some(value) => value.is_empty() || is_number(value),
5710 None => false,
5711 });
5712 let name = header.get(index).map(String::as_str).unwrap_or("");
5713 if numeric {
5714 table.add_column_justify(name, Justify::Right);
5715 table.column_style(Style::parse("bold green").expect("valid style"));
5716 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5717 } else {
5718 table.add_column(name);
5719 }
5720 }
5721 drop(data);
5724 table.extend_owned_rows(
5725 rows.into_iter()
5726 .skip(usize::from(has_header))
5727 .filter(|row| !row.is_empty())
5728 .collect(),
5729 );
5730 table
5731}
5732
5733fn join_source(value: &serde_json::Value) -> String {
5736 match value {
5737 serde_json::Value::String(s) => s.clone(),
5738 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5739 _ => String::new(),
5740 }
5741}
5742
5743fn join_traceback(value: &serde_json::Value) -> String {
5747 match value {
5748 serde_json::Value::String(s) => s.clone(),
5749 serde_json::Value::Array(a) => a
5750 .iter()
5751 .filter_map(|v| v.as_str())
5752 .collect::<Vec<_>>()
5753 .join(
5754 "
5755",
5756 ),
5757 _ => String::new(),
5758 }
5759}
5760
5761fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5765 let base_style = Style::parse(base).ok();
5766 let number_style = Style::parse(number).ok();
5767 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5768 let mut text = Text::new("");
5769 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5770 text.append(&n, number_style.map(Into::into));
5771 text.append("]:", base_style.map(Into::into));
5772 text
5773}
5774
5775struct Notebook {
5778 items: Vec<Box<dyn Renderable>>,
5779 justify: Option<Justify>,
5782}
5783
5784impl Renderable for Notebook {
5785 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5786 let mut options = options.clone();
5787 if let Some(justify) = self.justify {
5788 options.justify = justify;
5789 }
5790 let mut segments = Vec::new();
5791 for item in &self.items {
5792 let rendered = item.rich_render(console, &options);
5793 if !rendered.is_empty() {
5794 segments.extend(rendered);
5795 segments.push(Segment::line());
5796 }
5797 }
5798 if !segments.is_empty() {
5800 segments.pop();
5801 }
5802 segments
5803 }
5804
5805 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5806 self.items
5807 .iter()
5808 .map(|item| item.measure(console, options))
5809 .fold(Measurement::new(0, 0), |width, item| {
5810 Measurement::new(
5811 width.minimum.max(item.minimum),
5812 width.maximum.max(item.maximum),
5813 )
5814 })
5815 }
5816}
5817
5818fn notebook_ansi(source: &str) -> Text {
5820 let mut result = Text::new("");
5821 for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5822 if index > 0 {
5823 result.append("\n", None);
5824 }
5825 result = result.append_text(line);
5826 }
5827 result
5828}
5829
5830fn build_ipynb(
5833 notebook: &serde_json::Value,
5834 hyperlinks: bool,
5835 justify: Option<Justify>,
5836 source_options: &SourceOptions,
5837) -> Result<Notebook, String> {
5838 let language = notebook["metadata"]["kernelspec"]["language"]
5839 .as_str()
5840 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5841 .unwrap_or("python");
5842 let empty = Vec::new();
5843 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5844 let mut new_line = true;
5845 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5846 if new_line {
5847 items.push(text(""));
5848 }
5849 if cell.get("execution_count").is_some() {
5850 items.push(Box::new(io_label(
5851 "In ",
5852 cell["execution_count"].as_i64().filter(|n| *n != 0),
5853 "green",
5854 "#66ff00",
5855 )));
5856 }
5857 let source = join_source(&cell["source"]);
5858 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5859 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5860 "code" => Box::new(
5863 Panel::new(Box::new(source_options.syntax(&source, language)?))
5864 .border_style(Style::parse("dim").expect("valid style")),
5865 ),
5866 _ => text(&source),
5867 });
5868 new_line = true;
5869 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5870 let rendered = match output["output_type"].as_str().unwrap_or("") {
5871 "stream" => {
5872 new_line = false;
5873 notebook_ansi(&join_source(&output["text"]))
5874 }
5875 "error" => {
5876 new_line = true;
5877 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5878 }
5879 "execute_result" => {
5880 new_line = true;
5881 let mut label = io_label(
5882 "Out",
5883 output["execution_count"].as_i64().filter(|n| *n != 0),
5884 "red",
5885 "#ee4b2b",
5886 );
5887 label.append("\n", None);
5888 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5889 }
5890 _ => continue,
5891 };
5892 items.push(Box::new(rendered));
5893 }
5894 }
5895 Ok(Notebook { items, justify })
5896}
5897
5898fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5899 let displayed = |value: f32| {
5902 format!("{value:.1}")
5903 .parse::<f32>()
5904 .expect("formatted percentage is a number")
5905 };
5906 displayed(changed) > displayed(limit)
5907}
5908
5909fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5912 let mut contents = Vec::with_capacity(cli.resources.len());
5913 for resource in &cli.resources {
5914 let content = if is_url(resource) {
5915 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5916 } else {
5917 read_resource(Some(resource), cli.extensions.encoding)
5918 .map_err(|err| format!("cannot read {resource}: {err}"))
5919 };
5920 match content {
5921 Ok(content) => contents.push(content),
5922 Err(err) => return fail(cli, ExitClass::Input, err),
5923 }
5924 }
5925 let names: Vec<String> = cli
5926 .resources
5927 .iter()
5928 .map(|r| {
5929 if r == "-" {
5930 "<stdin>".to_string()
5931 } else {
5932 r.clone()
5933 }
5934 })
5935 .collect();
5936 let outcome = match tools::text_diff(
5937 &cli.tools,
5938 &names,
5939 &contents,
5940 cli.diff_threshold,
5941 cli.sanitize,
5942 ) {
5943 Ok(outcome) => outcome,
5944 Err(err) => return fail(cli, ExitClass::Data, err),
5945 };
5946 let fit = outcome
5947 .renderable
5948 .measure(console, &console.options())
5949 .maximum;
5950 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5951 if code != ExitCode::SUCCESS || !outcome.failed {
5952 return code;
5953 }
5954 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5955}
5956
5957#[cfg(feature = "art")]
5973fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5974 if fs_path(path).is_dir() {
5975 return Err(format!("cannot read {path}: is a directory, not a file"));
5976 }
5977 match rich_art::image::open(fs_path(path)) {
5978 Ok(image) => Ok(image),
5979 Err(err) => {
5980 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5981 let hint = match &err {
5982 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5983 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5984 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5985 _ => None,
5986 },
5987 _ => None,
5988 };
5989 Err(format!(
5990 "cannot read {path}: {}",
5991 hint.unwrap_or_else(|| err.to_string())
5992 ))
5993 }
5994 }
5995}
5996
5997#[cfg(feature = "art")]
6003fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
6004 match mode {
6005 ImageMode::Auto => rich_art::ImageMode::Auto,
6006 ImageMode::Sixel => rich_art::ImageMode::Sixel,
6007 ImageMode::Blocks => rich_art::ImageMode::Blocks,
6008 ImageMode::Braille => rich_art::ImageMode::Braille,
6009 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
6010 ImageMode::Ascii => rich_art::ImageMode::Ascii,
6011 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
6012 }
6013}
6014
6015#[cfg(feature = "art")]
6022fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6023 use rich_art::ImageArt;
6024
6025 let Some(resource) = cli.resource.as_deref() else {
6026 return fail(
6027 cli,
6028 ExitClass::Usage,
6029 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
6030 );
6031 };
6032
6033 let image = if resource == "-" {
6034 let mut buffer = Vec::new();
6035 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
6036 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
6037 }
6038 match rich_art::image::load_from_memory(&buffer) {
6039 Ok(image) => image,
6040 Err(err) => {
6041 return fail(
6042 cli,
6043 ExitClass::Input,
6044 format!("cannot decode image from stdin: {err}"),
6045 )
6046 }
6047 }
6048 } else {
6049 match open_image_path(resource) {
6050 Ok(image) => image,
6051 Err(message) => return fail(cli, ExitClass::Input, message),
6052 }
6053 };
6054
6055 let width = cli.width.unwrap_or_else(|| console.width());
6056 let mut art = ImageArt::new(image)
6057 .transforms(rich_art::ImageTransforms {
6058 rotation: match cli.image_rotate {
6059 Some(90) => rich_art::Rotation::Clockwise90,
6060 Some(180) => rich_art::Rotation::Clockwise180,
6061 Some(270) => rich_art::Rotation::Clockwise270,
6062 _ => rich_art::Rotation::None,
6063 },
6064 flip_horizontal: cli.image_flip_horizontal,
6065 flip_vertical: cli.image_flip_vertical,
6066 grayscale: cli.image_grayscale,
6067 brightness: cli.image_brightness.unwrap_or(1.0),
6068 contrast: cli.image_contrast.unwrap_or(1.0),
6069 gamma: cli.image_gamma.unwrap_or(1.0),
6070 })
6071 .mode(to_art_image_mode(cli.image_mode))
6072 .width(width)
6073 .color(!cli.no_color);
6074 if let Some(height) = cli.height {
6075 art = art.height(height);
6076 }
6077 if let Some(columns) = cli.image_max_width {
6078 art = art.max_width(columns);
6079 }
6080 if let Some(rows) = cli.image_max_height {
6081 art = art.max_height(rows);
6082 }
6083
6084 if let Some(fit) = cli.image_fit.as_deref() {
6085 art = art.fit(match fit {
6086 "cover" => rich_art::ImageFit::Cover,
6087 "stretch" => rich_art::ImageFit::Stretch,
6088 "native" => rich_art::ImageFit::Native,
6089 _ => rich_art::ImageFit::Contain,
6090 });
6091 }
6092 if let Some(anchor) = cli.image_anchor.as_deref() {
6093 use rich_art::ImageAnchor;
6094 art = art.anchor(match anchor {
6095 "top" => ImageAnchor::Top,
6096 "bottom" => ImageAnchor::Bottom,
6097 "left" => ImageAnchor::Left,
6098 "right" => ImageAnchor::Right,
6099 "top-left" => ImageAnchor::TopLeft,
6100 "top-right" => ImageAnchor::TopRight,
6101 "bottom-left" => ImageAnchor::BottomLeft,
6102 "bottom-right" => ImageAnchor::BottomRight,
6103 _ => ImageAnchor::Center,
6104 });
6105 }
6106 match cli.image_background {
6107 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
6108 Some(ImageBackdrop::Terminal) => {
6109 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
6110 }
6111 Some(ImageBackdrop::Checkerboard) => {
6112 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
6113 }
6114 None => {}
6115 }
6116 let (color_mode, dither, distance) = image_color_processing(cli);
6117 art = art
6118 .color_mode(color_mode)
6119 .dither(dither)
6120 .color_distance(distance);
6121
6122 let options = console.options();
6127 if let Err(err) = art.render(console, &options) {
6128 let message = match err {
6132 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
6133 "this terminal (TERM={}) is not known to support Sixel graphics; set \
6134 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
6135 --image-mode blocks, braille or ascii",
6136 std::env::var("TERM").unwrap_or_default()
6137 ),
6138 err => err.to_string(),
6139 };
6140 return fail(cli, ExitClass::Input, message);
6141 }
6142
6143 let terminal_only = Export {
6144 html_path: None,
6145 svg_path: None,
6146 ..*export
6147 };
6148 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
6149 return fail(cli, ExitClass::Input, message);
6150 }
6151 if export.html_path.is_some() || export.svg_path.is_some() {
6152 use rich::protocol::RenderEnvironment;
6153 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
6154 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
6155 caps.color_system = if cli.no_color {
6156 None
6157 } else {
6158 Some(ColorSystem::Truecolor)
6159 };
6160 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
6161 let export_console = target.console();
6162 if let Err(err) =
6163 art.render_with_environment(&export_console, &export_console.options(), &target)
6164 {
6165 return fail(cli, ExitClass::Input, err.to_string());
6166 }
6167 let segments = target.segments(&art);
6168 debug_assert!(!target.capabilities().interactive);
6169 if let Err(message) = save_exports(&export_console, export, &segments) {
6170 return fail(cli, ExitClass::Input, message);
6171 }
6172 }
6173 success(cli)
6174}
6175
6176#[cfg(feature = "art")]
6177fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6178 use rich::Table;
6179 use rich_art::imagediff::{diff, DiffSettings};
6180 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
6181
6182 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
6183 let before = match open_image_path(before_path) {
6184 Ok(image) => image,
6185 Err(message) => return fail(cli, ExitClass::Input, message),
6186 };
6187 let after = match open_image_path(after_path) {
6188 Ok(image) => image,
6189 Err(message) => return fail(cli, ExitClass::Input, message),
6190 };
6191
6192 let report = match diff(&before, &after, &DiffSettings::default()) {
6193 Ok(report) => report,
6194 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
6195 };
6196
6197 let width = cli.width.unwrap_or_else(|| console.width());
6198 let changed = report.changed_fraction * 100.0;
6199 let naive = report.naive_changed_fraction * 100.0;
6200
6201 let failed = cli
6210 .diff_threshold
6211 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
6212
6213 let render_report = |c: &Console, for_export: bool| {
6214 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
6217
6218 let has_color = c.color_system().is_some() && !cli.no_color;
6225 let is_terminal = c.is_terminal();
6226 let mut mode = cli.image_mode;
6227 if mode == ImageMode::Auto {
6228 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
6229 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
6230 }
6231 mode = if !has_color {
6232 ImageMode::Ascii
6233 } else if is_terminal && rich_art::sixel::is_probably_supported() {
6234 ImageMode::Sixel
6235 } else {
6236 ImageMode::Blocks
6237 };
6238 }
6239
6240 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
6244 if !for_export && cli.report_format == ReportFormat::Human {
6245 eprintln!("rich: no colour available, drawing the diff as ASCII art");
6246 }
6247 mode = ImageMode::Ascii;
6248 }
6249 if mode == ImageMode::Sixel && !is_terminal {
6250 if cli.image_mode != ImageMode::Sixel
6251 && !for_export
6252 && cli.report_format == ReportFormat::Human
6253 {
6254 eprintln!(
6255 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
6256 if has_color { "blocks" } else { "ASCII art" }
6257 );
6258 }
6259 mode = if has_color {
6260 ImageMode::Blocks
6261 } else {
6262 ImageMode::Ascii
6263 };
6264 }
6265
6266 match mode {
6267 ImageMode::None => {}
6268 ImageMode::Ascii => c.print(
6269 &AsciiArt::new(report.heatmap())
6270 .width(width)
6271 .color(has_color),
6272 ),
6273 ImageMode::Blocks => c.print(
6274 &BlockArt::new(report.heatmap())
6275 .width(width)
6276 .height(rows_cap),
6277 ),
6278 ImageMode::Quadrants => c.print(
6279 &rich_art::QuadrantArt::new(report.heatmap())
6280 .width(width)
6281 .height(rows_cap),
6282 ),
6283 ImageMode::Braille => c.print(
6284 &BrailleArt::new(report.heatmap())
6285 .width(width)
6286 .height(rows_cap),
6287 ),
6288 ImageMode::Sixel => {
6289 let art = SixelArt::new(report.heatmap())
6290 .width(width)
6291 .height(rows_cap);
6292 if art.encode(width).is_some() {
6295 c.print(&art);
6296 } else {
6297 if cli.report_format == ReportFormat::Human {
6298 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
6299 }
6300 c.print(
6301 &BlockArt::new(report.heatmap())
6302 .width(width)
6303 .height(rows_cap),
6304 );
6305 }
6306 }
6307 ImageMode::Auto => unreachable!("resolved above"),
6308 }
6309 if mode != ImageMode::None {
6313 c.print_str("");
6314 }
6315 c.print_str(&format!(
6316 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
6317 [dim](a plain pixel diff would say {naive:.1}%)[/]"
6318 ));
6319
6320 if report.regions.is_empty() {
6321 c.print_str("[dim]No region large enough to report.[/]");
6322 } else {
6323 let mut table = Table::new().title("Where it changed");
6324 table.add_column("#");
6325 table.add_column_justify("Share", rich::Justify::Right);
6326 table.add_column_justify("Mean ΔE", rich::Justify::Right);
6327 table.add_column_justify("Area px", rich::Justify::Right);
6328 table.add_column("Box (x,y w×h)");
6329 for (rank, r) in report.regions.iter().enumerate() {
6330 table.add_row(&[
6331 &format!("{}", rank + 1),
6332 &format!("{:.0}%", r.share_of_change * 100.0),
6333 &format!("{:.1}", r.mean_delta_e),
6334 &format!("{}", r.area_px),
6335 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
6336 ]);
6337 }
6338 c.print(&table);
6339 }
6340
6341 if let Some(limit) = cli.diff_threshold {
6345 if failed {
6346 c.print_str(&format!(
6347 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
6348 ));
6349 } else {
6350 c.print_str(&format!(
6351 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
6352 ));
6353 }
6354 }
6355 };
6356
6357 let terminal_only = Export {
6358 html_path: None,
6359 svg_path: None,
6360 ..*export
6361 };
6362 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
6363 if export.html_path.is_some() || export.svg_path.is_some() {
6364 let mut export_console = Console::builder()
6368 .force_terminal(false)
6369 .color_system(Some(ColorSystem::Truecolor))
6370 .no_color(cli.no_color)
6371 .width(console.width())
6372 .height(console.height())
6373 .theme(console.theme().clone())
6374 .build();
6375 export_console.install_extensions();
6376 let segments = export_console.record_output(|c| render_report(c, true));
6377 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6378 }
6379
6380 if let Err(message) = wrote {
6381 return fail(cli, ExitClass::Input, message);
6382 }
6383 if failed {
6384 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6385 }
6386 success(cli)
6387}
6388
6389#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6392#[cfg_attr(not(feature = "art"), allow(dead_code))]
6393enum ImageBackdrop {
6394 Color([u8; 3]),
6396 Terminal,
6398 Checkerboard,
6400}
6401
6402impl ImageBackdrop {
6403 fn parse(value: &str) -> Option<Self> {
6404 match value {
6405 "default" => return Some(ImageBackdrop::Terminal),
6406 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6407 _ => {}
6408 }
6409 let hex = value.strip_prefix('#')?;
6410 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6411 return None;
6412 }
6413 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6414 Some(ImageBackdrop::Color([
6415 channel(0)?,
6416 channel(1)?,
6417 channel(2)?,
6418 ]))
6419 }
6420}
6421
6422#[cfg(feature = "art")]
6425fn image_color_processing(
6426 cli: &Cli,
6427) -> (
6428 rich_art::ImageColorMode,
6429 rich_art::Dither,
6430 rich_art::ColorDistance,
6431) {
6432 use rich_art::{ColorDistance, Dither, ImageColorMode};
6433 let mode = match cli.image_color.as_deref() {
6434 Some("ansi256") => ImageColorMode::Ansi256,
6435 Some("ansi16") => ImageColorMode::Ansi16,
6436 Some("grayscale") => ImageColorMode::Grayscale,
6437 _ => ImageColorMode::TrueColor,
6438 };
6439 let dither = match cli.image_dither.as_deref() {
6440 Some("floyd-steinberg") => Dither::FloydSteinberg,
6441 Some("bayer4x4") => Dither::Bayer4x4,
6442 Some("atkinson") => Dither::Atkinson,
6443 _ => Dither::None,
6444 };
6445 let distance = match cli.image_color_distance.as_deref() {
6446 Some("oklab") => ColorDistance::Oklab,
6447 _ => ColorDistance::Rgb,
6448 };
6449 (mode, dither, distance)
6450}
6451
6452#[cfg(feature = "art")]
6453fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6454 use rich_art::{AnimatedArt, Repeat, Stage};
6455
6456 if cli.resources.is_empty() {
6457 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6458 }
6459 let count = cli.resources.len();
6460 const GAP: usize = 2;
6461
6462 let total = cli.width.unwrap_or_else(|| console.width());
6464 let per_gif = total
6465 .saturating_sub(GAP * count.saturating_sub(1))
6466 .checked_div(count)
6467 .unwrap_or(total)
6468 .max(8);
6469
6470 let repeat = match cli.loops {
6471 Some(0) => Repeat::Forever,
6472 Some(n) => Repeat::Times(n),
6473 None => Repeat::Times(1),
6474 };
6475
6476 let mut stage = Stage::new().gap(GAP);
6477 for path in &cli.resources {
6478 match AnimatedArt::from_path(path) {
6479 Ok(art) => {
6480 let (color_mode, dither, distance) = image_color_processing(cli);
6481 stage = stage.with(
6482 art.width(per_gif)
6483 .blocks(cli.extensions.gif_blocks())
6484 .color(!cli.no_color)
6485 .color_mode(color_mode)
6486 .dither(dither)
6487 .color_distance(distance)
6488 .repeat(repeat)
6489 .max_fps(30.0),
6491 );
6492 }
6493 Err(err) => {
6494 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6495 }
6496 }
6497 }
6498
6499 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6500 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6501 }
6502
6503 let mut builder = Console::builder().no_color(cli.no_color);
6505 if cli.themed() {
6506 builder = builder.theme(cli_theme(cli));
6507 }
6508 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6511 builder = builder.force_terminal(terminal == "1");
6512 }
6513 if let Some(width) = cli.width {
6514 builder = builder.width(width);
6515 }
6516 match stage.play_stdout(builder.build()) {
6517 Ok(()) => success(cli),
6518 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6519 }
6520}
6521
6522#[cfg(not(feature = "art"))]
6524fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6525 fail(
6526 _cli,
6527 ExitClass::Usage,
6528 "this build has no GIF support (rebuild with the `art` feature)",
6529 )
6530}
6531
6532struct Export<'a> {
6535 html_path: Option<&'a str>,
6537 svg_path: Option<&'a str>,
6539 svg_title: &'a str,
6541 pager: bool,
6543 auto_pager: bool,
6544}
6545
6546impl Export<'_> {
6547 fn terminal_only(&self) -> bool {
6550 !watch::capturing()
6551 && self.html_path.is_none()
6552 && self.svg_path.is_none()
6553 && !self.pager
6554 && !self.auto_pager
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 first_error.map_or(Ok(()), Err)
6647}
6648
6649fn print_help(no_color: bool) {
6651 cli_spec::print_help(no_color);
6652}
6653
6654fn build_demo_console(no_color: bool) -> Console {
6655 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6660 let mut console = Console::builder()
6661 .force_terminal(to_terminal)
6662 .no_color(no_color)
6663 .color_system(if to_terminal {
6664 Some(ColorSystem::Truecolor)
6665 } else {
6666 None
6667 })
6668 .theme(rich_ext::extended_theme())
6671 .build();
6672 console.install_extensions();
6673 console
6674}
6675
6676fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6677 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6678 let console = build_demo_console(no_color);
6679
6680 demo::section(&console, delay, "rs-rich-cli");
6681 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6682 console.print_str(
6683 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6684 );
6685
6686 demo::section(&console, delay, "markup · color · theme · extension");
6688 console.print_str(
6689 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6690 );
6691 console.print_str(
6692 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6693 );
6694 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6695 let mut themed = build_demo_console(no_color);
6697 let release =
6698 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6699 themed.use_theme(release).print_str(
6700 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6701 );
6702 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6703 let hl = Console::builder()
6709 .force_terminal(to_terminal)
6710 .no_color(no_color)
6711 .color_system(if to_terminal {
6712 Some(ColorSystem::Truecolor)
6713 } else {
6714 None
6715 })
6716 .highlight(true)
6717 .build();
6718 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6719 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6721 ISO8601Highlighter::new().highlight(&mut stamp);
6722 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6723 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6724
6725 demo::section(&console, delay, "rule");
6727 console.print(&Rule::line());
6728 demo::section(&console, delay, "centered");
6729 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6730 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6731
6732 demo::section(&console, delay, "panel");
6734 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6735 console.print(
6736 &Panel::new(text("square box"))
6737 .box_set(SQUARE)
6738 .title("square"),
6739 );
6740 console.print(
6741 &Panel::new(text("double box"))
6742 .box_set(DOUBLE)
6743 .title("double"),
6744 );
6745 console.print(
6746 &Panel::new(text("title + subtitle"))
6747 .title("top")
6748 .subtitle("bottom"),
6749 );
6750 let legacy = Console::builder()
6752 .force_terminal(to_terminal)
6753 .no_color(no_color)
6754 .color_system(if to_terminal {
6755 Some(ColorSystem::Truecolor)
6756 } else {
6757 None
6758 })
6759 .width(console.width())
6760 .legacy_windows(true)
6761 .build();
6762 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6763
6764 demo::section(&console, delay, "padding");
6766 console.print(
6767 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6768 );
6769
6770 demo::section(&console, delay, "styled");
6772 console.print(&Styled::new(
6773 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6774 Style::parse("green").unwrap(),
6775 ));
6776
6777 demo::section(&console, delay, "align");
6779 console.print(&Align::left(text("← left")));
6780 console.print(&Align::center(text("center")));
6781 console.print(&Align::right(text("right →")));
6782
6783 demo::section(&console, delay, "justify");
6785 console.print_justified("left justified", Justify::Left);
6786 console.print_justified("centered", Justify::Center);
6787 console.print_justified("right justified", Justify::Right);
6788
6789 demo::section(&console, delay, "constrain (width 24)");
6791 console.print(&Constrain::new(
6792 Box::new(Panel::new(text("constrained")).title("≤24")),
6793 Some(24),
6794 ));
6795
6796 demo::section(&console, delay, "table");
6798 let mut table = Table::new().title("ported renderables");
6799 table
6801 .add_column("Renderable")
6802 .column_style(Style::parse("cyan").unwrap());
6803 table.add_column("Module").column_width(12);
6804 table.add_column_justify("Parity", Justify::Center);
6805 for (renderable, module, parity) in [
6806 ("Text / markup", "text, markup", "✓"),
6807 ("Rule", "rule", "✓"),
6808 ("Panel", "panel", "✓"),
6809 ("Padding", "padding", "✓"),
6810 ("Align", "align", "✓"),
6811 ("Constrain", "constrain", "✓"),
6812 ("Columns", "columns", "✓"),
6813 ("Table", "table", "✓"),
6814 ("Tree", "tree", "✓"),
6815 ("ProgressBar", "progress_bar", "✓"),
6816 ] {
6817 table.add_row(&[renderable, module, parity]);
6818 }
6819 console.print(&table);
6820
6821 demo::section(&console, delay, "columns");
6823 let months = [
6824 "January",
6825 "February",
6826 "March",
6827 "April",
6828 "May",
6829 "June",
6830 "July",
6831 "August",
6832 "September",
6833 "October",
6834 "November",
6835 "December",
6836 ];
6837 console.print(&Columns::new(
6838 months.iter().map(|m| m.to_string()).collect(),
6839 ));
6840
6841 demo::section(&console, delay, "tree");
6843 let mut tree = Tree::new("rs-rich-cli");
6844 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6845 core.add("color · style · text · console");
6846 core.add("box · rule · panel · padding · align");
6847 core.add("table · tree · wrap · filesize");
6848 tree.add("crates/rich-ext (our extensions)");
6849 tree.add("crates/rich-cli (this binary)");
6850 console.print(&tree);
6851
6852 demo::section(&console, delay, "progress bar");
6854 for pct in [0.0, 33.0, 66.0, 100.0] {
6855 console.print(&ProgressBar::new(100.0, pct).width(48));
6856 }
6857
6858 demo::section(&console, delay, "progress");
6862 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6863 let set_time =
6864 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6865 let reader = clock.clone();
6866 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6867 let mut progress = Progress::new().clock(now.clone());
6868 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6869 let processing = progress.add_task("Processing", 100.0, 0.0);
6870 progress.add_task("Waiting", 100.0, 0.0);
6871 set_time(4.0);
6872 progress.advance(downloading, 20.0);
6873 progress.advance(processing, 60.0);
6874 set_time(8.0);
6875 progress.advance(downloading, 30.0);
6876 progress.advance(processing, 40.0);
6877 console.print(&progress);
6878
6879 let mut transfer = Progress::new().clock(now).columns(vec![
6881 ProgressColumn::spinner(),
6882 ProgressColumn::Description,
6883 ProgressColumn::Bar,
6884 ProgressColumn::Download,
6885 ProgressColumn::TransferSpeed,
6886 ProgressColumn::TimeElapsed,
6887 ProgressColumn::time_remaining(),
6888 ]);
6889 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6890 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6891 set_time(12.0);
6892 transfer.advance(iso, 1_200_000_000.0);
6893 transfer.advance(notes, 20_000.0);
6894 set_time(20.0);
6895 transfer.advance(iso, 900_000_000.0);
6896 transfer.advance(notes, 22_000.0);
6897 console.print(&transfer);
6898
6899 let mut mofn = Progress::new().columns(vec![
6901 ProgressColumn::Description,
6902 ProgressColumn::Bar,
6903 ProgressColumn::MofN,
6904 ]);
6905 mofn.add_task("Files", 8.0, 3.0);
6906 mofn.add_task("Chunks", 128.0, 128.0);
6907 console.print(&mofn);
6908
6909 let mut download = Progress::new().columns(vec![
6911 ProgressColumn::Description,
6912 ProgressColumn::Bar,
6913 ProgressColumn::Download,
6914 ]);
6915 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6916 console.print(&download);
6917
6918 demo::section(&console, delay, "json");
6920 if let Ok(json) = Json::new(
6921 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6922 ) {
6923 console.print(&json);
6924 }
6925
6926 demo::section(&console, delay, "pretty");
6928 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6929 console.print(&Pretty::new(&value));
6930
6931 demo::section(&console, delay, "log");
6933 let log = LogRender::new().show_level(true);
6934 for (level, message) in [
6935 (LogLevel::Info, "server started on port 8080"),
6936 (LogLevel::Debug, "cache warm: 128 entries"),
6937 (LogLevel::Warn, "disk usage at 85%"),
6938 (LogLevel::Error, "connection reset by peer"),
6939 ] {
6940 console.print(&log.render(
6941 &console,
6942 Text::new(message),
6943 Some(Text::new("[12:00:00]")),
6944 level.text(),
6945 Some("main.rs"),
6946 Some(42),
6947 None,
6948 ));
6949 }
6950
6951 demo::section(&console, delay, "traceback");
6953 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6954 let err = std::io::Error::other(cause);
6955 console.print(&Traceback::new(&err));
6956
6957 demo::section(&console, delay, "bar (range)");
6959 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6960 console.print(&Bar::new(100.0, begin, end).width(48));
6961 }
6962
6963 demo::section(&console, delay, "markdown");
6965 console.print(&Markdown::new(
6966 "# 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---",
6967 ));
6968
6969 demo::section(&console, delay, "syntax");
6971 console.print(&Syntax::new(
6972 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
6973 "rust",
6974 ));
6975
6976 demo::section(&console, delay, "control codes");
6978 let show_escape = |label: &str, control: &Control| {
6979 let escaped = control
6981 .as_str()
6982 .replace('\x1b', "\\x1b")
6983 .replace('\x07', "\\a")
6984 .replace('\r', "\\r");
6985 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
6986 };
6987 show_escape("clear screen", &Control::clear());
6988 show_escape("home", &Control::home());
6989 show_escape("move (2,-1)", &Control::move_(2, -1));
6990 show_escape("move_to (3,4)", &Control::move_to(3, 4));
6991 show_escape("hide cursor", &Control::show_cursor(false));
6992 show_escape("bell", &Control::bell());
6993 let live_render = LiveRender::new(text("a\nb\nc"));
6995 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
6997 let live_console = Console::builder()
7000 .force_terminal(true)
7001 .color_system(Some(ColorSystem::Truecolor))
7002 .width(20)
7003 .build();
7004 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
7005 live.start();
7006 live.update(text("frame two"));
7007 live.stop();
7008 let stream = String::from_utf8_lossy(live.writer())
7009 .replace('\x1b', "\\x1b")
7010 .replace('\r', "\\r")
7011 .replace('\n', "\\n");
7012 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
7013
7014 demo::section(&console, delay, "ansi decoder");
7016 let raw =
7017 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
7018 console.print(&Text::new(format!(
7019 " input: {}",
7020 raw.replace('\x1b', "\\x1b")
7021 )));
7022 let mut decoder = rich::AnsiDecoder::new();
7023 for line in decoder.decode(raw) {
7024 console.print(&Text::new(" output: ").append_text(&line));
7025 }
7026
7027 demo::section(&console, delay, "capture · export_text");
7029 let plain = console.export_text(|c| {
7030 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
7031 });
7032 console.print(&Text::new(" export_text (styles stripped):"));
7033 for line in plain.lines() {
7034 console.print(&Text::new(format!(" {line}")));
7035 }
7036 let html_rule = Style::parse("bold red")
7038 .unwrap()
7039 .get_html_style(&DEFAULT_TERMINAL_THEME);
7040 console.print(&Text::new(format!(
7041 " export_html: [bold red] → <span style=\"{html_rule}\">"
7042 )));
7043
7044 demo::section(&console, delay, "layout");
7047 let panel_leaf = |title: &str, body: &str| {
7048 Layout::with_renderable(Box::new(
7049 Panel::new(text(body))
7050 .box_set(SQUARE)
7051 .title(title.to_string()),
7052 ))
7053 };
7054 let mut header = Layout::new();
7055 header.split_row(vec![
7056 panel_leaf("left", "pane A"),
7057 panel_leaf("right", "pane B"),
7058 ]);
7059 let mut root = Layout::new();
7060 root.split_column(vec![
7061 header,
7062 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
7063 ]);
7064 let grid = Console::builder()
7065 .force_terminal(true)
7066 .color_system(Some(ColorSystem::Truecolor))
7067 .width(48)
7068 .height(7)
7069 .build();
7070 for line in grid.export_text(|c| c.print(&root)).lines() {
7071 console.print(&Text::new(line));
7072 }
7073
7074 demo::section(&console, delay, "spinner (frames)");
7076 for name in ["dots", "line", "arrow", "simpleDots"] {
7077 let spinner = Spinner::new(name);
7078 let frames: String = (0..6)
7079 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
7080 .collect::<Vec<_>>()
7081 .join(" ");
7082 console.print_str(&format!(" {name:>11}: {frames}"));
7083 }
7084 let status = Status::new("Loading…").renderable().render(0.0);
7086 console.print(&Text::new(" status: ").append_text(&status));
7087
7088 demo::section(&console, delay, "csv");
7090 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
7091 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None)) {
7092 console.print(&table);
7093 }
7094
7095 demo::section(&console, delay, "filesize");
7097 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
7098 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
7099 }
7100 console.print(&Rule::line());
7101 console
7102}
7103
7104#[cfg(test)]
7105mod tests {
7106 use super::*;
7107 use rich::ColorSystem;
7108
7109 const MODE_FLAGS: &[&str] = &[
7111 "--print",
7112 "-p",
7113 "--markdown",
7114 "-m",
7115 "--json",
7116 "-j",
7117 "-J",
7118 "--syntax",
7119 "-x",
7120 "--csv",
7121 "--ipynb",
7122 "--rst",
7123 "--jsonl",
7124 "--ndjson",
7125 "--log",
7126 "--rule",
7127 "-u",
7128 "--image",
7129 "--gif",
7130 "--diff",
7131 "--inspect",
7132 "--ansi-explain",
7133 ];
7134
7135 #[test]
7138 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
7139 for flag in MODE_FLAGS {
7140 assert!(mode_flag_alias(flag).is_some(), "{flag}");
7141 }
7142 let claimed = |args: &[String]| {
7143 let mut words = Vec::new();
7144 if plugins::requested(args) {
7145 words.push("plugins");
7146 }
7147 if doctor::requested(args) {
7148 words.push("doctor");
7149 }
7150 #[cfg(feature = "art")]
7151 if micro::requested(args) {
7152 words.push("micro");
7153 }
7154 #[cfg(feature = "record")]
7155 if record::requested(args) {
7156 words.push("record");
7157 }
7158 #[cfg(feature = "interact")]
7159 words.extend(interactive::requested(args));
7160 if tools::bench_dispatch(args).unwrap_or(true) {
7161 words.push("bench");
7162 }
7163 let roots = ConfigRoots {
7164 home: None,
7165 cwd: std::env::temp_dir(),
7166 no_color_env: false,
7167 };
7168 if !matches!(config::inspect(args, &roots), Ok(None)) {
7169 words.push("config");
7170 }
7171 words
7172 };
7173 let words = [
7174 "plugins", "doctor", "record", "choose", "filter", "input", "confirm", "pager",
7175 "bench", "config",
7176 ];
7177 for word in words {
7178 let plain = claimed(&[word.to_string(), "--no-color".into()]);
7180 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
7181 for flag in MODE_FLAGS {
7182 let args = [flag.to_string(), word.to_string()];
7183 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
7184 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
7185 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
7186 }
7187 }
7188 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
7189 assert!(doctor::requested(&["doctor".into()]));
7190 }
7191
7192 #[cfg(feature = "art")]
7193 #[test]
7194 fn diff_threshold_uses_the_reports_tie_rounding() {
7195 assert!(!diff_threshold_exceeded(0.25, 0.24));
7197 assert!(diff_threshold_exceeded(0.75, 0.74));
7198 assert!(!diff_threshold_exceeded(0.75, 0.76));
7199 }
7200
7201 #[test]
7202 fn read_csv_rows_handles_quotes_and_delimiters() {
7203 let comma = Dialect::excel(',');
7204 let rows = read_csv_rows("a,\"b,c\",d\n\"he said \"\"hi\"\"\",2\n", &comma);
7206 assert_eq!(
7207 rows,
7208 vec![
7209 vec!["a".to_string(), "b,c".to_string(), "d".to_string()],
7210 vec!["he said \"hi\"".to_string(), "2".to_string()],
7211 ]
7212 );
7213 assert_eq!(read_csv_rows("x\ny\n", &comma), vec![vec!["x"], vec!["y"]]);
7216 assert_eq!(
7218 read_csv_rows("a\tb", &Dialect::excel('\t')),
7219 vec![vec!["a", "b"]]
7220 );
7221 assert_eq!(read_csv_rows("\u{feff}a,b", &comma), vec![vec!["a", "b"]]);
7223 assert_eq!(
7226 read_csv_rows("a,b\n\nc,d\n", &comma),
7227 vec![vec!["a", "b"], vec![], vec!["c", "d"]]
7228 );
7229 let padded = Dialect {
7231 skipinitialspace: true,
7232 ..Dialect::excel(',')
7233 };
7234 assert_eq!(
7235 read_csv_rows("a, b , c", &padded),
7236 vec![vec!["a", "b ", "c"]]
7237 );
7238 }
7239
7240 #[test]
7241 fn the_sniffer_finds_the_delimiter_and_the_header() {
7242 let candidates = [',', '\t', '|', ';'];
7244 let sniffed =
7245 |sample: &str| sniff(sample, Some(&candidates)).map(|dialect| dialect.delimiter);
7246 assert_eq!(
7247 sniffed("name;age;city\nAlice;30;Paris\nBob;25;Lyon\n"),
7248 Some(';')
7249 );
7250 assert_eq!(sniffed("a|b|c\n1|2|3\n4|5|6\n"), Some('|'));
7251 assert_eq!(sniffed("a\tb\tc\n1\t2\t3\n4\t5\t6\n"), Some('\t'));
7252 assert_eq!(sniffed("a, b, c\n1, 2, 3\n4, 5, 6\n"), Some(','));
7253 assert_eq!(
7256 sniffed("name,bio\nAlice,\"line one\nline two\"\nBob,short\n"),
7257 Some(',')
7258 );
7259 assert_eq!(sniffed("a,b,c\n1,2\n3,4,5,6\n"), None);
7262 assert_eq!(sniffed("alpha\nbeta\ngamma\n"), None);
7263
7264 assert_eq!(has_header("name,age\nAlice,30\nBob,25\n"), Some(true));
7265 assert_eq!(has_header("price,note\n10,aa\n9.5,bbb\n"), Some(true));
7269 assert_eq!(has_header("1,2,3\n4,5,6\n7,8,9\n"), Some(false));
7270 }
7271
7272 #[test]
7273 fn detects_urls() {
7274 assert!(is_url("http://example.com"));
7275 assert!(is_url("https://example.com/x"));
7276 assert!(is_url("HTTPS://example.com"));
7278 assert!(is_url("Http://example.com"));
7279 assert!(!is_url("example.com"));
7280 assert!(!is_url("./file.md"));
7281 assert!(!is_url("-"));
7282 assert!(!is_url("")); assert!(!is_url("ht"));
7284 assert!(!is_url("ftp://example.com"));
7285 }
7286
7287 #[test]
7288 fn uninformative_extensions_defer_to_content_type() {
7289 assert!(is_uninformative_ext("txt"));
7290 assert!(is_uninformative_ext("log"));
7291 assert!(!is_uninformative_ext("py"));
7292 assert!(!is_uninformative_ext("rs"));
7293 }
7294
7295 #[test]
7296 fn resource_ext_uses_basename_without_query() {
7297 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
7298 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
7299 assert_eq!(
7300 resource_ext("https://x.com/c.py?raw=1").as_deref(),
7301 Some("py")
7302 );
7303 assert_eq!(resource_ext("https://api.example.com/data"), None);
7305 assert_eq!(resource_ext("https://x.com/"), None);
7306 assert_eq!(resource_ext("noext"), None);
7307 }
7308
7309 #[test]
7310 fn content_type_maps_mode_and_lexer() {
7311 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
7312 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
7313 assert_eq!(
7314 content_type_mode("application/json; charset=utf-8"),
7315 Some(Mode::Json)
7316 );
7317 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
7318 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
7320 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
7321 assert_eq!(content_type_lexer("text/plain"), None);
7322 }
7323
7324 #[test]
7325 fn join_source_handles_string_and_array() {
7326 use serde_json::json;
7327 assert_eq!(join_source(&json!("a\nb")), "a\nb");
7328 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
7330 assert_eq!(join_source(&json!(null)), "");
7331 }
7332
7333 #[test]
7334 fn ipynb_extension_auto_detects() {
7335 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
7336 }
7337
7338 #[test]
7339 fn io_label_builds_execution_count() {
7340 assert_eq!(
7342 io_label("In ", Some(1), "green", "#66ff00").plain(),
7343 "In [1]:"
7344 );
7345 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
7346 }
7347
7348 #[test]
7349 fn parse_padding_unpacks_like_upstream() {
7350 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
7351 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
7352 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
7353 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
7354 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
7356 }
7357
7358 #[test]
7359 fn parse_box_maps_names() {
7360 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
7361 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
7362 assert!(matches!(parse_box("none"), Ok(None)));
7364 assert!(parse_box("bogus").is_err());
7365 }
7366
7367 #[test]
7368 fn paging_policy_respects_viewport_and_redirection() {
7369 assert!(!should_page(true, true, false, 100, 24));
7370 assert!(!should_page(false, true, true, 23, 24));
7371 assert!(should_page(false, true, true, 24, 24));
7372 assert!(should_page(true, false, true, 1, 24));
7373 assert!(!should_page(false, false, true, 100, 24));
7374 }
7375
7376 #[test]
7377 fn worker_arguments_preserve_resources_named_like_commands() {
7378 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
7379 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
7380 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
7381 }
7382 }
7383
7384 #[test]
7385 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
7386 let args: Vec<String> = [
7387 "--batch",
7388 "--title",
7389 "--batch",
7390 "--jobs",
7391 "2",
7392 "json",
7393 "--export-html",
7394 "out.html",
7395 "--",
7396 "--pager",
7397 ]
7398 .into_iter()
7399 .map(str::to_string)
7400 .collect();
7401 assert_eq!(
7402 worker_args(&args),
7403 ["--no-config", "--title", "--batch", "--json"]
7404 );
7405 }
7406
7407 #[test]
7409 fn worker_arguments_keep_the_code_highlighter() {
7410 let args: Vec<String> = [
7411 "--batch",
7412 "--highlighter",
7413 "syntect",
7414 "--code-theme",
7415 "ansi_dark",
7416 "a.py",
7417 ]
7418 .into_iter()
7419 .map(str::to_string)
7420 .collect();
7421 assert_eq!(
7422 worker_args(&args),
7423 [
7424 "--no-config",
7425 "--highlighter",
7426 "syntect",
7427 "--code-theme",
7428 "ansi_dark"
7429 ]
7430 );
7431 }
7432
7433 #[test]
7434 fn parses_pager_flag() {
7435 let s = |v: &str| v.to_string();
7436 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
7437 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
7438 }
7439
7440 #[test]
7441 fn parses_watch_options_and_rejects_orphans() {
7442 let s = |v: &str| v.to_string();
7443 let cli = parse(&[
7444 s("--watch"),
7445 s("--watch-interval"),
7446 s("0.25"),
7447 s("--watch-cache"),
7448 s("file.md"),
7449 ])
7450 .unwrap()
7451 .unwrap();
7452 assert!(cli.watch);
7453 assert_eq!(cli.watch_interval, 0.25);
7454 assert!(cli.watch_cache);
7455 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7456 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7457 }
7458
7459 #[test]
7460 fn parses_multi_file_watch_options() {
7461 let s = |v: &str| v.to_string();
7462 let parse = parse_inner;
7463 let cli = parse(&[
7464 s("--watch"),
7465 s("--watch-debounce"),
7466 s("0.3"),
7467 s("--watch-poll"),
7468 s("--watch-exit-on-error"),
7469 s("a.md"),
7470 s("b.json"),
7471 ])
7472 .unwrap()
7473 .unwrap();
7474 assert_eq!(cli.resources, ["a.md", "b.json"]);
7475 assert_eq!(cli.watch_debounce, 0.3);
7476 assert!(cli.watch_poll && cli.watch_exit_on_error);
7477 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7478 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7479 assert!(!default.watch_poll && !default.watch_exit_on_error);
7480 for orphan in [
7481 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7482 vec![s("--watch-poll"), s("a.md")],
7483 vec![s("--watch-exit-on-error"), s("a.md")],
7484 ] {
7485 assert!(parse(&orphan)
7486 .err()
7487 .unwrap()
7488 .contains("only has an effect with --watch"));
7489 }
7490 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7491 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7492 }
7493 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7495 }
7496
7497 #[test]
7498 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7499 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7500 let _ = std::fs::remove_file(&path);
7501 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7502 std::fs::write(&path, "first").unwrap();
7503 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7504 std::fs::write(&path, "second content").unwrap();
7505 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7506 let _ = std::fs::remove_file(&path);
7507 assert_eq!(missing, "missing");
7508 assert_ne!(first, missing);
7509 assert_ne!(first, second);
7510 }
7511
7512 #[test]
7513 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7514 let s = |v: &str| v.to_string();
7515 let cli = parse(&[
7516 s("--no-config"),
7517 s("--batch"),
7518 s("--jobs"),
7519 s("3"),
7520 s("-o"),
7521 s("out.html"),
7522 s("--continue-on-error"),
7523 s("--collision"),
7524 s("suffix"),
7525 s("input"),
7526 ])
7527 .unwrap()
7528 .unwrap();
7529 assert!(cli.batch);
7530 assert_eq!(cli.jobs, 3);
7531 assert!(cli.continue_on_error);
7532 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7533 }
7534
7535 #[test]
7536 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7537 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7538 let _ = std::fs::remove_dir_all(&root);
7539 let nested = root.join("nested");
7540 std::fs::create_dir_all(&nested).unwrap();
7541 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7542 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7543
7544 let root_arg = root.to_string_lossy().into_owned();
7545 let glob_arg = root
7546 .join("*richbatchonly.md")
7547 .to_string_lossy()
7548 .into_owned();
7549 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7550 assert_eq!(inputs.len(), 2);
7551 assert!(inputs[0].ends_with("richbatchnested.md"));
7552 assert!(inputs[1].ends_with("richbatchonly.md"));
7553
7554 std::fs::remove_dir_all(root).unwrap();
7555 }
7556
7557 #[test]
7558 fn batch_output_suffixes_are_deterministic_for_collisions() {
7559 let inputs = ["one.md", "two.md"];
7560 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7561 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7562 assert_eq!(first, "one.html");
7563 assert_eq!(second, "two.html");
7564 assert_eq!(
7565 Path::new("one.html")
7566 .with_file_name("one-2.html")
7567 .to_string_lossy(),
7568 "one-2.html"
7569 );
7570 }
7571
7572 #[test]
7573 fn config_profiles_support_profiles_section_and_source_errors() {
7574 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7575 std::fs::create_dir_all(&root).unwrap();
7576 let config = root.join("config.toml");
7577 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7578 let args = vec![
7579 "--config".to_string(),
7580 config.to_string_lossy().into_owned(),
7581 "--profile".to_string(),
7582 "ci".to_string(),
7583 ];
7584 let merged = config_args(
7585 &args,
7586 &ConfigRoots {
7587 home: None,
7588 cwd: root.clone(),
7589 no_color_env: false,
7590 },
7591 )
7592 .unwrap();
7593 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7594 assert!(merged
7595 .windows(2)
7596 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7597
7598 let bad = root.join("bad.toml");
7599 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7600 let error = config_args(
7601 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7602 &ConfigRoots {
7603 home: None,
7604 cwd: root.clone(),
7605 no_color_env: false,
7606 },
7607 )
7608 .unwrap_err();
7609 assert!(error.contains("config "));
7610 assert!(error.contains("unknown key"));
7611 std::fs::remove_dir_all(root).unwrap();
7612 }
7613
7614 #[test]
7615 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7616 let s = |v: &str| v.to_string();
7617 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7618 .err()
7619 .unwrap();
7620 assert!(error.contains("--batch cannot be combined with --diff"));
7621 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7622 .err()
7623 .unwrap();
7624 assert!(error.contains("--batch cannot be combined with --gif"));
7625 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7627 .err()
7628 .unwrap();
7629 assert!(error.contains("--pager cannot be combined with --batch"));
7630 }
7631
7632 #[test]
7633 fn line_counts_follow_python_splitlines() {
7634 assert_eq!(python_line_count(""), 0);
7635 assert_eq!(python_line_count("a"), 1);
7636 assert_eq!(python_line_count("a\n"), 1);
7637 assert_eq!(python_line_count("a\n\n"), 2);
7638 assert_eq!(python_line_count("a\r\nb"), 2);
7639 assert_eq!(
7640 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7641 8
7642 );
7643 }
7644
7645 #[test]
7646 fn glob_matches_metacharacters_rather_than_substrings() {
7647 assert!(glob_match("*.md", "notes.md"));
7648 assert!(!glob_match("*.md", "notes.md.bak"));
7650 assert!(glob_match("a?c.md", "abc.md"));
7651 assert!(!glob_match("a?c.md", "ac.md"));
7652 assert!(glob_match("*", "anything"));
7653 assert!(glob_match("a*b*c", "axxbyyc"));
7654 assert!(!glob_match("a*b*c", "axxbyy"));
7655 }
7656
7657 #[test]
7658 fn batch_expansion_skips_symlinked_directories() {
7659 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7660 let _ = std::fs::remove_dir_all(&root);
7661 std::fs::create_dir_all(&root).unwrap();
7662 std::fs::write(root.join("a.md"), "# a").unwrap();
7663 #[cfg(unix)]
7665 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7666 #[cfg(windows)]
7667 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7668 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7669 if linked {
7670 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7671 }
7672 assert!(inputs[0].ends_with("a.md"));
7673 let _ = std::fs::remove_dir_all(&root);
7674 }
7675
7676 #[test]
7677 fn subcommand_mode_wins_over_config_mode_default() {
7678 let s = |v: &str| v.to_string();
7679 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7680 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7681 assert!(!selects_mode_explicitly(&[
7683 s("--title"),
7684 s("json"),
7685 s("file")
7686 ]));
7687 assert!(!selects_mode_explicitly(&[s("file.md")]));
7688
7689 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7690 let _ = std::fs::remove_dir_all(&root);
7691 std::fs::create_dir_all(&root).unwrap();
7692 let config = root.join("config.toml");
7693 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7694 let merged = config_args(
7695 &[
7696 "--config".to_string(),
7697 config.to_string_lossy().into_owned(),
7698 "json".to_string(),
7699 "file.json".to_string(),
7700 ],
7701 &ConfigRoots {
7702 home: None,
7703 cwd: root.clone(),
7704 no_color_env: false,
7705 },
7706 )
7707 .unwrap();
7708 assert!(
7709 !merged.iter().any(|arg| arg == "--markdown"),
7710 "config mode must not override an explicit subcommand: {merged:?}"
7711 );
7712 let _ = std::fs::remove_dir_all(&root);
7713 }
7714
7715 #[test]
7716 fn suffix_collisions_step_past_files_already_on_disk() {
7717 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7718 let _ = std::fs::remove_dir_all(&root);
7719 std::fs::create_dir_all(&root).unwrap();
7720 let target = root.join("out.html");
7721 std::fs::write(&target, "existing").unwrap();
7722 let target = target.to_string_lossy().into_owned();
7723
7724 let cli = parse(&[
7725 "--no-config".to_string(),
7726 "--batch".to_string(),
7727 "--collision".to_string(),
7728 "suffix".to_string(),
7729 "x".to_string(),
7730 ])
7731 .unwrap()
7732 .unwrap();
7733 let mut taken = std::collections::BTreeSet::new();
7734 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7735 assert!(
7736 first.ends_with("out-2.html"),
7737 "existing file must be kept: {first}"
7738 );
7739 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7740 assert!(second.ends_with("out-3.html"));
7741
7742 let mut cli_error = cli.clone();
7744 cli_error.collision = CollisionPolicy::Error;
7745 let mut taken = std::collections::BTreeSet::new();
7746 let (class, message) =
7747 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7748 assert_eq!(class, ExitClass::Input);
7749 assert!(message.contains("--overwrite"));
7750
7751 let mut cli_overwrite = cli.clone();
7753 cli_overwrite.overwrite = true;
7754 let mut taken = std::collections::BTreeSet::new();
7755 assert_eq!(
7756 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7757 target
7758 );
7759 let _ = std::fs::remove_dir_all(&root);
7760 }
7761
7762 #[test]
7763 fn batch_outputs_keep_their_export_kind() {
7764 let outputs = BatchOutputs {
7766 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7767 ..BatchOutputs::default()
7768 };
7769 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7770 assert!(outputs.svg.is_none());
7771 }
7772
7773 #[test]
7774 fn parses_export_flags() {
7775 let s = |v: &str| v.to_string();
7776 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7778 .unwrap()
7779 .unwrap();
7780 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7781 assert!(cli.export_html.is_none());
7782 assert_eq!(cli.resource.as_deref(), Some("x"));
7783
7784 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7785 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7786
7787 let cli = parse(&[
7789 s("--export-html"),
7790 s("a.html"),
7791 s("--export-svg"),
7792 s("b.svg"),
7793 s("x"),
7794 ])
7795 .unwrap()
7796 .unwrap();
7797 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7798 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7799
7800 assert!(parse(&[s("--export-html")]).is_err());
7802 }
7804
7805 #[test]
7806 fn is_number_matches_pattern() {
7807 for ok in [
7812 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7813 ] {
7814 assert!(is_number(ok), "{ok:?} should be numeric");
7815 }
7816 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7817 assert!(!is_number(no), "{no:?} should not be numeric");
7818 }
7819 }
7820
7821 #[test]
7822 fn render_csv_matches_upstream() {
7823 let table = build_csv_table(
7827 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7828 Some(','),
7829 None,
7830 None,
7831 (None, None),
7832 )
7833 .expect("the sniffer reads this one");
7834 let out = Console::builder()
7835 .force_terminal(true)
7836 .color_system(Some(ColorSystem::Truecolor))
7837 .width(40)
7838 .no_color(false)
7839 .build()
7840 .render_to_string(&table);
7841 let expected = concat!(
7842 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7843 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7844 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7845 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7846 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7847 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7848 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7849 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7850 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7851 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7852 );
7853 assert_eq!(out, expected);
7854 }
7855}