Skip to main content

doing_template/
colors.rs

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/// A named ANSI color or modifier that can appear as a `%color` token in templates.
12#[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  /// Parse a color name string into a `Color`, if valid.
20  ///
21  /// Accepts named colors (`"cyan"`, `"boldwhite"`), modifiers (`"bold"`, `"italic"`),
22  /// and hex RGB (`"#FF5500"`, `"bg#00FF00"`).
23  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  /// Return the ANSI escape sequence for this color.
31  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/// A named ANSI color attribute.
58#[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  /// Parse a string into a `NamedColor`, if it matches a known color name.
125  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  /// Return the ANSI escape sequence for this named color.
193  fn to_ansi(self) -> String {
194    if !yansi::is_enabled() {
195      return String::new();
196    }
197    // Reset and default need raw ANSI since yansi styles compose forward
198    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  /// Convert this named color into a [`yansi::Style`].
206  ///
207  /// For most colors, this maps directly to yansi's `Color` and `Attribute` types.
208  /// Compound themes (e.g. `chalkboard`, `flamingo`) compose multiple style properties.
209  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
274/// Initialize color output with terminal detection.
275///
276/// Call this once at program startup to enable automatic TTY and `NO_COLOR` / `CLICOLOR`
277/// detection via [`yansi`]. When output is piped or redirected, colors are suppressed.
278pub fn init() {
279  yansi::whenever(Condition::TTY_AND_COLOR);
280}
281
282/// Strip all ANSI escape sequences from a string.
283pub fn strip_ansi(s: &str) -> String {
284  STRIP_ANSI_RE.replace_all(s, "").into_owned()
285}
286
287/// Validate a color name string, returning the longest valid prefix.
288///
289/// This allows color tokens to bleed into adjacent text, e.g. `%greensomething`
290/// still matches `green`.
291pub 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  // Check for hex color
300  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  // Find longest matching named color
316  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
327/// Return the visible (non-ANSI) display width of a string.
328///
329/// Uses Unicode display widths so CJK characters count as 2 and emoji count as 2.
330pub fn visible_len(s: &str) -> usize {
331  unicode_width::UnicodeWidthStr::width(strip_ansi(s).as_str())
332}
333
334/// Return a sorted list of all supported color names.
335#[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      // CJK characters are 2 display columns each
667      assert_eq!(visible_len("日本語"), 6);
668    }
669
670    #[test]
671    fn it_counts_emoji_as_double_width() {
672      // Most emoji are 2 display columns
673      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}