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