1use bitflags::bitflags;
26
27use crate::terminal::Env;
28
29bitflags! {
30 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43 pub struct Optimizations: u32 {
44 const ECH = 1 << 0;
47 const REP = 1 << 1;
50 const ICH = 1 << 2;
53 const DCH = 1 << 3;
56 const CSR = 1 << 4;
58 const SU_SD = 1 << 5;
61 const IL_DL = 1 << 6;
64 const BCE = 1 << 7;
67 const CHA = 1 << 8;
70 const HPA = 1 << 9;
73 const VPA = 1 << 10;
76 const TABS = 1 << 11;
78 const CBT = 1 << 12;
80 const CHT = 1 << 13;
82 const BS = 1 << 14;
85 const ONLCR = 1 << 15;
89 }
90}
91
92impl Default for Optimizations {
93 fn default() -> Self {
97 Self::xterm()
98 }
99}
100
101impl Optimizations {
102 pub const fn none() -> Self {
109 Self::TABS.union(Self::BS)
110 }
111
112 pub const fn modern() -> Self {
117 Self::ECH
118 .union(Self::REP)
119 .union(Self::ICH)
120 .union(Self::DCH)
121 .union(Self::CSR)
122 .union(Self::SU_SD)
123 .union(Self::IL_DL)
124 .union(Self::BCE)
125 .union(Self::CHA)
126 .union(Self::HPA)
127 .union(Self::VPA)
128 .union(Self::TABS)
129 .union(Self::CBT)
130 .union(Self::CHT)
131 .union(Self::BS)
132 }
133
134 pub const fn xterm() -> Self {
146 Self::modern().difference(Self::HPA.union(Self::CHT).union(Self::REP))
147 }
148
149 pub const fn vt100() -> Self {
156 Self::ICH
157 .union(Self::DCH)
158 .union(Self::CSR)
159 .union(Self::IL_DL)
160 .union(Self::TABS)
161 .union(Self::BS)
162 }
163
164 pub const fn linux() -> Self {
172 Self::ECH
173 .union(Self::ICH)
174 .union(Self::CHA)
175 .union(Self::HPA)
176 .union(Self::VPA)
177 .union(Self::TABS)
178 .union(Self::BS)
179 }
180
181 pub const fn screen() -> Self {
186 Self::ICH
187 .union(Self::DCH)
188 .union(Self::CSR)
189 .union(Self::SU_SD)
190 .union(Self::IL_DL)
191 .union(Self::HPA)
192 .union(Self::VPA)
193 .union(Self::TABS)
194 .union(Self::CBT)
195 .union(Self::BS)
196 }
197
198 #[must_use]
204 pub const fn with_tabs(self, enabled: bool) -> Self {
205 self.with_flag(Self::TABS, enabled)
206 }
207
208 #[must_use]
214 pub const fn with_bs(self, enabled: bool) -> Self {
215 self.with_flag(Self::BS, enabled)
216 }
217
218 #[must_use]
224 pub const fn with_onlcr(self, enabled: bool) -> Self {
225 self.with_flag(Self::ONLCR, enabled)
226 }
227
228 #[must_use]
230 pub const fn with_ech(self, enabled: bool) -> Self {
231 self.with_flag(Self::ECH, enabled)
232 }
233
234 #[must_use]
236 pub const fn with_rep(self, enabled: bool) -> Self {
237 self.with_flag(Self::REP, enabled)
238 }
239
240 #[must_use]
242 pub const fn with_ich(self, enabled: bool) -> Self {
243 self.with_flag(Self::ICH, enabled)
244 }
245
246 #[must_use]
248 pub const fn with_dch(self, enabled: bool) -> Self {
249 self.with_flag(Self::DCH, enabled)
250 }
251
252 #[must_use]
254 pub const fn with_csr(self, enabled: bool) -> Self {
255 self.with_flag(Self::CSR, enabled)
256 }
257
258 #[must_use]
260 pub const fn with_su_sd(self, enabled: bool) -> Self {
261 self.with_flag(Self::SU_SD, enabled)
262 }
263
264 #[must_use]
266 pub const fn with_il_dl(self, enabled: bool) -> Self {
267 self.with_flag(Self::IL_DL, enabled)
268 }
269
270 #[must_use]
272 pub const fn with_bce(self, enabled: bool) -> Self {
273 self.with_flag(Self::BCE, enabled)
274 }
275
276 #[must_use]
278 pub const fn with_cha(self, enabled: bool) -> Self {
279 self.with_flag(Self::CHA, enabled)
280 }
281
282 #[must_use]
284 pub const fn with_hpa(self, enabled: bool) -> Self {
285 self.with_flag(Self::HPA, enabled)
286 }
287
288 #[must_use]
290 pub const fn with_vpa(self, enabled: bool) -> Self {
291 self.with_flag(Self::VPA, enabled)
292 }
293
294 #[must_use]
296 pub const fn with_cbt(self, enabled: bool) -> Self {
297 self.with_flag(Self::CBT, enabled)
298 }
299
300 #[must_use]
302 pub const fn with_cht(self, enabled: bool) -> Self {
303 self.with_flag(Self::CHT, enabled)
304 }
305
306 const fn with_flag(self, flag: Self, enabled: bool) -> Self {
308 if enabled {
309 self.union(flag)
310 } else {
311 self.difference(flag)
312 }
313 }
314
315 pub fn from_term(term: &str) -> Self {
326 let head = term.split('-').next().unwrap_or("");
327 if head == "xterm"
330 && let Some(rest) = term.strip_prefix("xterm-")
331 {
332 let vendor = rest.split('-').next().unwrap_or("");
333 if matches!(vendor, "ghostty" | "kitty" | "rio") {
334 return Self::modern();
335 }
336 }
337 match head {
338 "" | "dumb" => Self::none(),
339 "alacritty" | "contour" | "foot" | "ghostty" | "kitty" | "rio" | "st" | "tmux"
344 | "wezterm" => Self::modern(),
345 "xterm" => Self::xterm(),
346 "screen" => Self::screen(),
347 "linux" => Self::linux(),
348 _ => Self::none(),
349 }
350 }
351
352 pub fn from_env(env: &Env) -> Self {
360 match env.get("TERM") {
361 Some(term) => Self::from_term(&term),
362 None => Self::default(),
363 }
364 }
365
366 pub(crate) fn supports_decst8c(env: &Env) -> bool {
376 if env.has("WT_SESSION") {
379 return true;
380 }
381 if env.has("KITTY_WINDOW_ID")
384 || env.has("ALACRITTY_WINDOW_ID")
385 || env.has("ITERM_SESSION_ID")
386 {
387 return true;
388 }
389 if env
391 .get("LC_TERMINAL")
392 .is_some_and(|t| t.eq_ignore_ascii_case("iterm2"))
393 {
394 return true;
395 }
396 if let Some(program) = env.get("TERM_PROGRAM")
398 && matches!(
399 program.to_ascii_lowercase().as_str(),
400 "ghostty" | "rio" | "iterm.app"
401 )
402 {
403 return true;
404 }
405 let Some(term) = env.get("TERM") else {
406 return false;
407 };
408 let head = term.split('-').next().unwrap_or("");
411 if matches!(head, "alacritty" | "ghostty" | "kitty" | "rio") {
412 return true;
413 }
414 if let Some(rest) = term.strip_prefix("xterm-") {
415 let vendor = rest.split('-').next().unwrap_or("");
416 return matches!(vendor, "ghostty" | "kitty" | "rio");
417 }
418 false
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn env_with(pairs: &[(&str, &str)]) -> Env {
427 Env::from_pairs(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())))
428 }
429
430 #[test]
431 fn supports_decst8c_matches_allowlisted_terms() {
432 for term in [
433 "alacritty",
434 "ghostty",
435 "kitty",
436 "rio",
437 "xterm-kitty",
438 "xterm-ghostty",
439 ] {
440 assert!(
441 Optimizations::supports_decst8c(&env_with(&[("TERM", term)])),
442 "expected DECST8C support for TERM={term}",
443 );
444 }
445 }
446
447 #[test]
448 fn supports_decst8c_rejects_plain_xterm_and_unknown() {
449 for term in [
450 "xterm-256color",
451 "screen-256color",
452 "tmux",
453 "vt100",
454 "dumb",
455 "",
456 ] {
457 assert!(
458 !Optimizations::supports_decst8c(&env_with(&[("TERM", term)])),
459 "did not expect DECST8C support for TERM={term}",
460 );
461 }
462 }
463
464 #[test]
465 fn supports_decst8c_detects_terminals_via_env_tokens() {
466 assert!(Optimizations::supports_decst8c(&env_with(&[
469 ("TERM", "xterm-256color"),
470 ("WT_SESSION", "abc"),
471 ])));
472 assert!(Optimizations::supports_decst8c(&env_with(&[
473 ("TERM", "xterm-256color"),
474 ("KITTY_WINDOW_ID", "1"),
475 ])));
476 assert!(Optimizations::supports_decst8c(&env_with(&[
477 ("TERM", "xterm-256color"),
478 ("ALACRITTY_WINDOW_ID", "1"),
479 ])));
480 assert!(Optimizations::supports_decst8c(&env_with(&[
481 ("TERM", "screen"),
482 ("TERM_PROGRAM", "ghostty"),
483 ])));
484 assert!(Optimizations::supports_decst8c(&env_with(&[
487 ("TERM", "xterm-256color"),
488 ("TERM_PROGRAM", "iTerm.app"),
489 ])));
490 assert!(Optimizations::supports_decst8c(&env_with(&[
491 ("TERM", "xterm-256color"),
492 ("ITERM_SESSION_ID", "w0t0p0:abc"),
493 ])));
494 assert!(Optimizations::supports_decst8c(&env_with(&[
495 ("TERM", "xterm-256color"),
496 ("LC_TERMINAL", "iTerm2"),
497 ])));
498 }
499
500 #[test]
501 fn default_is_xterm() {
502 assert_eq!(Optimizations::default(), Optimizations::xterm());
503 }
504
505 #[test]
506 fn none_disables_escape_caps_only() {
507 let o = Optimizations::none();
508 assert!(!o.intersects(
509 Optimizations::ECH
510 | Optimizations::REP
511 | Optimizations::ICH
512 | Optimizations::DCH
513 | Optimizations::CSR
514 | Optimizations::SU_SD
515 | Optimizations::IL_DL
516 | Optimizations::BCE
517 | Optimizations::CHA
518 | Optimizations::HPA
519 | Optimizations::VPA
520 | Optimizations::CBT
521 | Optimizations::CHT
522 | Optimizations::ONLCR,
523 ));
524 assert!(o.contains(Optimizations::TABS));
526 assert!(o.contains(Optimizations::BS));
527 }
528
529 #[test]
530 fn modern_enables_everything_except_onlcr() {
531 let o = Optimizations::modern();
532 let everything_but_onlcr = Optimizations::all().difference(Optimizations::ONLCR);
533 assert_eq!(o, everything_but_onlcr);
534 }
535
536 #[test]
537 fn xterm_drops_hpa_cht_rep() {
538 let o = Optimizations::xterm();
539 assert!(!o.contains(Optimizations::HPA));
540 assert!(!o.contains(Optimizations::CHT));
541 assert!(!o.contains(Optimizations::REP));
542 let expected = Optimizations::modern()
543 .difference(Optimizations::HPA | Optimizations::CHT | Optimizations::REP);
544 assert_eq!(o, expected);
545 }
546
547 #[test]
548 fn vt100_predates_xterm_extensions() {
549 let o = Optimizations::vt100();
550 let missing = Optimizations::CHA
552 | Optimizations::HPA
553 | Optimizations::VPA
554 | Optimizations::ECH
555 | Optimizations::REP
556 | Optimizations::SU_SD
557 | Optimizations::BCE
558 | Optimizations::CBT
559 | Optimizations::CHT
560 | Optimizations::ONLCR;
561 assert!(!o.intersects(missing));
562 let present = Optimizations::CSR
564 | Optimizations::ICH
565 | Optimizations::DCH
566 | Optimizations::IL_DL
567 | Optimizations::TABS
568 | Optimizations::BS;
569 assert!(o.contains(present));
570 }
571
572 #[test]
573 fn linux_matches_console_codes_4() {
574 let o = Optimizations::linux();
575 let present = Optimizations::ECH
576 | Optimizations::ICH
577 | Optimizations::CHA
578 | Optimizations::HPA
579 | Optimizations::VPA
580 | Optimizations::TABS
581 | Optimizations::BS;
582 assert_eq!(o, present);
583 }
584
585 #[test]
586 fn screen_matches_infocmp_x1_screen_256color() {
587 let o = Optimizations::screen();
588 let present = Optimizations::ICH
589 | Optimizations::DCH
590 | Optimizations::CSR
591 | Optimizations::SU_SD
592 | Optimizations::IL_DL
593 | Optimizations::HPA
594 | Optimizations::VPA
595 | Optimizations::TABS
596 | Optimizations::CBT
597 | Optimizations::BS;
598 assert_eq!(o, present);
599 assert!(!o.contains(Optimizations::BCE));
600 assert!(!o.contains(Optimizations::ECH));
601 assert!(!o.contains(Optimizations::REP));
602 assert!(!o.contains(Optimizations::CHA));
603 assert!(!o.contains(Optimizations::CHT));
604 }
605
606 #[test]
607 fn from_term_kitty() {
608 let o = Optimizations::from_term("kitty");
609 assert!(o.contains(Optimizations::REP | Optimizations::HPA | Optimizations::VPA));
610 }
611
612 #[test]
613 fn from_term_xterm_excludes_hpa_cht_rep() {
614 let o = Optimizations::from_term("xterm-256color");
615 assert!(!o.contains(Optimizations::HPA));
616 assert!(!o.contains(Optimizations::CHT));
617 assert!(!o.contains(Optimizations::REP));
618 assert!(o.contains(Optimizations::CHA));
619 }
620
621 #[test]
622 fn from_term_xterm_kitty_promotes() {
623 let o = Optimizations::from_term("xterm-kitty");
624 assert!(o.contains(Optimizations::HPA | Optimizations::REP));
625 }
626
627 #[test]
628 fn from_term_xterm_ghostty_promotes() {
629 let o = Optimizations::from_term("xterm-ghostty");
630 assert!(o.contains(Optimizations::HPA | Optimizations::REP));
631 }
632
633 #[test]
634 fn from_term_xterm_rio_promotes() {
635 let o = Optimizations::from_term("xterm-rio");
636 assert!(o.contains(Optimizations::HPA | Optimizations::REP));
637 }
638
639 #[test]
640 fn from_term_linux_console() {
641 assert_eq!(Optimizations::from_term("linux"), Optimizations::linux());
642 }
643
644 #[test]
645 fn from_term_dumb_is_none() {
646 assert_eq!(Optimizations::from_term("dumb"), Optimizations::none());
647 }
648
649 #[test]
650 fn from_term_empty_is_none() {
651 assert_eq!(Optimizations::from_term(""), Optimizations::none());
652 }
653
654 #[test]
655 fn from_term_unknown_falls_back_to_none() {
656 assert_eq!(
657 Optimizations::from_term("madeupterm-256color"),
658 Optimizations::none(),
659 );
660 }
661
662 #[test]
663 fn xterm_term_enables_cha() {
664 let o = Optimizations::from_term("xterm-256color");
665 assert!(o.contains(Optimizations::CHA));
666 assert!(!o.contains(Optimizations::HPA));
667 }
668
669 #[test]
670 fn linux_term_supports_vpa_hpa_not_rep() {
671 let o = Optimizations::from_term("linux");
672 assert!(o.contains(Optimizations::VPA | Optimizations::HPA));
673 assert!(!o.contains(Optimizations::REP));
674 }
675
676 #[test]
677 fn alacritty_term_has_explicit_caps() {
678 let o = Optimizations::from_term("alacritty");
679 assert!(o.contains(Optimizations::CHA | Optimizations::ECH | Optimizations::REP));
680 }
681
682 #[test]
683 fn screen_term_uses_screen_profile() {
684 assert_eq!(
685 Optimizations::from_term("screen-256color"),
686 Optimizations::screen(),
687 );
688 }
689
690 #[test]
691 fn tmux_term_supports_vpa() {
692 assert!(Optimizations::from_term("tmux-256color").contains(Optimizations::VPA));
693 }
694
695 #[test]
696 fn from_term_modern_families_all_enable_rep() {
697 for term in [
698 "contour",
699 "foot",
700 "ghostty",
701 "kitty",
702 "rio",
703 "st",
704 "tmux",
705 "wezterm",
706 "alacritty",
707 ] {
708 let o = Optimizations::from_term(term);
709 assert!(
710 o.contains(Optimizations::REP | Optimizations::HPA),
711 "{term} should enable REP and HPA"
712 );
713 }
714 }
715
716 #[test]
717 fn with_tabs_toggles_tabs() {
718 let on = Optimizations::none().with_tabs(true);
719 let off = Optimizations::none().with_tabs(false);
720 assert!(on.contains(Optimizations::TABS));
721 assert!(!off.contains(Optimizations::TABS));
722 }
723
724 #[test]
725 fn with_bs_toggles_bs() {
726 let on = Optimizations::none().with_bs(true);
727 let off = Optimizations::none().with_bs(false);
728 assert!(on.contains(Optimizations::BS));
729 assert!(!off.contains(Optimizations::BS));
730 }
731
732 #[test]
733 fn with_onlcr_toggles_onlcr() {
734 let on = Optimizations::none().with_onlcr(true);
735 let off = Optimizations::modern().with_onlcr(false);
736 assert!(on.contains(Optimizations::ONLCR));
737 assert!(!off.contains(Optimizations::ONLCR));
738 }
739
740 #[test]
741 fn with_builders_compose() {
742 let o = Optimizations::xterm()
743 .with_rep(true)
744 .with_hpa(true)
745 .with_cha(false);
746 assert!(o.contains(Optimizations::REP | Optimizations::HPA));
747 assert!(!o.contains(Optimizations::CHA));
748 }
749
750 #[test]
751 fn from_env_uses_term_when_set() {
752 let env = Env::from_pairs([("TERM", "xterm-kitty")]);
753 assert_eq!(Optimizations::from_env(&env), Optimizations::modern());
754 }
755
756 #[test]
757 fn from_env_dumb_collapses_to_none() {
758 let env = Env::from_pairs([("TERM", "dumb")]);
759 assert_eq!(Optimizations::from_env(&env), Optimizations::none());
760 }
761
762 #[test]
763 fn from_env_empty_term_collapses_to_none() {
764 let env = Env::from_pairs([("TERM", "")]);
765 assert_eq!(Optimizations::from_env(&env), Optimizations::none());
766 }
767
768 #[test]
769 fn from_env_missing_term_falls_back_to_default() {
770 let env = Env::new();
771 assert_eq!(Optimizations::from_env(&env), Optimizations::default());
772 }
773
774 #[test]
775 fn with_builders_are_idempotent() {
776 let a = Optimizations::modern().with_tabs(true).with_tabs(true);
777 let b = Optimizations::modern().with_tabs(true);
778 assert_eq!(a, b);
779 let c = Optimizations::modern().with_rep(false).with_rep(false);
780 let d = Optimizations::modern().with_rep(false);
781 assert_eq!(c, d);
782 }
783}