1use crate::target::{CapabilityOrigin, DetectedCapabilities, TargetObservations};
57use rich::protocol::{Support, TargetCapabilities};
58use rich::{ColorSystem, Console, ConsoleOptions, Renderable, Segment, Table, Text};
59use std::collections::BTreeMap;
60use std::io::IsTerminal;
61
62pub trait Environment {
65 fn var(&self, name: &str) -> Option<String>;
67 fn is_terminal(&self) -> bool;
69 fn size(&self) -> Option<(usize, usize)>;
71 fn is_windows(&self) -> bool {
73 false
74 }
75 fn cell_pixels(&self) -> Option<(u16, u16)> {
79 None
80 }
81}
82
83#[derive(Clone, Copy, Debug, Default)]
86pub struct SystemEnvironment;
87
88impl Environment for SystemEnvironment {
89 fn var(&self, name: &str) -> Option<String> {
90 std::env::var(name).ok()
91 }
92 fn is_terminal(&self) -> bool {
93 std::io::stdout().is_terminal()
94 }
95 fn size(&self) -> Option<(usize, usize)> {
96 if !self.is_terminal() {
99 return None;
100 }
101 let console = Console::builder().force_terminal(true).build();
102 Some((console.width(), console.height()))
103 }
104 fn is_windows(&self) -> bool {
105 cfg!(windows)
106 }
107 fn cell_pixels(&self) -> Option<(u16, u16)> {
108 if !self.is_terminal() {
109 return None;
110 }
111 let (cells, pixels) = crate::graphics::stdout_window()?;
112 crate::graphics::CellPixels::from_window(pixels, cells).map(|c| (c.width, c.height))
113 }
114}
115
116#[derive(Clone, Debug, Default)]
118pub struct MapEnvironment {
119 pub vars: BTreeMap<String, String>,
120 pub terminal: bool,
121 pub size: Option<(usize, usize)>,
122 pub windows: bool,
123 pub cell_pixels: Option<(u16, u16)>,
125}
126
127impl MapEnvironment {
128 pub fn new() -> Self {
130 Self::default()
131 }
132 pub fn tty() -> Self {
134 Self {
135 terminal: true,
136 ..Self::default()
137 }
138 }
139 #[must_use]
141 pub fn var(mut self, name: &str, value: &str) -> Self {
142 self.vars.insert(name.into(), value.into());
143 self
144 }
145 #[must_use]
147 pub fn terminal(mut self, terminal: bool) -> Self {
148 self.terminal = terminal;
149 self
150 }
151 #[must_use]
153 pub fn size(mut self, width: usize, height: usize) -> Self {
154 self.size = Some((width, height));
155 self
156 }
157 #[must_use]
159 pub fn windows(mut self, windows: bool) -> Self {
160 self.windows = windows;
161 self
162 }
163 #[must_use]
165 pub fn cell_pixels(mut self, width: u16, height: u16) -> Self {
166 self.cell_pixels = Some((width, height));
167 self
168 }
169}
170
171impl Environment for MapEnvironment {
172 fn var(&self, name: &str) -> Option<String> {
173 self.vars.get(name).cloned()
174 }
175 fn is_terminal(&self) -> bool {
176 self.terminal
177 }
178 fn size(&self) -> Option<(usize, usize)> {
179 self.size
180 }
181 fn is_windows(&self) -> bool {
182 self.windows
183 }
184 fn cell_pixels(&self) -> Option<(u16, u16)> {
185 self.cell_pixels
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Hash)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize))]
192#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
193pub enum Origin {
194 Override,
196 Environment(String),
198 Default,
200 Inferred,
202}
203
204impl std::fmt::Display for Origin {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 match self {
207 Origin::Override => f.write_str("override"),
208 Origin::Environment(name) => write!(f, "environment ({name})"),
209 Origin::Default => f.write_str("default"),
210 Origin::Inferred => f.write_str("inferred"),
211 }
212 }
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218pub struct Field<T> {
219 pub value: T,
220 pub origin: Origin,
221 pub reason: String,
222}
223
224impl<T> Field<T> {
225 pub fn new(value: T, origin: Origin, reason: impl Into<String>) -> Self {
227 Self {
228 value,
229 origin,
230 reason: reason.into(),
231 }
232 }
233}
234
235#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
237#[cfg_attr(feature = "serde", derive(serde::Serialize))]
238#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
239pub enum ColorDepth {
240 None,
241 Ansi16,
242 Ansi256,
243 TrueColor,
244}
245
246impl ColorDepth {
247 pub fn color_system(self) -> Option<ColorSystem> {
249 match self {
250 ColorDepth::None => None,
251 ColorDepth::Ansi16 => Some(ColorSystem::Standard),
252 ColorDepth::Ansi256 => Some(ColorSystem::EightBit),
253 ColorDepth::TrueColor => Some(ColorSystem::Truecolor),
254 }
255 }
256 pub fn from_color_system(system: Option<ColorSystem>) -> Self {
258 match system {
259 None => ColorDepth::None,
260 Some(ColorSystem::Standard | ColorSystem::Windows) => ColorDepth::Ansi16,
261 Some(ColorSystem::EightBit) => ColorDepth::Ansi256,
262 Some(ColorSystem::Truecolor) => ColorDepth::TrueColor,
263 }
264 }
265 pub fn parse(value: &str) -> Option<Self> {
267 match value.trim().to_ascii_lowercase().as_str() {
268 "none" | "0" | "no" | "off" | "false" => Some(ColorDepth::None),
269 "16" | "standard" | "ansi" => Some(ColorDepth::Ansi16),
270 "256" | "8bit" | "eightbit" => Some(ColorDepth::Ansi256),
271 "truecolor" | "24bit" | "16m" => Some(ColorDepth::TrueColor),
272 _ => None,
273 }
274 }
275 pub fn name(self) -> &'static str {
277 match self {
278 ColorDepth::None => "none",
279 ColorDepth::Ansi16 => "16",
280 ColorDepth::Ansi256 => "256",
281 ColorDepth::TrueColor => "truecolor",
282 }
283 }
284}
285
286#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
288#[cfg_attr(feature = "serde", derive(serde::Serialize))]
289#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
290pub enum Graphics {
291 None,
292 Sixel,
293 Kitty,
294 Iterm,
295}
296
297impl Graphics {
298 pub fn parse(value: &str) -> Option<Self> {
300 match value.trim().to_ascii_lowercase().as_str() {
301 "none" | "0" | "no" | "off" | "false" => Some(Graphics::None),
302 "sixel" => Some(Graphics::Sixel),
303 "kitty" => Some(Graphics::Kitty),
304 "iterm" | "iterm2" => Some(Graphics::Iterm),
305 _ => None,
306 }
307 }
308 pub fn name(self) -> &'static str {
310 match self {
311 Graphics::None => "none",
312 Graphics::Sixel => "sixel",
313 Graphics::Kitty => "kitty",
314 Graphics::Iterm => "iterm",
315 }
316 }
317}
318
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
321pub struct Overrides {
322 pub color: Option<ColorDepth>,
323 pub unicode: Option<bool>,
324 pub hyperlinks: Option<bool>,
325 pub graphics: Option<Graphics>,
326 pub sixel: Option<bool>,
327 pub animation: Option<bool>,
328 pub interactive: Option<bool>,
329 pub width: Option<usize>,
330 pub height: Option<usize>,
331}
332
333pub const OVERRIDE_VARS: &[&str] = &[
335 "RICH_COLOR",
336 "RICH_UNICODE",
337 "RICH_HYPERLINKS",
338 "RICH_GRAPHICS",
339 "RICH_SIXEL",
340 "RICH_ANIMATION",
341 "RICH_WIDTH",
342 "RICH_HEIGHT",
343];
344
345pub fn parse_bool(value: &str) -> Option<bool> {
347 match value.trim().to_ascii_lowercase().as_str() {
348 "1" | "true" | "yes" | "on" => Some(true),
349 "0" | "false" | "no" | "off" => Some(false),
350 _ => None,
351 }
352}
353
354#[derive(Clone, Debug, PartialEq, Eq)]
356#[cfg_attr(feature = "serde", derive(serde::Serialize))]
357pub struct Report {
358 pub color: Field<ColorDepth>,
359 pub unicode: Field<bool>,
360 pub hyperlinks: Field<bool>,
361 pub graphics: Field<Graphics>,
362 pub sixel: Field<bool>,
363 pub width: Field<usize>,
364 pub height: Field<usize>,
365 pub interactive: Field<bool>,
366 pub animation: Field<bool>,
367 pub terminal: Option<String>,
369 pub ci: Option<String>,
371 pub warnings: Vec<String>,
373}
374
375#[derive(Clone, Copy, Debug, Default)]
377pub struct Capabilities;
378
379const CI_PROVIDERS: &[(&str, &str, ColorDepth)] = &[
380 ("GITHUB_ACTIONS", "GitHub Actions", ColorDepth::TrueColor),
381 ("GITEA_ACTIONS", "Gitea Actions", ColorDepth::TrueColor),
382 ("GITLAB_CI", "GitLab CI", ColorDepth::Ansi16),
383 ("BUILDKITE", "Buildkite", ColorDepth::Ansi16),
384 ("CIRCLECI", "CircleCI", ColorDepth::Ansi16),
385 ("TRAVIS", "Travis CI", ColorDepth::Ansi16),
386 ("APPVEYOR", "AppVeyor", ColorDepth::Ansi16),
387 ("DRONE", "Drone", ColorDepth::Ansi16),
388 ("TF_BUILD", "Azure Pipelines", ColorDepth::Ansi16),
389 ("TEAMCITY_VERSION", "TeamCity", ColorDepth::Ansi16),
390 ("JENKINS_URL", "Jenkins", ColorDepth::None),
391 (
392 "BITBUCKET_BUILD_NUMBER",
393 "Bitbucket Pipelines",
394 ColorDepth::None,
395 ),
396];
397
398struct Probe<'a> {
399 env: &'a dyn Environment,
400}
401
402impl Probe<'_> {
403 fn get(&self, name: &str) -> Option<String> {
404 self.env.var(name).filter(|v| !v.is_empty())
405 }
406 fn term(&self) -> String {
407 self.get("TERM").unwrap_or_default().to_ascii_lowercase()
408 }
409 fn program(&self) -> String {
410 self.get("TERM_PROGRAM").unwrap_or_default()
411 }
412 fn kitty(&self) -> Option<String> {
413 if self.term() == "xterm-kitty" {
414 Some("TERM=xterm-kitty".into())
415 } else {
416 self.get("KITTY_WINDOW_ID")
417 .map(|_| "KITTY_WINDOW_ID".into())
418 }
419 }
420 fn ci(&self) -> Option<(&'static str, &'static str, ColorDepth)> {
421 CI_PROVIDERS
422 .iter()
423 .find(|(var, _, _)| self.get(var).is_some_and(|v| v != "false"))
424 .copied()
425 .or_else(|| {
426 self.get("CI")
427 .filter(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false"))
428 .map(|_| ("CI", "CI", ColorDepth::None))
429 })
430 }
431 fn terminal(&self) -> Option<String> {
432 if self.get("WT_SESSION").is_some() {
433 return Some("Windows Terminal".into());
434 }
435 if self.kitty().is_some() {
436 return Some("kitty".into());
437 }
438 if let Some(p) = self.get("TERM_PROGRAM") {
439 return Some(p);
440 }
441 self.get("VTE_VERSION").map(|v| format!("VTE {v}"))
442 }
443 fn sixel(&self) -> Option<String> {
444 if self.get("WT_SESSION").is_some() {
445 return Some("WT_SESSION".into());
446 }
447 let term = self.term();
448 if ["sixel", "mlterm", "foot"].iter().any(|t| term.contains(t)) {
449 return Some(format!("TERM={}", self.get("TERM").unwrap_or_default()));
450 }
451 let program = self.program().to_ascii_lowercase();
452 if program.contains("wezterm") || program.contains("mintty") {
453 return Some(format!("TERM_PROGRAM={}", self.program()));
454 }
455 None
456 }
457 fn reduced_motion(&self) -> Option<String> {
458 let list = self.get("RICH_A11Y")?;
459 list.split(',')
460 .map(|item| item.trim().to_ascii_lowercase())
461 .find(|item| {
462 matches!(
463 item.as_str(),
464 "reduced-motion" | "no-animation" | "screen-reader"
465 )
466 })
467 .map(|item| format!("RICH_A11Y={item}"))
468 }
469}
470
471fn from_var(name: &str) -> Origin {
472 Origin::Environment(name.into())
473}
474
475impl Capabilities {
476 pub fn detect(env: &dyn Environment) -> Report {
478 Self::detect_with(env, &Overrides::default())
479 }
480
481 pub fn system() -> Report {
483 Self::detect(&SystemEnvironment)
484 }
485
486 pub fn detect_with(env: &dyn Environment, overrides: &Overrides) -> Report {
488 let p = Probe { env };
489 let mut warnings = Vec::new();
490 let tty = env.is_terminal();
491 let term = p.term();
492 let program = p.program();
493 let ci = p.ci();
494
495 fn parsed<T>(
497 p: &Probe<'_>,
498 name: &str,
499 parse: impl Fn(&str) -> Option<T>,
500 warnings: &mut Vec<String>,
501 ) -> Option<Field<T>> {
502 let raw = p.get(name)?;
503 match parse(&raw) {
504 Some(value) => Some(Field::new(value, from_var(name), format!("{name}={raw}"))),
505 None => {
506 warnings.push(format!("ignored {name}={raw:?}: unrecognised value"));
507 None
508 }
509 }
510 }
511 let size = |v: &str| v.trim().parse::<usize>().ok().filter(|v| *v > 0);
512
513 let color = parsed(&p, "RICH_COLOR", ColorDepth::parse, &mut warnings)
515 .unwrap_or_else(|| detect_color(&p, tty, &term, &program, ci, env.is_windows()));
516
517 let unicode = parsed(&p, "RICH_UNICODE", parse_bool, &mut warnings).unwrap_or_else(|| {
519 for name in ["LC_ALL", "LC_CTYPE", "LANG"] {
520 if let Some(value) = p.get(name) {
521 let lower = value.to_ascii_lowercase();
522 let charset = lower.split('@').next().unwrap_or("");
523 let reason = format!("{name}={value}");
524 return if charset.contains("utf-8") || charset.contains("utf8") {
525 Field::new(true, from_var(name), reason)
526 } else if matches!(charset, "c" | "posix") {
527 Field::new(true, from_var(name), format!("{reason} (coerced to UTF-8)"))
528 } else {
529 Field::new(false, from_var(name), format!("{reason} is not UTF-8"))
530 };
531 }
532 }
533 if p.get("WT_SESSION").is_some() {
534 return Field::new(true, from_var("WT_SESSION"), "Windows Terminal");
535 }
536 Field::new(true, Origin::Default, "no locale set; UTF-8 assumed")
537 });
538
539 let hyperlinks = parsed(&p, "RICH_HYPERLINKS", parse_bool, &mut warnings)
541 .unwrap_or_else(|| detect_hyperlinks(&p, tty, &term, &program));
542
543 let sixel_alias = parsed(&p, "RICH_SIXEL", parse_bool, &mut warnings);
545 let graphics_var = parsed(&p, "RICH_GRAPHICS", Graphics::parse, &mut warnings);
546 let sixel = if let Some(g) = &graphics_var {
547 Field::new(
548 g.value == Graphics::Sixel,
549 g.origin.clone(),
550 g.reason.clone(),
551 )
552 } else if let Some(s) = &sixel_alias {
553 s.clone()
554 } else if !tty {
555 Field::new(false, Origin::Inferred, "stdout is not a terminal")
556 } else if let Some(reason) = p.sixel() {
557 let origin = from_var(reason.split('=').next().unwrap_or(&reason));
558 Field::new(true, origin, reason)
559 } else {
560 Field::new(
561 false,
562 Origin::Default,
563 "no sixel-capable terminal identified",
564 )
565 };
566 let graphics = if let Some(g) = graphics_var {
567 g
568 } else if let Some(s) = sixel_alias {
569 let value = if s.value {
570 Graphics::Sixel
571 } else {
572 Graphics::None
573 };
574 Field::new(value, s.origin, s.reason)
575 } else if !tty {
576 Field::new(Graphics::None, Origin::Inferred, "stdout is not a terminal")
577 } else if let Some(reason) = p.kitty() {
578 let origin = from_var(reason.split('=').next().unwrap_or(&reason));
579 Field::new(Graphics::Kitty, origin, reason)
580 } else if program == "iTerm.app" || program.eq_ignore_ascii_case("wezterm") {
581 Field::new(
582 Graphics::Iterm,
583 from_var("TERM_PROGRAM"),
584 format!("TERM_PROGRAM={program}"),
585 )
586 } else if sixel.value {
587 Field::new(Graphics::Sixel, sixel.origin.clone(), sixel.reason.clone())
588 } else {
589 Field::new(
590 Graphics::None,
591 Origin::Default,
592 "no graphics protocol identified",
593 )
594 };
595
596 let dimension =
598 |rich: &str, var: &str, index: usize, default: usize, w: &mut Vec<String>| {
599 parsed(&p, rich, size, w)
600 .or_else(|| parsed(&p, var, size, w))
601 .unwrap_or_else(|| match env.size() {
602 Some(s) => {
603 let v = if index == 0 { s.0 } else { s.1 };
604 Field::new(v, Origin::Inferred, "terminal size")
605 }
606 None => Field::new(default, Origin::Default, format!("default {default}")),
607 })
608 };
609 let width = dimension("RICH_WIDTH", "COLUMNS", 0, 80, &mut warnings);
610 let height = dimension("RICH_HEIGHT", "LINES", 1, 25, &mut warnings);
611
612 let interactive = if tty {
613 Field::new(true, Origin::Inferred, "stdout is a terminal")
614 } else {
615 Field::new(false, Origin::Inferred, "stdout is not a terminal")
616 };
617
618 let mut report = Report {
619 color,
620 unicode,
621 hyperlinks,
622 graphics,
623 sixel,
624 width,
625 height,
626 interactive,
627 animation: Field::new(false, Origin::Default, ""),
628 terminal: p.terminal(),
629 ci: ci.map(|(_, name, _)| name.to_owned()),
630 warnings,
631 };
632 report.apply(overrides);
633 let animation_var = parsed(&p, "RICH_ANIMATION", parse_bool, &mut report.warnings);
634 report.animation = match (overrides.animation, animation_var) {
635 (Some(v), _) => Field::new(v, Origin::Override, "override"),
636 (None, Some(field)) => field,
637 (None, None) => {
638 if !report.interactive.value {
639 Field::new(false, Origin::Inferred, "not interactive")
640 } else if let Some((var, name, _)) = ci {
641 Field::new(false, from_var(var), format!("running in {name}"))
642 } else if term == "dumb" {
643 Field::new(false, from_var("TERM"), "TERM=dumb")
644 } else if let Some(reason) = p.reduced_motion() {
645 Field::new(false, from_var("RICH_A11Y"), reason)
646 } else {
647 Field::new(true, Origin::Inferred, "interactive terminal")
648 }
649 }
650 };
651 report.escape_controls();
652 report
653 }
654}
655
656fn escape_controls(text: &str) -> String {
659 if !text
660 .chars()
661 .any(|c| c.is_control() || crate::sanitize::is_bidi_control(c))
662 {
663 return text.to_owned();
664 }
665 let mut out = String::with_capacity(text.len() + 8);
666 for c in text.chars() {
667 if c.is_control() || crate::sanitize::is_bidi_control(c) {
668 out.push_str(&format!("\\u{{{:x}}}", c as u32));
669 } else {
670 out.push(c);
671 }
672 }
673 out
674}
675
676impl Report {
677 fn escape_controls(&mut self) {
679 for reason in [
680 &mut self.color.reason,
681 &mut self.unicode.reason,
682 &mut self.hyperlinks.reason,
683 &mut self.graphics.reason,
684 &mut self.sixel.reason,
685 &mut self.width.reason,
686 &mut self.height.reason,
687 &mut self.interactive.reason,
688 &mut self.animation.reason,
689 ] {
690 *reason = escape_controls(reason);
691 }
692 for warning in &mut self.warnings {
693 *warning = escape_controls(warning);
694 }
695 for value in [&mut self.terminal, &mut self.ci].into_iter().flatten() {
696 *value = escape_controls(value);
697 }
698 }
699}
700
701fn detect_color(
702 p: &Probe<'_>,
703 tty: bool,
704 term: &str,
705 program: &str,
706 ci: Option<(&str, &str, ColorDepth)>,
707 windows: bool,
708) -> Field<ColorDepth> {
709 use ColorDepth::*;
710 if let Some(v) = p.get("NO_COLOR") {
711 return Field::new(None, from_var("NO_COLOR"), format!("NO_COLOR={v}"));
712 }
713 let force = p.get("FORCE_COLOR");
715 if let Some(force) = &force {
716 let reason = format!("FORCE_COLOR={force}");
717 match force.trim().to_ascii_lowercase().as_str() {
718 "0" | "false" => return Field::new(None, from_var("FORCE_COLOR"), reason),
719 "1" => return Field::new(Ansi16, from_var("FORCE_COLOR"), reason),
720 "2" => return Field::new(Ansi256, from_var("FORCE_COLOR"), reason),
721 "3" => return Field::new(TrueColor, from_var("FORCE_COLOR"), reason),
722 _ => {}
723 }
724 }
725 if !tty && force.is_none() {
726 if let Some((var, name, depth)) = ci.filter(|c| c.2 != None) {
727 return Field::new(depth, from_var(var), format!("{name} renders ANSI logs"));
728 }
729 return Field::new(None, Origin::Inferred, "stdout is not a terminal");
730 }
731 if term == "dumb" {
732 return Field::new(None, from_var("TERM"), "TERM=dumb");
733 }
734 let terminal = detect_terminal_depth(p, term, program, windows);
735 if let Some(force) = force {
736 let depth = terminal.value.max(Ansi16);
738 let reason = if terminal.value >= Ansi16 && terminal.origin != Origin::Default {
739 terminal.reason
740 } else {
741 format!("FORCE_COLOR={force}")
742 };
743 return Field::new(depth, from_var("FORCE_COLOR"), reason);
744 }
745 terminal
746}
747
748fn detect_terminal_depth(
749 p: &Probe<'_>,
750 term: &str,
751 program: &str,
752 windows: bool,
753) -> Field<ColorDepth> {
754 use ColorDepth::*;
755 if let Some(v) = p.get("COLORTERM") {
756 let lower = v.to_ascii_lowercase();
757 if lower.contains("truecolor") || lower.contains("24bit") {
758 return Field::new(TrueColor, from_var("COLORTERM"), format!("COLORTERM={v}"));
759 }
760 }
761 if p.get("WT_SESSION").is_some() {
762 return Field::new(TrueColor, from_var("WT_SESSION"), "Windows Terminal");
763 }
764 if let Some(reason) = p.kitty() {
765 let origin = from_var(reason.split('=').next().unwrap_or(&reason));
766 return Field::new(TrueColor, origin, reason);
767 }
768 if ["iTerm.app", "WezTerm", "vscode", "ghostty"]
769 .iter()
770 .any(|t| program.eq_ignore_ascii_case(t))
771 {
772 return Field::new(
773 TrueColor,
774 from_var("TERM_PROGRAM"),
775 format!("TERM_PROGRAM={program}"),
776 );
777 }
778 let raw_term = p.get("TERM").unwrap_or_default();
779 if term.contains("direct") || term.contains("truecolor") {
780 return Field::new(TrueColor, from_var("TERM"), format!("TERM={raw_term}"));
781 }
782 if program == "Apple_Terminal" {
783 return Field::new(
784 Ansi256,
785 from_var("TERM_PROGRAM"),
786 "TERM_PROGRAM=Apple_Terminal",
787 );
788 }
789 if term.contains("256") {
790 return Field::new(Ansi256, from_var("TERM"), format!("TERM={raw_term}"));
791 }
792 if windows {
793 return Field::new(TrueColor, Origin::Inferred, "Windows console");
794 }
795 if !term.is_empty() {
796 return Field::new(Ansi16, from_var("TERM"), format!("TERM={raw_term}"));
797 }
798 Field::new(Ansi16, Origin::Default, "terminal with no TERM")
799}
800
801fn detect_hyperlinks(p: &Probe<'_>, tty: bool, term: &str, program: &str) -> Field<bool> {
802 if !tty {
803 return Field::new(false, Origin::Inferred, "stdout is not a terminal");
804 }
805 if term == "dumb" {
806 return Field::new(false, from_var("TERM"), "TERM=dumb");
807 }
808 if p.get("TMUX").is_some() || term.starts_with("screen") || term.starts_with("tmux") {
809 let origin = if p.get("TMUX").is_some() {
810 from_var("TMUX")
811 } else {
812 from_var("TERM")
813 };
814 return Field::new(false, origin, "inside a multiplexer");
815 }
816 if p.get("WT_SESSION").is_some() {
817 return Field::new(true, from_var("WT_SESSION"), "Windows Terminal");
818 }
819 if let Some(reason) = p.kitty() {
820 let origin = from_var(reason.split('=').next().unwrap_or(&reason));
821 return Field::new(true, origin, reason);
822 }
823 if [
824 "iTerm.app",
825 "WezTerm",
826 "vscode",
827 "ghostty",
828 "contour",
829 "DomTerm",
830 ]
831 .iter()
832 .any(|t| program.eq_ignore_ascii_case(t))
833 {
834 return Field::new(
835 true,
836 from_var("TERM_PROGRAM"),
837 format!("TERM_PROGRAM={program}"),
838 );
839 }
840 if let Some(v) = p.get("VTE_VERSION") {
841 let ok = v.trim().parse::<u32>().is_ok_and(|n| n >= 5000);
842 let reason = format!("VTE_VERSION={v}");
843 return Field::new(ok, from_var("VTE_VERSION"), reason);
844 }
845 if ["foot", "contour", "xterm-ghostty"]
846 .iter()
847 .any(|t| term.starts_with(t))
848 {
849 return Field::new(true, from_var("TERM"), format!("TERM={term}"));
850 }
851 if p.get("DOMTERM").is_some() {
852 return Field::new(true, from_var("DOMTERM"), "DOMTERM");
853 }
854 Field::new(
855 false,
856 Origin::Default,
857 "terminal not known to support OSC 8",
858 )
859}
860
861impl Report {
862 pub fn apply(&mut self, o: &Overrides) {
864 fn set<T>(field: &mut Field<T>, value: Option<T>) {
865 if let Some(value) = value {
866 *field = Field::new(value, Origin::Override, "override");
867 }
868 }
869 set(&mut self.color, o.color);
870 set(&mut self.unicode, o.unicode);
871 set(&mut self.hyperlinks, o.hyperlinks);
872 if let Some(g) = o.graphics {
873 set(&mut self.graphics, Some(g));
874 if o.sixel.is_none() {
875 set(&mut self.sixel, Some(g == Graphics::Sixel));
876 }
877 }
878 set(&mut self.sixel, o.sixel);
879 set(&mut self.animation, o.animation);
880 set(&mut self.interactive, o.interactive);
881 set(&mut self.width, o.width);
882 set(&mut self.height, o.height);
883 }
884
885 pub fn to_target_capabilities(&self) -> TargetCapabilities {
889 TargetCapabilities {
890 width: self.width.value,
891 height: self.height.value,
892 color_system: self.color.value.color_system(),
893 interactive: self.interactive.value,
894 unicode: self.unicode.value,
895 hyperlinks: self.hyperlinks.value,
896 sixel: self.sixel_support(),
897 }
898 }
899
900 fn sixel_support(&self) -> Support {
901 match (&self.sixel.value, &self.sixel.origin) {
902 (false, _) => Support::Unsupported,
903 (true, Origin::Override) => Support::Confirmed,
904 (true, Origin::Environment(name)) if name.starts_with("RICH_") => Support::Confirmed,
905 (true, _) => Support::Inferred,
906 }
907 }
908
909 pub fn observations(&self) -> TargetObservations {
912 TargetObservations {
913 width: Some(self.width.value),
914 height: Some(self.height.value),
915 is_terminal: self.interactive.value,
916 color_system: self.color.value.color_system(),
917 unicode: self.unicode.value,
918 hyperlinks: self.hyperlinks.value,
919 sixel_hint: self.sixel_support(),
920 }
921 }
922
923 pub fn to_detected(&self) -> DetectedCapabilities {
925 fn origin(o: &Origin) -> CapabilityOrigin {
926 match o {
927 Origin::Override => CapabilityOrigin::Configured,
928 Origin::Environment(name) if name.starts_with("RICH_") => {
929 CapabilityOrigin::Configured
930 }
931 Origin::Environment(_) => CapabilityOrigin::Detected,
932 Origin::Default => CapabilityOrigin::Default,
933 Origin::Inferred => CapabilityOrigin::Inferred,
934 }
935 }
936 let origins = [
937 ("width", &self.width.origin),
938 ("height", &self.height.origin),
939 ("color_system", &self.color.origin),
940 ("interactive", &self.interactive.origin),
941 ("unicode", &self.unicode.origin),
942 ("hyperlinks", &self.hyperlinks.origin),
943 ("sixel", &self.sixel.origin),
944 ]
945 .into_iter()
946 .map(|(name, o)| (name.to_owned(), origin(o)))
947 .collect();
948 DetectedCapabilities {
949 capabilities: self.to_target_capabilities(),
950 origins,
951 }
952 }
953
954 pub fn rows(&self) -> Vec<(&'static str, String, &Origin, &str)> {
956 let yes = |b: bool| if b { "yes" } else { "no" }.to_owned();
957 vec![
958 (
959 "color",
960 self.color.value.name().into(),
961 &self.color.origin,
962 &self.color.reason,
963 ),
964 (
965 "unicode",
966 yes(self.unicode.value),
967 &self.unicode.origin,
968 &self.unicode.reason,
969 ),
970 (
971 "hyperlinks",
972 yes(self.hyperlinks.value),
973 &self.hyperlinks.origin,
974 &self.hyperlinks.reason,
975 ),
976 (
977 "graphics",
978 self.graphics.value.name().into(),
979 &self.graphics.origin,
980 &self.graphics.reason,
981 ),
982 (
983 "sixel",
984 yes(self.sixel.value),
985 &self.sixel.origin,
986 &self.sixel.reason,
987 ),
988 (
989 "width",
990 self.width.value.to_string(),
991 &self.width.origin,
992 &self.width.reason,
993 ),
994 (
995 "height",
996 self.height.value.to_string(),
997 &self.height.origin,
998 &self.height.reason,
999 ),
1000 (
1001 "interactive",
1002 yes(self.interactive.value),
1003 &self.interactive.origin,
1004 &self.interactive.reason,
1005 ),
1006 (
1007 "animation",
1008 yes(self.animation.value),
1009 &self.animation.origin,
1010 &self.animation.reason,
1011 ),
1012 ]
1013 }
1014}
1015
1016pub struct CapabilityReport<'a> {
1018 report: &'a Report,
1019}
1020
1021impl<'a> CapabilityReport<'a> {
1022 pub fn new(report: &'a Report) -> Self {
1023 Self { report }
1024 }
1025}
1026
1027impl Renderable for CapabilityReport<'_> {
1028 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1029 let mut table = Table::new();
1030 table.add_column("Capability");
1031 table.add_column("Value");
1032 table.add_column("Source");
1033 for (name, value, origin, reason) in self.report.rows() {
1034 let source = if reason.is_empty() || reason == "override" {
1035 origin.to_string()
1036 } else {
1037 format!("{origin}: {reason}")
1038 };
1039 table.add_row_text(vec![Text::new(name), Text::new(value), Text::new(source)]);
1041 }
1042 let facts = [("terminal", &self.report.terminal), ("ci", &self.report.ci)];
1043 for (name, value) in facts {
1044 if let Some(value) = value {
1045 table.add_row_text(vec![
1046 Text::new(name),
1047 Text::new(value.as_str()),
1048 Text::new("inferred"),
1049 ]);
1050 }
1051 }
1052 for warning in &self.report.warnings {
1053 table.add_row_text(vec![
1054 Text::new("warning"),
1055 Text::new(""),
1056 Text::new(warning.as_str()),
1057 ]);
1058 }
1059 table.rich_render(console, options)
1060 }
1061}