use std::{
fmt::{Display, Formatter, Result as FmtResult},
sync::LazyLock,
};
use regex::Regex;
use yansi::{Condition, Style};
pub static STRIP_ANSI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap());
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Color {
Hex { b: u8, background: bool, g: u8, r: u8 },
Named(NamedColor),
}
impl Color {
pub fn parse(s: &str) -> Option<Self> {
if let Some(hex) = parse_hex(s) {
return Some(hex);
}
NamedColor::parse(s).map(Self::Named)
}
pub fn to_ansi(&self) -> String {
match self {
Self::Hex {
background,
b,
g,
r,
} => {
let style = if *background {
Style::new().on_rgb(*r, *g, *b)
} else {
Style::new().rgb(*r, *g, *b)
};
style.prefix().to_string()
}
Self::Named(named) => named.to_ansi(),
}
}
}
impl Display for Color {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", self.to_ansi())
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum NamedColor {
Alert,
BgBlack,
BgBlue,
BgCyan,
BgGreen,
BgMagenta,
BgPurple,
BgRed,
BgWhite,
BgYellow,
Black,
Blink,
Blue,
Bold,
BoldBgBlack,
BoldBgBlue,
BoldBgCyan,
BoldBgGreen,
BoldBgMagenta,
BoldBgPurple,
BoldBgRed,
BoldBgWhite,
BoldBgYellow,
BoldBlack,
BoldBlue,
BoldCyan,
BoldGreen,
BoldMagenta,
BoldPurple,
BoldRed,
BoldWhite,
BoldYellow,
Chalkboard,
Concealed,
Cyan,
Dark,
Default,
Error,
Flamingo,
Green,
Hotpants,
Italic,
Knightrider,
Led,
Magenta,
Negative,
Purple,
RapidBlink,
Red,
Redacted,
Reset,
Softpurple,
Strike,
Strikethrough,
Underline,
Underscore,
White,
Whiteboard,
Yeller,
Yellow,
}
impl NamedColor {
pub fn parse(s: &str) -> Option<Self> {
let normalized = normalize_color_name(s);
match normalized.as_str() {
"alert" => Some(Self::Alert),
"bgblack" => Some(Self::BgBlack),
"bgblue" => Some(Self::BgBlue),
"bgcyan" => Some(Self::BgCyan),
"bggreen" => Some(Self::BgGreen),
"bgmagenta" | "bgpurple" => Some(Self::BgMagenta),
"bgred" => Some(Self::BgRed),
"bgwhite" => Some(Self::BgWhite),
"bgyellow" => Some(Self::BgYellow),
"black" => Some(Self::Black),
"blink" => Some(Self::Blink),
"blue" => Some(Self::Blue),
"bold" => Some(Self::Bold),
"boldbgblack" => Some(Self::BoldBgBlack),
"boldbgblue" => Some(Self::BoldBgBlue),
"boldbgcyan" => Some(Self::BoldBgCyan),
"boldbggreen" => Some(Self::BoldBgGreen),
"boldbgmagenta" | "boldbgpurple" => Some(Self::BoldBgMagenta),
"boldbgred" => Some(Self::BoldBgRed),
"boldbgwhite" => Some(Self::BoldBgWhite),
"boldbgyellow" => Some(Self::BoldBgYellow),
"boldblack" => Some(Self::BoldBlack),
"boldblue" => Some(Self::BoldBlue),
"boldcyan" => Some(Self::BoldCyan),
"boldgreen" => Some(Self::BoldGreen),
"boldmagenta" | "boldpurple" => Some(Self::BoldMagenta),
"boldred" => Some(Self::BoldRed),
"boldwhite" => Some(Self::BoldWhite),
"boldyellow" => Some(Self::BoldYellow),
"chalkboard" => Some(Self::Chalkboard),
"clear" | "reset" => Some(Self::Reset),
"concealed" => Some(Self::Concealed),
"cyan" => Some(Self::Cyan),
"dark" => Some(Self::Dark),
"default" => Some(Self::Default),
"error" => Some(Self::Error),
"flamingo" => Some(Self::Flamingo),
"green" => Some(Self::Green),
"hotpants" => Some(Self::Hotpants),
"italic" => Some(Self::Italic),
"knightrider" => Some(Self::Knightrider),
"led" => Some(Self::Led),
"magenta" | "purple" => Some(Self::Magenta),
"negative" => Some(Self::Negative),
"rapidblink" => Some(Self::RapidBlink),
"red" => Some(Self::Red),
"redacted" => Some(Self::Redacted),
"softpurple" => Some(Self::Softpurple),
"strike" => Some(Self::Strike),
"strikethrough" => Some(Self::Strikethrough),
"underline" | "underscore" => Some(Self::Underline),
"white" => Some(Self::White),
"whiteboard" => Some(Self::Whiteboard),
"yeller" => Some(Self::Yeller),
"yellow" => Some(Self::Yellow),
_ => None,
}
}
fn to_ansi(self) -> String {
if !yansi::is_enabled() {
return String::new();
}
match self {
Self::Default => "\x1b[0;39m".into(),
Self::Reset => "\x1b[0m".into(),
_ => self.to_style().prefix().to_string(),
}
}
fn to_style(self) -> Style {
match self {
Self::Alert => Style::new().red().on_yellow().bold(),
Self::BgBlack => Style::new().on_black(),
Self::BgBlue => Style::new().on_blue(),
Self::BgCyan => Style::new().on_cyan(),
Self::BgGreen => Style::new().on_green(),
Self::BgMagenta | Self::BgPurple => Style::new().on_magenta(),
Self::BgRed => Style::new().on_red(),
Self::BgWhite => Style::new().on_white(),
Self::BgYellow => Style::new().on_yellow(),
Self::Black => Style::new().black(),
Self::Blink => Style::new().blink(),
Self::Blue => Style::new().blue(),
Self::Bold => Style::new().bold(),
Self::BoldBgBlack => Style::new().on_bright_black(),
Self::BoldBgBlue => Style::new().on_bright_blue(),
Self::BoldBgCyan => Style::new().on_bright_cyan(),
Self::BoldBgGreen => Style::new().on_bright_green(),
Self::BoldBgMagenta | Self::BoldBgPurple => Style::new().on_bright_magenta(),
Self::BoldBgRed => Style::new().on_bright_red(),
Self::BoldBgWhite => Style::new().on_bright_white(),
Self::BoldBgYellow => Style::new().on_bright_yellow(),
Self::BoldBlack => Style::new().bright_black(),
Self::BoldBlue => Style::new().bright_blue(),
Self::BoldCyan => Style::new().bright_cyan(),
Self::BoldGreen => Style::new().bright_green(),
Self::BoldMagenta | Self::BoldPurple => Style::new().bright_magenta(),
Self::BoldRed => Style::new().bright_red(),
Self::BoldWhite => Style::new().bright_white(),
Self::BoldYellow => Style::new().bright_yellow(),
Self::Chalkboard => Style::new().white().on_black().bold(),
Self::Concealed => Style::new().conceal(),
Self::Cyan => Style::new().cyan(),
Self::Dark => Style::new().dim(),
Self::Default | Self::Reset => Style::new(),
Self::Error => Style::new().white().on_red().bold(),
Self::Flamingo => Style::new().red().on_white().invert(),
Self::Green => Style::new().green(),
Self::Hotpants => Style::new().blue().on_black().invert(),
Self::Italic => Style::new().italic(),
Self::Knightrider => Style::new().black().on_black().invert(),
Self::Led => Style::new().green().on_black(),
Self::Magenta | Self::Purple => Style::new().magenta(),
Self::Negative => Style::new().invert(),
Self::RapidBlink => Style::new().rapid_blink(),
Self::Red => Style::new().red(),
Self::Redacted => Style::new().black().on_black(),
Self::Softpurple => Style::new().magenta().on_black(),
Self::Strike | Self::Strikethrough => Style::new().strike(),
Self::Underline | Self::Underscore => Style::new().underline(),
Self::White => Style::new().white(),
Self::Whiteboard => Style::new().black().on_white().bold(),
Self::Yeller => Style::new().white().on_yellow().bold(),
Self::Yellow => Style::new().yellow(),
}
}
}
impl Display for NamedColor {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", self.to_ansi())
}
}
pub fn init() {
yansi::whenever(Condition::TTY_AND_COLOR);
}
pub fn strip_ansi(s: &str) -> String {
STRIP_ANSI_RE.replace_all(s, "").into_owned()
}
pub fn validate_color(s: &str) -> Option<(String, usize)> {
let normalized = normalize_color_name(s);
if let Some(rest) = normalized.strip_prefix('#')
&& rest.len() >= 6
&& rest[..6].chars().all(|c| c.is_ascii_hexdigit())
{
let color = format!("#{}", &rest[..6]);
let orig_len = original_len_for_normalized(s, color.len());
return Some((color, orig_len));
}
for prefix in ["fg#", "bg#", "f#", "b#"] {
if let Some(rest) = normalized.strip_prefix(prefix)
&& rest.len() >= 6
&& rest[..6].chars().all(|c| c.is_ascii_hexdigit())
{
let color = format!("{prefix}{}", &rest[..6]);
let orig_len = original_len_for_normalized(s, color.len());
return Some((color, orig_len));
}
}
let mut valid = None;
let mut compiled = String::new();
let mut norm_len = 0;
for ch in normalized.chars() {
compiled.push(ch);
norm_len += ch.len_utf8();
if NamedColor::parse(&compiled).is_some() {
valid = Some((compiled.clone(), original_len_for_normalized(s, norm_len)));
}
}
valid
}
fn original_len_for_normalized(original: &str, normalized_len: usize) -> usize {
let stripped: String = original.chars().filter(|&c| c != '_').collect();
let mut stripped_to_orig = Vec::new();
let mut orig_pos = 0;
for ch in original.chars() {
if ch != '_' {
stripped_to_orig.push(orig_pos);
}
orig_pos += ch.len_utf8();
}
stripped_to_orig.push(orig_pos);
let mut norm_pos = 0;
let mut strip_pos = 0;
while norm_pos < normalized_len && strip_pos < stripped.len() {
if stripped[strip_pos..].starts_with("bright") {
let norm_advance = "bold".len().min(normalized_len - norm_pos);
norm_pos += norm_advance;
if norm_advance == "bold".len() {
strip_pos += "bright".len();
} else {
strip_pos += norm_advance;
}
} else {
let ch_len = stripped[strip_pos..].chars().next().map_or(1, |c| c.len_utf8());
norm_pos += ch_len;
strip_pos += ch_len;
}
}
if strip_pos >= stripped.len() {
*stripped_to_orig.last().unwrap_or(&orig_pos)
} else {
let stripped_chars_consumed = stripped[..strip_pos].chars().count();
stripped_to_orig
.get(stripped_chars_consumed)
.copied()
.unwrap_or(orig_pos)
}
}
pub fn visible_len(s: &str) -> usize {
unicode_width::UnicodeWidthStr::width(strip_ansi(s).as_str())
}
#[cfg(test)]
fn available_colors() -> Vec<&'static str> {
vec![
"alert",
"bgblack",
"bgblue",
"bgcyan",
"bggreen",
"bgmagenta",
"bgpurple",
"bgred",
"bgwhite",
"bgyellow",
"black",
"blink",
"blue",
"bold",
"boldbgblack",
"boldbgblue",
"boldbgcyan",
"boldbggreen",
"boldbgmagenta",
"boldbgpurple",
"boldbgred",
"boldbgwhite",
"boldbgyellow",
"boldblack",
"boldblue",
"boldcyan",
"boldgreen",
"boldmagenta",
"boldpurple",
"boldred",
"boldwhite",
"boldyellow",
"chalkboard",
"clear",
"concealed",
"cyan",
"dark",
"default",
"error",
"flamingo",
"green",
"hotpants",
"italic",
"knightrider",
"led",
"magenta",
"negative",
"purple",
"rapidblink",
"red",
"redacted",
"reset",
"softpurple",
"strike",
"strikethrough",
"underline",
"underscore",
"white",
"whiteboard",
"yeller",
"yellow",
]
}
fn normalize_color_name(s: &str) -> String {
let normalized = s.replace('_', "").replace("bright", "bold");
if normalized.starts_with("bgbold") {
normalized.replacen("bgbold", "boldbg", 1)
} else {
normalized
}
}
fn parse_hex(s: &str) -> Option<Color> {
let (background, hex_str) = if let Some(rest) = s.strip_prefix("bg#").or_else(|| s.strip_prefix("b#")) {
(true, rest)
} else if let Some(rest) = s.strip_prefix("fg#").or_else(|| s.strip_prefix("f#")) {
(false, rest)
} else if let Some(rest) = s.strip_prefix('#') {
(false, rest)
} else {
return None;
};
if hex_str.len() != 6 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&hex_str[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex_str[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex_str[4..6], 16).ok()?;
Some(Color::Hex {
background,
b,
g,
r,
})
}
#[cfg(test)]
mod test {
use super::*;
mod available_colors {
use super::*;
#[test]
fn it_contains_basic_colors() {
let colors = available_colors();
assert!(colors.contains(&"red"));
assert!(colors.contains(&"green"));
assert!(colors.contains(&"blue"));
assert!(colors.contains(&"cyan"));
assert!(colors.contains(&"yellow"));
assert!(colors.contains(&"magenta"));
assert!(colors.contains(&"white"));
assert!(colors.contains(&"black"));
}
#[test]
fn it_contains_reset() {
let colors = available_colors();
assert!(colors.contains(&"reset"));
assert!(colors.contains(&"default"));
}
#[test]
fn it_returns_sorted_list() {
let colors = available_colors();
let mut sorted = colors.clone();
sorted.sort();
assert_eq!(colors, sorted);
}
}
mod color_parse {
use super::*;
#[test]
fn it_normalizes_bright_to_bold() {
let color = Color::parse("brightwhite");
assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
}
#[test]
fn it_normalizes_underscores() {
let color = Color::parse("bold_white");
assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
}
#[test]
fn it_parses_bold_color() {
let color = Color::parse("boldwhite");
assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
}
#[test]
fn it_parses_hex_background() {
let color = Color::parse("bg#00FF00");
assert_eq!(
color,
Some(Color::Hex {
background: true,
b: 0x00,
g: 0xFF,
r: 0x00,
})
);
}
#[test]
fn it_parses_hex_foreground() {
let color = Color::parse("#FF5500");
assert_eq!(
color,
Some(Color::Hex {
background: false,
b: 0x00,
g: 0x55,
r: 0xFF,
})
);
}
#[test]
fn it_parses_named_color() {
let color = Color::parse("cyan");
assert_eq!(color, Some(Color::Named(NamedColor::Cyan)));
}
#[test]
fn it_returns_none_for_invalid() {
assert_eq!(Color::parse("notacolor"), None);
}
}
mod color_to_ansi {
use super::*;
#[test]
fn it_emits_empty_when_disabled() {
yansi::disable();
let result = Color::Named(NamedColor::Cyan).to_ansi();
assert_eq!(result, "");
yansi::enable();
}
#[test]
fn it_emits_hex_background() {
yansi::enable();
let color = Color::Hex {
background: true,
b: 0x00,
g: 0xFF,
r: 0x00,
};
let result = color.to_ansi();
assert!(result.contains("48;2;0;255;0"), "expected RGB bg escape, got: {result}");
}
#[test]
fn it_emits_hex_foreground() {
yansi::enable();
let color = Color::Hex {
background: false,
b: 0x00,
g: 0x55,
r: 0xFF,
};
let result = color.to_ansi();
assert!(
result.contains("38;2;255;85;0"),
"expected RGB fg escape, got: {result}"
);
}
#[test]
fn it_emits_named_ansi() {
yansi::enable();
let result = Color::Named(NamedColor::Cyan).to_ansi();
assert!(result.contains("36"), "expected cyan code 36, got: {result}");
}
#[test]
fn it_emits_reset() {
yansi::enable();
let result = Color::Named(NamedColor::Reset).to_ansi();
assert_eq!(result, "\x1b[0m");
}
}
mod strip_ansi {
use pretty_assertions::assert_eq;
use super::super::strip_ansi;
#[test]
fn it_removes_escape_sequences() {
let input = "\x1b[36mhello\x1b[0m world";
assert_eq!(strip_ansi(input), "hello world");
}
#[test]
fn it_returns_plain_text_unchanged() {
assert_eq!(strip_ansi("hello"), "hello");
}
#[test]
fn it_strips_cursor_movement_sequences() {
let input = "\x1b[2Ahello\x1b[3Bworld";
assert_eq!(strip_ansi(input), "helloworld");
}
#[test]
fn it_strips_erase_sequences() {
let input = "hello\x1b[2Jworld";
assert_eq!(strip_ansi(input), "helloworld");
}
#[test]
fn it_strips_scroll_sequences() {
let input = "hello\x1b[1Sworld";
assert_eq!(strip_ansi(input), "helloworld");
}
}
mod validate_color {
use pretty_assertions::assert_eq;
use super::super::validate_color;
#[test]
fn it_finds_longest_prefix() {
assert_eq!(validate_color("boldbluefoo"), Some(("boldblue".into(), 8)));
}
#[test]
fn it_returns_correct_original_length_with_underscores() {
assert_eq!(validate_color("bold_white"), Some(("boldwhite".into(), 10)));
}
#[test]
fn it_returns_none_for_invalid() {
assert_eq!(validate_color("notacolor"), None);
}
#[test]
fn it_validates_bg_hex() {
assert_eq!(validate_color("bg#00FF00"), Some(("bg#00FF00".into(), 9)));
}
#[test]
fn it_validates_bold_color() {
assert_eq!(validate_color("boldwhite"), Some(("boldwhite".into(), 9)));
}
#[test]
fn it_validates_bright_red_with_underscore() {
assert_eq!(validate_color("bright_red"), Some(("boldred".into(), 10)));
}
#[test]
fn it_validates_hex() {
assert_eq!(validate_color("#FF5500"), Some(("#FF5500".into(), 7)));
}
#[test]
fn it_validates_simple_color() {
assert_eq!(validate_color("cyan"), Some(("cyan".into(), 4)));
}
}
mod visible_len {
use pretty_assertions::assert_eq;
use super::super::visible_len;
#[test]
fn it_counts_plain_text() {
assert_eq!(visible_len("hello"), 5);
}
#[test]
fn it_counts_cjk_characters_as_double_width() {
assert_eq!(visible_len("日本語"), 6);
}
#[test]
fn it_counts_emoji_as_double_width() {
assert_eq!(visible_len("🎉"), 2);
}
#[test]
fn it_excludes_ansi_codes() {
let input = "\x1b[36mhello\x1b[0m";
assert_eq!(visible_len(input), 5);
}
}
}