Skip to main content

tess/
style_spec.rs

1//! Parser for the `--status-style` / `--prompt-style` CLI flags and the
2//! per-format `prompt_style` config key. Maps a comma-separated token list
3//! onto an `ansi::Style`.
4//!
5//! Grammar:
6//! ```text
7//! spec   := token ("," token)*
8//! token  := attr | "fg=" color | "bg=" color
9//! attr   := bold | dim | italic | underline | reverse
10//! color  := name | "#RRGGBB" | "0".."255"
11//! name   := ("bright-")? (black|red|green|yellow|blue|magenta|cyan|white)
12//! ```
13//!
14//! Empty input is treated as "no styling" and returns `Ok(Style::default())`.
15
16use crate::ansi::{Color, Style};
17
18#[derive(Debug, PartialEq, Eq)]
19pub enum ParseError {
20    UnknownAttr(String),
21    UnknownColor(String),
22    BadHex(String),
23    BadToken(String),
24}
25
26impl std::fmt::Display for ParseError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            ParseError::UnknownAttr(t) => write!(f, "unknown attribute `{t}`"),
30            ParseError::UnknownColor(t) => write!(f, "unknown color `{t}`"),
31            ParseError::BadHex(t) => write!(f, "bad hex color `{t}` (expected #RRGGBB)"),
32            ParseError::BadToken(t) => write!(f, "bad token `{t}`"),
33        }
34    }
35}
36
37/// Parse a style spec into an `ansi::Style`. Empty / whitespace-only input
38/// returns `Style::default()` so callers can treat empty CLI flags as "off".
39pub fn parse(spec: &str) -> Result<Style, ParseError> {
40    let mut style = Style::default();
41    let trimmed = spec.trim();
42    if trimmed.is_empty() {
43        return Ok(style);
44    }
45    for raw in trimmed.split(',') {
46        let tok = raw.trim();
47        if tok.is_empty() {
48            continue;
49        }
50        if let Some(c) = tok.strip_prefix("fg=") {
51            style.fg = Some(parse_color(c)?);
52        } else if let Some(c) = tok.strip_prefix("bg=") {
53            style.bg = Some(parse_color(c)?);
54        } else {
55            match tok {
56                "bold" => style.bold = true,
57                "dim" => style.dim = true,
58                "italic" => style.italic = true,
59                "underline" => style.underline = true,
60                "reverse" => style.reverse = true,
61                other if other.contains('=') => {
62                    return Err(ParseError::BadToken(other.to_string()))
63                }
64                other => return Err(ParseError::UnknownAttr(other.to_string())),
65            }
66        }
67    }
68    Ok(style)
69}
70
71fn parse_color(s: &str) -> Result<Color, ParseError> {
72    if let Some(hex) = s.strip_prefix('#') {
73        if hex.len() != 6 {
74            return Err(ParseError::BadHex(s.to_string()));
75        }
76        let r = u8::from_str_radix(&hex[0..2], 16)
77            .map_err(|_| ParseError::BadHex(s.to_string()))?;
78        let g = u8::from_str_radix(&hex[2..4], 16)
79            .map_err(|_| ParseError::BadHex(s.to_string()))?;
80        let b = u8::from_str_radix(&hex[4..6], 16)
81            .map_err(|_| ParseError::BadHex(s.to_string()))?;
82        return Ok(Color::Rgb(r, g, b));
83    }
84    if let Ok(n) = s.parse::<u8>() {
85        return Ok(if n < 16 { Color::Ansi(n) } else { Color::Indexed(n) });
86    }
87    let (bright, name) = match s.strip_prefix("bright-") {
88        Some(rest) => (true, rest),
89        None => (false, s),
90    };
91    let base: u8 = match name {
92        "black" => 0,
93        "red" => 1,
94        "green" => 2,
95        "yellow" => 3,
96        "blue" => 4,
97        "magenta" => 5,
98        "cyan" => 6,
99        "white" => 7,
100        _ => return Err(ParseError::UnknownColor(s.to_string())),
101    };
102    Ok(Color::Ansi(if bright { base + 8 } else { base }))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn empty_is_default_style() {
111        assert_eq!(parse("").unwrap(), Style::default());
112        assert_eq!(parse("   ").unwrap(), Style::default());
113    }
114
115    #[test]
116    fn bold_only() {
117        let s = parse("bold").unwrap();
118        assert!(s.bold);
119        assert!(s.fg.is_none());
120    }
121
122    #[test]
123    fn fg_named() {
124        let s = parse("fg=cyan").unwrap();
125        assert_eq!(s.fg, Some(Color::Ansi(6)));
126    }
127
128    #[test]
129    fn fg_bright_named() {
130        let s = parse("fg=bright-red").unwrap();
131        assert_eq!(s.fg, Some(Color::Ansi(9)));
132    }
133
134    #[test]
135    fn bg_hex() {
136        let s = parse("bg=#ff0080").unwrap();
137        assert_eq!(s.bg, Some(Color::Rgb(0xff, 0x00, 0x80)));
138    }
139
140    #[test]
141    fn fg_indexed_under_16_is_ansi() {
142        let s = parse("fg=4").unwrap();
143        assert_eq!(s.fg, Some(Color::Ansi(4)));
144    }
145
146    #[test]
147    fn fg_indexed_over_16_is_palette() {
148        let s = parse("fg=200").unwrap();
149        assert_eq!(s.fg, Some(Color::Indexed(200)));
150    }
151
152    #[test]
153    fn combined_attrs_and_colors() {
154        let s = parse("bold,fg=cyan,bg=black").unwrap();
155        assert!(s.bold);
156        assert_eq!(s.fg, Some(Color::Ansi(6)));
157        assert_eq!(s.bg, Some(Color::Ansi(0)));
158    }
159
160    #[test]
161    fn reverse_attr() {
162        let s = parse("reverse").unwrap();
163        assert!(s.reverse);
164    }
165
166    #[test]
167    fn bad_hex_errors() {
168        assert!(matches!(parse("fg=#12"), Err(ParseError::BadHex(_))));
169        assert!(matches!(parse("fg=#xxxxxx"), Err(ParseError::BadHex(_))));
170    }
171
172    #[test]
173    fn unknown_attr_errors() {
174        assert!(matches!(parse("blink"), Err(ParseError::UnknownAttr(_))));
175    }
176
177    #[test]
178    fn unknown_color_name_errors() {
179        assert!(matches!(parse("fg=puce"), Err(ParseError::UnknownColor(_))));
180    }
181
182    #[test]
183    fn trailing_comma_is_tolerated() {
184        let s = parse("bold,").unwrap();
185        assert!(s.bold);
186    }
187}