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;]*m").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 Redacted,
110 Red,
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> {
292 let normalized = s.replace('_', "").replace("bright", "bold");
293 let normalized = if normalized.starts_with("bgbold") {
294 normalized.replacen("bgbold", "boldbg", 1)
295 } else {
296 normalized
297 };
298
299 if let Some(rest) = normalized.strip_prefix('#')
301 && rest.len() >= 6
302 && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
303 {
304 return Some(format!("#{}", &rest[..6]));
305 }
306 for prefix in ["fg#", "bg#", "f#", "b#"] {
307 if let Some(rest) = normalized.strip_prefix(prefix)
308 && rest.len() >= 6
309 && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
310 {
311 return Some(format!("{prefix}{}", &rest[..6]));
312 }
313 }
314
315 let mut valid = None;
317 let mut compiled = String::new();
318 for ch in normalized.chars() {
319 compiled.push(ch);
320 if NamedColor::parse(&compiled).is_some() {
321 valid = Some(compiled.clone());
322 }
323 }
324 valid
325}
326
327pub fn visible_len(s: &str) -> usize {
329 strip_ansi(s).len()
330}
331
332#[cfg(test)]
334fn available_colors() -> Vec<&'static str> {
335 vec![
336 "alert",
337 "bgblack",
338 "bgblue",
339 "bgcyan",
340 "bggreen",
341 "bgmagenta",
342 "bgpurple",
343 "bgred",
344 "bgwhite",
345 "bgyellow",
346 "black",
347 "blink",
348 "blue",
349 "bold",
350 "boldbgblack",
351 "boldbgblue",
352 "boldbgcyan",
353 "boldbggreen",
354 "boldbgmagenta",
355 "boldbgpurple",
356 "boldbgred",
357 "boldbgwhite",
358 "boldbgyellow",
359 "boldblack",
360 "boldblue",
361 "boldcyan",
362 "boldgreen",
363 "boldmagenta",
364 "boldpurple",
365 "boldred",
366 "boldwhite",
367 "boldyellow",
368 "chalkboard",
369 "clear",
370 "concealed",
371 "cyan",
372 "dark",
373 "default",
374 "error",
375 "flamingo",
376 "green",
377 "hotpants",
378 "italic",
379 "knightrider",
380 "led",
381 "magenta",
382 "negative",
383 "purple",
384 "rapidblink",
385 "red",
386 "redacted",
387 "reset",
388 "softpurple",
389 "strike",
390 "strikethrough",
391 "underline",
392 "underscore",
393 "white",
394 "whiteboard",
395 "yeller",
396 "yellow",
397 ]
398}
399
400fn parse_hex(s: &str) -> Option<Color> {
401 let (background, hex_str) = if let Some(rest) = s.strip_prefix("bg#").or_else(|| s.strip_prefix("b#")) {
402 (true, rest)
403 } else if let Some(rest) = s.strip_prefix("fg#").or_else(|| s.strip_prefix("f#")) {
404 (false, rest)
405 } else if let Some(rest) = s.strip_prefix('#') {
406 (false, rest)
407 } else {
408 return None;
409 };
410
411 if hex_str.len() != 6 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
412 return None;
413 }
414
415 let r = u8::from_str_radix(&hex_str[0..2], 16).ok()?;
416 let g = u8::from_str_radix(&hex_str[2..4], 16).ok()?;
417 let b = u8::from_str_radix(&hex_str[4..6], 16).ok()?;
418
419 Some(Color::Hex {
420 background,
421 b,
422 g,
423 r,
424 })
425}
426
427#[cfg(test)]
428mod test {
429 use super::*;
430
431 mod available_colors {
432 use super::*;
433
434 #[test]
435 fn it_contains_basic_colors() {
436 let colors = available_colors();
437
438 assert!(colors.contains(&"red"));
439 assert!(colors.contains(&"green"));
440 assert!(colors.contains(&"blue"));
441 assert!(colors.contains(&"cyan"));
442 assert!(colors.contains(&"yellow"));
443 assert!(colors.contains(&"magenta"));
444 assert!(colors.contains(&"white"));
445 assert!(colors.contains(&"black"));
446 }
447
448 #[test]
449 fn it_contains_reset() {
450 let colors = available_colors();
451
452 assert!(colors.contains(&"reset"));
453 assert!(colors.contains(&"default"));
454 }
455
456 #[test]
457 fn it_returns_sorted_list() {
458 let colors = available_colors();
459
460 let mut sorted = colors.clone();
461 sorted.sort();
462 assert_eq!(colors, sorted);
463 }
464 }
465
466 mod color_parse {
467 use super::*;
468
469 #[test]
470 fn it_normalizes_bright_to_bold() {
471 let color = Color::parse("brightwhite");
472
473 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
474 }
475
476 #[test]
477 fn it_normalizes_underscores() {
478 let color = Color::parse("bold_white");
479
480 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
481 }
482
483 #[test]
484 fn it_parses_bold_color() {
485 let color = Color::parse("boldwhite");
486
487 assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
488 }
489
490 #[test]
491 fn it_parses_hex_background() {
492 let color = Color::parse("bg#00FF00");
493
494 assert_eq!(
495 color,
496 Some(Color::Hex {
497 background: true,
498 b: 0x00,
499 g: 0xFF,
500 r: 0x00,
501 })
502 );
503 }
504
505 #[test]
506 fn it_parses_hex_foreground() {
507 let color = Color::parse("#FF5500");
508
509 assert_eq!(
510 color,
511 Some(Color::Hex {
512 background: false,
513 b: 0x00,
514 g: 0x55,
515 r: 0xFF,
516 })
517 );
518 }
519
520 #[test]
521 fn it_parses_named_color() {
522 let color = Color::parse("cyan");
523
524 assert_eq!(color, Some(Color::Named(NamedColor::Cyan)));
525 }
526
527 #[test]
528 fn it_returns_none_for_invalid() {
529 assert_eq!(Color::parse("notacolor"), None);
530 }
531 }
532
533 mod color_to_ansi {
534 use super::*;
535
536 #[test]
537 fn it_emits_empty_when_disabled() {
538 yansi::disable();
539
540 let result = Color::Named(NamedColor::Cyan).to_ansi();
541
542 assert_eq!(result, "");
543 yansi::enable();
544 }
545
546 #[test]
547 fn it_emits_hex_background() {
548 yansi::enable();
549 let color = Color::Hex {
550 background: true,
551 b: 0x00,
552 g: 0xFF,
553 r: 0x00,
554 };
555
556 let result = color.to_ansi();
557
558 assert!(result.contains("48;2;0;255;0"), "expected RGB bg escape, got: {result}");
559 }
560
561 #[test]
562 fn it_emits_hex_foreground() {
563 yansi::enable();
564 let color = Color::Hex {
565 background: false,
566 b: 0x00,
567 g: 0x55,
568 r: 0xFF,
569 };
570
571 let result = color.to_ansi();
572
573 assert!(
574 result.contains("38;2;255;85;0"),
575 "expected RGB fg escape, got: {result}"
576 );
577 }
578
579 #[test]
580 fn it_emits_named_ansi() {
581 yansi::enable();
582
583 let result = Color::Named(NamedColor::Cyan).to_ansi();
584
585 assert!(result.contains("36"), "expected cyan code 36, got: {result}");
586 }
587
588 #[test]
589 fn it_emits_reset() {
590 yansi::enable();
591
592 let result = Color::Named(NamedColor::Reset).to_ansi();
593
594 assert_eq!(result, "\x1b[0m");
595 }
596 }
597
598 mod strip_ansi {
599 use pretty_assertions::assert_eq;
600
601 use super::super::strip_ansi;
602
603 #[test]
604 fn it_removes_escape_sequences() {
605 let input = "\x1b[36mhello\x1b[0m world";
606
607 assert_eq!(strip_ansi(input), "hello world");
608 }
609
610 #[test]
611 fn it_returns_plain_text_unchanged() {
612 assert_eq!(strip_ansi("hello"), "hello");
613 }
614 }
615
616 mod validate_color {
617 use pretty_assertions::assert_eq;
618
619 use super::super::validate_color;
620
621 #[test]
622 fn it_finds_longest_prefix() {
623 assert_eq!(validate_color("boldbluefoo"), Some("boldblue".into()));
624 }
625
626 #[test]
627 fn it_returns_none_for_invalid() {
628 assert_eq!(validate_color("notacolor"), None);
629 }
630
631 #[test]
632 fn it_validates_bg_hex() {
633 assert_eq!(validate_color("bg#00FF00"), Some("bg#00FF00".into()));
634 }
635
636 #[test]
637 fn it_validates_bold_color() {
638 assert_eq!(validate_color("boldwhite"), Some("boldwhite".into()));
639 }
640
641 #[test]
642 fn it_validates_hex() {
643 assert_eq!(validate_color("#FF5500"), Some("#FF5500".into()));
644 }
645
646 #[test]
647 fn it_validates_simple_color() {
648 assert_eq!(validate_color("cyan"), Some("cyan".into()));
649 }
650 }
651
652 mod visible_len {
653 use pretty_assertions::assert_eq;
654
655 use super::super::visible_len;
656
657 #[test]
658 fn it_counts_plain_text() {
659 assert_eq!(visible_len("hello"), 5);
660 }
661
662 #[test]
663 fn it_excludes_ansi_codes() {
664 let input = "\x1b[36mhello\x1b[0m";
665
666 assert_eq!(visible_len(input), 5);
667 }
668 }
669}