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