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