1use std::{
2 fmt::{Display, Formatter, Result as FmtResult},
3 sync::LazyLock,
4};
5
6use regex::Regex;
7use yansi::{Condition, Style};
8
9pub static STRIP_ANSI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap());
10
11#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum Color {
14 Hex { b: u8, background: bool, g: u8, r: u8 },
15 Named(NamedColor),
16}
17
18impl Color {
19 pub fn parse(s: &str) -> Option<Self> {
24 if let Some(hex) = parse_hex(s) {
25 return Some(hex);
26 }
27 NamedColor::parse(s).map(Self::Named)
28 }
29
30 pub fn to_ansi(&self) -> String {
32 match self {
33 Self::Hex {
34 background,
35 b,
36 g,
37 r,
38 } => {
39 let style = if *background {
40 Style::new().on_rgb(*r, *g, *b)
41 } else {
42 Style::new().rgb(*r, *g, *b)
43 };
44 style.prefix().to_string()
45 }
46 Self::Named(named) => named.to_ansi(),
47 }
48 }
49}
50
51impl Display for Color {
52 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
53 write!(f, "{}", self.to_ansi())
54 }
55}
56
57#[allow(dead_code)]
59#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
60pub enum NamedColor {
61 Alert,
62 BgBlack,
63 BgBlue,
64 BgCyan,
65 BgGreen,
66 BgMagenta,
67 BgPurple,
68 BgRed,
69 BgWhite,
70 BgYellow,
71 Black,
72 Blink,
73 Blue,
74 Bold,
75 BoldBgBlack,
76 BoldBgBlue,
77 BoldBgCyan,
78 BoldBgGreen,
79 BoldBgMagenta,
80 BoldBgPurple,
81 BoldBgRed,
82 BoldBgWhite,
83 BoldBgYellow,
84 BoldBlack,
85 BoldBlue,
86 BoldCyan,
87 BoldGreen,
88 BoldMagenta,
89 BoldPurple,
90 BoldRed,
91 BoldWhite,
92 BoldYellow,
93 Chalkboard,
94 Concealed,
95 Cyan,
96 Dark,
97 Default,
98 Error,
99 Flamingo,
100 Green,
101 Hotpants,
102 Italic,
103 Knightrider,
104 Led,
105 Magenta,
106 Negative,
107 Purple,
108 RapidBlink,
109 Red,
110 Redacted,
111 Reset,
112 Softpurple,
113 Strike,
114 Strikethrough,
115 Underline,
116 Underscore,
117 White,
118 Whiteboard,
119 Yeller,
120 Yellow,
121}
122
123impl NamedColor {
124 pub fn parse(s: &str) -> Option<Self> {
126 let normalized = normalize_color_name(s);
127 match normalized.as_str() {
128 "alert" => Some(Self::Alert),
129 "bgblack" => Some(Self::BgBlack),
130 "bgblue" => Some(Self::BgBlue),
131 "bgcyan" => Some(Self::BgCyan),
132 "bggreen" => Some(Self::BgGreen),
133 "bgmagenta" | "bgpurple" => Some(Self::BgMagenta),
134 "bgred" => Some(Self::BgRed),
135 "bgwhite" => Some(Self::BgWhite),
136 "bgyellow" => Some(Self::BgYellow),
137 "black" => Some(Self::Black),
138 "blink" => Some(Self::Blink),
139 "blue" => Some(Self::Blue),
140 "bold" => Some(Self::Bold),
141 "boldbgblack" => Some(Self::BoldBgBlack),
142 "boldbgblue" => Some(Self::BoldBgBlue),
143 "boldbgcyan" => Some(Self::BoldBgCyan),
144 "boldbggreen" => Some(Self::BoldBgGreen),
145 "boldbgmagenta" | "boldbgpurple" => Some(Self::BoldBgMagenta),
146 "boldbgred" => Some(Self::BoldBgRed),
147 "boldbgwhite" => Some(Self::BoldBgWhite),
148 "boldbgyellow" => Some(Self::BoldBgYellow),
149 "boldblack" => Some(Self::BoldBlack),
150 "boldblue" => Some(Self::BoldBlue),
151 "boldcyan" => Some(Self::BoldCyan),
152 "boldgreen" => Some(Self::BoldGreen),
153 "boldmagenta" | "boldpurple" => Some(Self::BoldMagenta),
154 "boldred" => Some(Self::BoldRed),
155 "boldwhite" => Some(Self::BoldWhite),
156 "boldyellow" => Some(Self::BoldYellow),
157 "chalkboard" => Some(Self::Chalkboard),
158 "clear" | "reset" => Some(Self::Reset),
159 "concealed" => Some(Self::Concealed),
160 "cyan" => Some(Self::Cyan),
161 "dark" => Some(Self::Dark),
162 "default" => Some(Self::Default),
163 "error" => Some(Self::Error),
164 "flamingo" => Some(Self::Flamingo),
165 "green" => Some(Self::Green),
166 "hotpants" => Some(Self::Hotpants),
167 "italic" => Some(Self::Italic),
168 "knightrider" => Some(Self::Knightrider),
169 "led" => Some(Self::Led),
170 "magenta" | "purple" => Some(Self::Magenta),
171 "negative" => Some(Self::Negative),
172 "rapidblink" => Some(Self::RapidBlink),
173 "red" => Some(Self::Red),
174 "redacted" => Some(Self::Redacted),
175 "softpurple" => Some(Self::Softpurple),
176 "strike" => Some(Self::Strike),
177 "strikethrough" => Some(Self::Strikethrough),
178 "underline" | "underscore" => Some(Self::Underline),
179 "white" => Some(Self::White),
180 "whiteboard" => Some(Self::Whiteboard),
181 "yeller" => Some(Self::Yeller),
182 "yellow" => Some(Self::Yellow),
183 _ => None,
184 }
185 }
186
187 fn to_ansi(self) -> String {
189 if !yansi::is_enabled() {
190 return String::new();
191 }
192 match self {
194 Self::Default => "\x1b[0;39m".into(),
195 Self::Reset => "\x1b[0m".into(),
196 _ => self.to_style().prefix().to_string(),
197 }
198 }
199
200 fn to_style(self) -> Style {
205 match self {
206 Self::Alert => Style::new().red().on_yellow().bold(),
207 Self::BgBlack => Style::new().on_black(),
208 Self::BgBlue => Style::new().on_blue(),
209 Self::BgCyan => Style::new().on_cyan(),
210 Self::BgGreen => Style::new().on_green(),
211 Self::BgMagenta | Self::BgPurple => Style::new().on_magenta(),
212 Self::BgRed => Style::new().on_red(),
213 Self::BgWhite => Style::new().on_white(),
214 Self::BgYellow => Style::new().on_yellow(),
215 Self::Black => Style::new().black(),
216 Self::Blink => Style::new().blink(),
217 Self::Blue => Style::new().blue(),
218 Self::Bold => Style::new().bold(),
219 Self::BoldBgBlack => Style::new().on_bright_black(),
220 Self::BoldBgBlue => Style::new().on_bright_blue(),
221 Self::BoldBgCyan => Style::new().on_bright_cyan(),
222 Self::BoldBgGreen => Style::new().on_bright_green(),
223 Self::BoldBgMagenta | Self::BoldBgPurple => Style::new().on_bright_magenta(),
224 Self::BoldBgRed => Style::new().on_bright_red(),
225 Self::BoldBgWhite => Style::new().on_bright_white(),
226 Self::BoldBgYellow => Style::new().on_bright_yellow(),
227 Self::BoldBlack => Style::new().bright_black(),
228 Self::BoldBlue => Style::new().bright_blue(),
229 Self::BoldCyan => Style::new().bright_cyan(),
230 Self::BoldGreen => Style::new().bright_green(),
231 Self::BoldMagenta | Self::BoldPurple => Style::new().bright_magenta(),
232 Self::BoldRed => Style::new().bright_red(),
233 Self::BoldWhite => Style::new().bright_white(),
234 Self::BoldYellow => Style::new().bright_yellow(),
235 Self::Chalkboard => Style::new().white().on_black().bold(),
236 Self::Concealed => Style::new().conceal(),
237 Self::Cyan => Style::new().cyan(),
238 Self::Dark => Style::new().dim(),
239 Self::Default | Self::Reset => Style::new(),
240 Self::Error => Style::new().white().on_red().bold(),
241 Self::Flamingo => Style::new().red().on_white().invert(),
242 Self::Green => Style::new().green(),
243 Self::Hotpants => Style::new().blue().on_black().invert(),
244 Self::Italic => Style::new().italic(),
245 Self::Knightrider => Style::new().black().on_black().invert(),
246 Self::Led => Style::new().green().on_black(),
247 Self::Magenta | Self::Purple => Style::new().magenta(),
248 Self::Negative => Style::new().invert(),
249 Self::RapidBlink => Style::new().rapid_blink(),
250 Self::Red => Style::new().red(),
251 Self::Redacted => Style::new().black().on_black(),
252 Self::Softpurple => Style::new().magenta().on_black(),
253 Self::Strike | Self::Strikethrough => Style::new().strike(),
254 Self::Underline | Self::Underscore => Style::new().underline(),
255 Self::White => Style::new().white(),
256 Self::Whiteboard => Style::new().black().on_white().bold(),
257 Self::Yeller => Style::new().white().on_yellow().bold(),
258 Self::Yellow => Style::new().yellow(),
259 }
260 }
261}
262
263impl Display for NamedColor {
264 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
265 write!(f, "{}", self.to_ansi())
266 }
267}
268
269pub fn init() {
274 yansi::whenever(Condition::TTY_AND_COLOR);
275}
276
277pub fn strip_ansi(s: &str) -> String {
279 STRIP_ANSI_RE.replace_all(s, "").into_owned()
280}
281
282pub fn validate_color(s: &str) -> Option<(String, usize)> {
290 let normalized = normalize_color_name(s);
291
292 if let Some(rest) = normalized.strip_prefix('#')
294 && rest.len() >= 6
295 && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
296 {
297 let color = format!("#{}", &rest[..6]);
298 let orig_len = original_len_for_normalized(s, color.len());
299 return Some((color, orig_len));
300 }
301 for prefix in ["fg#", "bg#", "f#", "b#"] {
302 if let Some(rest) = normalized.strip_prefix(prefix)
303 && rest.len() >= 6
304 && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
305 {
306 let color = format!("{prefix}{}", &rest[..6]);
307 let orig_len = original_len_for_normalized(s, color.len());
308 return Some((color, orig_len));
309 }
310 }
311
312 let mut valid = None;
314 let mut compiled = String::new();
315 let mut norm_len = 0;
316 for ch in normalized.chars() {
317 compiled.push(ch);
318 norm_len += ch.len_utf8();
319 if NamedColor::parse(&compiled).is_some() {
320 valid = Some((compiled.clone(), original_len_for_normalized(s, norm_len)));
321 }
322 }
323 valid
324}
325
326fn original_len_for_normalized(original: &str, normalized_len: usize) -> usize {
329 let stripped: String = original.chars().filter(|&c| c != '_').collect();
331 let mut stripped_to_orig = Vec::new();
332 let mut orig_pos = 0;
333 for ch in original.chars() {
334 if ch != '_' {
335 stripped_to_orig.push(orig_pos);
336 }
337 orig_pos += ch.len_utf8();
338 }
339 stripped_to_orig.push(orig_pos);
341
342 let mut norm_pos = 0;
345 let mut strip_pos = 0;
346 while norm_pos < normalized_len && strip_pos < stripped.len() {
347 if stripped[strip_pos..].starts_with("bright") {
348 let norm_advance = "bold".len().min(normalized_len - norm_pos);
350 norm_pos += norm_advance;
351 if norm_advance == "bold".len() {
352 strip_pos += "bright".len();
353 } else {
354 strip_pos += norm_advance;
356 }
357 } else {
358 let ch_len = stripped[strip_pos..].chars().next().map_or(1, |c| c.len_utf8());
359 norm_pos += ch_len;
360 strip_pos += ch_len;
361 }
362 }
363
364 if strip_pos >= stripped.len() {
366 *stripped_to_orig.last().unwrap_or(&orig_pos)
367 } else {
368 let stripped_chars_consumed = stripped[..strip_pos].chars().count();
370 stripped_to_orig
371 .get(stripped_chars_consumed)
372 .copied()
373 .unwrap_or(orig_pos)
374 }
375}
376
377pub fn visible_len(s: &str) -> usize {
381 unicode_width::UnicodeWidthStr::width(strip_ansi(s).as_str())
382}
383
384#[cfg(test)]
386fn available_colors() -> Vec<&'static str> {
387 vec![
388 "alert",
389 "bgblack",
390 "bgblue",
391 "bgcyan",
392 "bggreen",
393 "bgmagenta",
394 "bgpurple",
395 "bgred",
396 "bgwhite",
397 "bgyellow",
398 "black",
399 "blink",
400 "blue",
401 "bold",
402 "boldbgblack",
403 "boldbgblue",
404 "boldbgcyan",
405 "boldbggreen",
406 "boldbgmagenta",
407 "boldbgpurple",
408 "boldbgred",
409 "boldbgwhite",
410 "boldbgyellow",
411 "boldblack",
412 "boldblue",
413 "boldcyan",
414 "boldgreen",
415 "boldmagenta",
416 "boldpurple",
417 "boldred",
418 "boldwhite",
419 "boldyellow",
420 "chalkboard",
421 "clear",
422 "concealed",
423 "cyan",
424 "dark",
425 "default",
426 "error",
427 "flamingo",
428 "green",
429 "hotpants",
430 "italic",
431 "knightrider",
432 "led",
433 "magenta",
434 "negative",
435 "purple",
436 "rapidblink",
437 "red",
438 "redacted",
439 "reset",
440 "softpurple",
441 "strike",
442 "strikethrough",
443 "underline",
444 "underscore",
445 "white",
446 "whiteboard",
447 "yeller",
448 "yellow",
449 ]
450}
451
452fn normalize_color_name(s: &str) -> String {
453 let normalized = s.replace('_', "").replace("bright", "bold");
454 if normalized.starts_with("bgbold") {
455 normalized.replacen("bgbold", "boldbg", 1)
456 } else {
457 normalized
458 }
459}
460
461fn parse_hex(s: &str) -> Option<Color> {
462 let (background, hex_str) = if let Some(rest) = s.strip_prefix("bg#").or_else(|| s.strip_prefix("b#")) {
463 (true, rest)
464 } else if let Some(rest) = s.strip_prefix("fg#").or_else(|| s.strip_prefix("f#")) {
465 (false, rest)
466 } else if let Some(rest) = s.strip_prefix('#') {
467 (false, rest)
468 } else {
469 return None;
470 };
471
472 if hex_str.len() != 6 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
473 return None;
474 }
475
476 let r = u8::from_str_radix(&hex_str[0..2], 16).ok()?;
477 let g = u8::from_str_radix(&hex_str[2..4], 16).ok()?;
478 let b = u8::from_str_radix(&hex_str[4..6], 16).ok()?;
479
480 Some(Color::Hex {
481 background,
482 b,
483 g,
484 r,
485 })
486}
487
488#[cfg(test)]
489mod test {
490 use super::*;
491
492 mod available_colors {
493 use super::*;
494
495 #[test]
496 fn it_contains_basic_colors() {
497 let colors = available_colors();
498
499 assert!(colors.contains(&"red"));
500 assert!(colors.contains(&"green"));
501 assert!(colors.contains(&"blue"));
502 assert!(colors.contains(&"cyan"));
503 assert!(colors.contains(&"yellow"));
504 assert!(colors.contains(&"magenta"));
505 assert!(colors.contains(&"white"));
506 assert!(colors.contains(&"black"));
507 }
508
509 #[test]
510 fn it_contains_reset() {
511 let colors = available_colors();
512
513 assert!(colors.contains(&"reset"));
514 assert!(colors.contains(&"default"));
515 }
516
517 #[test]
518 fn it_returns_sorted_list() {
519 let colors = available_colors();
520
521 let mut sorted = colors.clone();
522 sorted.sort();
523 assert_eq!(colors, sorted);
524 }
525 }
526
527 mod color_parse {
528 use super::*;
529
530 #[test]
531 fn it_normalizes_bright_to_bold() {
532 let color = Color::parse("brightwhite");
533
534 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
535 }
536
537 #[test]
538 fn it_normalizes_underscores() {
539 let color = Color::parse("bold_white");
540
541 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
542 }
543
544 #[test]
545 fn it_parses_bold_color() {
546 let color = Color::parse("boldwhite");
547
548 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
549 }
550
551 #[test]
552 fn it_parses_hex_background() {
553 let color = Color::parse("bg#00FF00");
554
555 assert_eq!(
556 color,
557 Some(Color::Hex {
558 background: true,
559 b: 0x00,
560 g: 0xFF,
561 r: 0x00,
562 })
563 );
564 }
565
566 #[test]
567 fn it_parses_hex_foreground() {
568 let color = Color::parse("#FF5500");
569
570 assert_eq!(
571 color,
572 Some(Color::Hex {
573 background: false,
574 b: 0x00,
575 g: 0x55,
576 r: 0xFF,
577 })
578 );
579 }
580
581 #[test]
582 fn it_parses_named_color() {
583 let color = Color::parse("cyan");
584
585 assert_eq!(color, Some(Color::Named(NamedColor::Cyan)));
586 }
587
588 #[test]
589 fn it_returns_none_for_invalid() {
590 assert_eq!(Color::parse("notacolor"), None);
591 }
592 }
593
594 mod color_to_ansi {
595 use super::*;
596
597 #[test]
598 fn it_emits_empty_when_disabled() {
599 yansi::disable();
600
601 let result = Color::Named(NamedColor::Cyan).to_ansi();
602
603 assert_eq!(result, "");
604 yansi::enable();
605 }
606
607 #[test]
608 fn it_emits_hex_background() {
609 yansi::enable();
610 let color = Color::Hex {
611 background: true,
612 b: 0x00,
613 g: 0xFF,
614 r: 0x00,
615 };
616
617 let result = color.to_ansi();
618
619 assert!(result.contains("48;2;0;255;0"), "expected RGB bg escape, got: {result}");
620 }
621
622 #[test]
623 fn it_emits_hex_foreground() {
624 yansi::enable();
625 let color = Color::Hex {
626 background: false,
627 b: 0x00,
628 g: 0x55,
629 r: 0xFF,
630 };
631
632 let result = color.to_ansi();
633
634 assert!(
635 result.contains("38;2;255;85;0"),
636 "expected RGB fg escape, got: {result}"
637 );
638 }
639
640 #[test]
641 fn it_emits_named_ansi() {
642 yansi::enable();
643
644 let result = Color::Named(NamedColor::Cyan).to_ansi();
645
646 assert!(result.contains("36"), "expected cyan code 36, got: {result}");
647 }
648
649 #[test]
650 fn it_emits_reset() {
651 yansi::enable();
652
653 let result = Color::Named(NamedColor::Reset).to_ansi();
654
655 assert_eq!(result, "\x1b[0m");
656 }
657 }
658
659 mod strip_ansi {
660 use pretty_assertions::assert_eq;
661
662 use super::super::strip_ansi;
663
664 #[test]
665 fn it_removes_escape_sequences() {
666 let input = "\x1b[36mhello\x1b[0m world";
667
668 assert_eq!(strip_ansi(input), "hello world");
669 }
670
671 #[test]
672 fn it_returns_plain_text_unchanged() {
673 assert_eq!(strip_ansi("hello"), "hello");
674 }
675
676 #[test]
677 fn it_strips_cursor_movement_sequences() {
678 let input = "\x1b[2Ahello\x1b[3Bworld";
679
680 assert_eq!(strip_ansi(input), "helloworld");
681 }
682
683 #[test]
684 fn it_strips_erase_sequences() {
685 let input = "hello\x1b[2Jworld";
686
687 assert_eq!(strip_ansi(input), "helloworld");
688 }
689
690 #[test]
691 fn it_strips_scroll_sequences() {
692 let input = "hello\x1b[1Sworld";
693
694 assert_eq!(strip_ansi(input), "helloworld");
695 }
696 }
697
698 mod validate_color {
699 use pretty_assertions::assert_eq;
700
701 use super::super::validate_color;
702
703 #[test]
704 fn it_finds_longest_prefix() {
705 assert_eq!(validate_color("boldbluefoo"), Some(("boldblue".into(), 8)));
706 }
707
708 #[test]
709 fn it_returns_correct_original_length_with_underscores() {
710 assert_eq!(validate_color("bold_white"), Some(("boldwhite".into(), 10)));
711 }
712
713 #[test]
714 fn it_returns_none_for_invalid() {
715 assert_eq!(validate_color("notacolor"), None);
716 }
717
718 #[test]
719 fn it_validates_bg_hex() {
720 assert_eq!(validate_color("bg#00FF00"), Some(("bg#00FF00".into(), 9)));
721 }
722
723 #[test]
724 fn it_validates_bold_color() {
725 assert_eq!(validate_color("boldwhite"), Some(("boldwhite".into(), 9)));
726 }
727
728 #[test]
729 fn it_validates_bright_red_with_underscore() {
730 assert_eq!(validate_color("bright_red"), Some(("boldred".into(), 10)));
731 }
732
733 #[test]
734 fn it_validates_hex() {
735 assert_eq!(validate_color("#FF5500"), Some(("#FF5500".into(), 7)));
736 }
737
738 #[test]
739 fn it_validates_simple_color() {
740 assert_eq!(validate_color("cyan"), Some(("cyan".into(), 4)));
741 }
742 }
743
744 mod visible_len {
745 use pretty_assertions::assert_eq;
746
747 use super::super::visible_len;
748
749 #[test]
750 fn it_counts_plain_text() {
751 assert_eq!(visible_len("hello"), 5);
752 }
753
754 #[test]
755 fn it_counts_cjk_characters_as_double_width() {
756 assert_eq!(visible_len("日本語"), 6);
758 }
759
760 #[test]
761 fn it_counts_emoji_as_double_width() {
762 assert_eq!(visible_len("🎉"), 2);
764 }
765
766 #[test]
767 fn it_excludes_ansi_codes() {
768 let input = "\x1b[36mhello\x1b[0m";
769
770 assert_eq!(visible_len(input), 5);
771 }
772 }
773}