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) length of a string.
328pub fn visible_len(s: &str) -> usize {
329  strip_ansi(s).len()
330}
331
332/// Return a sorted list of all supported color names.
333#[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}