Skip to main content

linesmith_core/theme/
style_syntax.rs

1//! Style-string parser per `docs/specs/theming.md` §Style syntax.
2//! Accepts whitespace-separated tokens in any order:
3//!   - `role:<name>`  — sets `Style.role`
4//!   - `fg:<color>`   — sets `Style.fg` (hex / named / rgb() per [`parse_color`])
5//!   - `bold` / `italic` / `underline` / `dim` — decoration flags
6//!
7//! Empty or whitespace-only input yields `Style::default()`. All tokens
8//! are case-insensitive.
9
10use super::user::parse_color;
11use super::{Role, Style};
12
13/// Parse failures; each variant carries the offending input for diagnostics.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum StyleParseError {
17    UnknownRole(String),
18    InvalidFg(String),
19    UnknownToken(String),
20    MalformedRole(String),
21    MalformedFg(String),
22    UnclosedParen(String),
23    StrayCloseParen(String),
24}
25
26impl std::fmt::Display for StyleParseError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::UnknownRole(s) => write!(f, "unknown role '{s}'"),
30            Self::InvalidFg(s) => write!(f, "invalid fg color '{s}'"),
31            Self::UnknownToken(s) => write!(f, "unknown style token '{s}'"),
32            Self::MalformedRole(s) => write!(f, "malformed role directive '{s}'"),
33            Self::MalformedFg(s) => write!(f, "malformed fg directive '{s}'"),
34            Self::UnclosedParen(s) => write!(f, "unclosed paren in '{s}'"),
35            Self::StrayCloseParen(s) => write!(f, "stray ')' in '{s}'"),
36        }
37    }
38}
39
40impl std::error::Error for StyleParseError {}
41
42/// Parse a style string. Empty / whitespace-only yields the default
43/// style. Tokens may appear in any order; duplicate tokens overwrite
44/// (last-wins within a single string).
45pub fn parse_style(s: &str) -> Result<Style, StyleParseError> {
46    let mut style = Style::default();
47    for token in tokenize(s)? {
48        let (head, rest) = token.split_once(':').unzip();
49        let head = head.map(str::to_ascii_lowercase);
50        match head.as_deref() {
51            Some("role") => {
52                let name = rest.unwrap_or("");
53                if name.is_empty() {
54                    return Err(StyleParseError::MalformedRole(token.to_string()));
55                }
56                style.role = Some(parse_role(name)?);
57            }
58            Some("fg") => {
59                let color = rest.unwrap_or("");
60                if color.is_empty() {
61                    return Err(StyleParseError::MalformedFg(token.to_string()));
62                }
63                style.fg = Some(
64                    parse_color(color)
65                        .map_err(|_| StyleParseError::InvalidFg(color.to_string()))?,
66                );
67            }
68            _ => match token.to_ascii_lowercase().as_str() {
69                "bold" => style.bold = true,
70                "italic" => style.italic = true,
71                "underline" => style.underline = true,
72                "dim" => style.dim = true,
73                _ => return Err(StyleParseError::UnknownToken(token.to_string())),
74            },
75        }
76    }
77    Ok(style)
78}
79
80/// Split on whitespace, but treat parenthesized groups as atomic so
81/// `fg:rgb(203, 166, 247)` survives its internal spaces. Unclosed
82/// parens surface as `UnclosedParen`; stray `)` outside any group
83/// surfaces as `StrayCloseParen` rather than being silently dropped
84/// (otherwise `fg:rgb(1,2,3))` would reach `parse_color` as
85/// `rgb(1,2,3))` and surface a confusing `InvalidFg` instead).
86fn tokenize(s: &str) -> Result<Vec<&str>, StyleParseError> {
87    let mut tokens = Vec::new();
88    let mut start: Option<usize> = None;
89    let mut depth: u32 = 0;
90    for (i, c) in s.char_indices() {
91        if c.is_whitespace() && depth == 0 {
92            if let Some(s0) = start.take() {
93                tokens.push(&s[s0..i]);
94            }
95            continue;
96        }
97        if start.is_none() {
98            start = Some(i);
99        }
100        match c {
101            '(' => depth += 1,
102            ')' => {
103                if depth == 0 {
104                    let s0 = start.unwrap_or(i);
105                    return Err(StyleParseError::StrayCloseParen(s[s0..].to_string()));
106                }
107                depth -= 1;
108            }
109            _ => {}
110        }
111    }
112    if depth > 0 {
113        let offending = start.map(|s0| &s[s0..]).unwrap_or("");
114        return Err(StyleParseError::UnclosedParen(offending.to_string()));
115    }
116    if let Some(s0) = start {
117        tokens.push(&s[s0..]);
118    }
119    Ok(tokens)
120}
121
122fn parse_role(s: &str) -> Result<Role, StyleParseError> {
123    let role = match s.to_ascii_lowercase().as_str() {
124        "foreground" => Role::Foreground,
125        "background" => Role::Background,
126        "muted" => Role::Muted,
127        "primary" => Role::Primary,
128        "accent" => Role::Accent,
129        "success" => Role::Success,
130        "warning" => Role::Warning,
131        "error" => Role::Error,
132        "info" => Role::Info,
133        "success_dim" | "success-dim" => Role::SuccessDim,
134        "warning_dim" | "warning-dim" => Role::WarningDim,
135        "error_dim" | "error-dim" => Role::ErrorDim,
136        "primary_dim" | "primary-dim" => Role::PrimaryDim,
137        "accent_dim" | "accent-dim" => Role::AccentDim,
138        "surface" => Role::Surface,
139        "border" => Role::Border,
140        _ => return Err(StyleParseError::UnknownRole(s.to_string())),
141    };
142    Ok(role)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::theme::{AnsiColor, Color};
149
150    #[test]
151    fn empty_string_yields_default_style() {
152        assert_eq!(parse_style(""), Ok(Style::default()));
153    }
154
155    #[test]
156    fn whitespace_only_yields_default_style() {
157        assert_eq!(parse_style("   \t \n "), Ok(Style::default()));
158    }
159
160    #[test]
161    fn role_directive_sets_role() {
162        assert_eq!(parse_style("role:primary"), Ok(Style::role(Role::Primary)));
163    }
164
165    #[test]
166    fn role_plus_decorations_combine() {
167        let got = parse_style("role:success bold italic").expect("ok");
168        assert_eq!(got.role, Some(Role::Success));
169        assert!(got.bold);
170        assert!(got.italic);
171        assert!(!got.underline);
172        assert!(!got.dim);
173    }
174
175    #[test]
176    fn fg_hex_and_decoration() {
177        let got = parse_style("fg:#ff0000 underline").expect("ok");
178        assert_eq!(got.fg, Some(Color::TrueColor { r: 255, g: 0, b: 0 }));
179        assert_eq!(got.role, None);
180        assert!(got.underline);
181    }
182
183    #[test]
184    fn fg_named_color() {
185        let got = parse_style("fg:red").expect("ok");
186        assert_eq!(got.fg, Some(Color::Palette16(AnsiColor::Red)));
187    }
188
189    #[test]
190    fn fg_rgb_function() {
191        let got = parse_style("fg:rgb(203,166,247)").expect("ok");
192        assert_eq!(
193            got.fg,
194            Some(Color::TrueColor {
195                r: 203,
196                g: 166,
197                b: 247,
198            })
199        );
200    }
201
202    #[test]
203    fn fg_rgb_with_spaces_inside_parens_is_one_token() {
204        // Spec `docs/specs/theming.md` accepts `rgb(r, g, b)` with
205        // spaces; tokenizer must treat the parenthesized group as atomic.
206        let got = parse_style("fg:rgb(203, 166, 247) bold").expect("ok");
207        assert_eq!(
208            got.fg,
209            Some(Color::TrueColor {
210                r: 203,
211                g: 166,
212                b: 247,
213            })
214        );
215        assert!(got.bold);
216    }
217
218    #[test]
219    fn unclosed_paren_errors() {
220        match parse_style("fg:rgb(203,166 bold") {
221            Err(StyleParseError::UnclosedParen(s)) => {
222                assert!(s.starts_with("fg:rgb("), "got {s:?}");
223            }
224            other => panic!("expected UnclosedParen, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn stray_close_paren_errors_before_reaching_parse_color() {
230        // Without the eager check, `fg:rgb(1,2,3))` would reach
231        // `parse_color` as `rgb(1,2,3))` and surface as `InvalidFg`,
232        // masking the real structural defect.
233        match parse_style("fg:rgb(1,2,3))") {
234            Err(StyleParseError::StrayCloseParen(s)) => {
235                assert!(s.starts_with("fg:rgb("), "got {s:?}");
236            }
237            other => panic!("expected StrayCloseParen, got {other:?}"),
238        }
239    }
240
241    #[test]
242    fn bare_close_paren_errors() {
243        match parse_style("bold )") {
244            Err(StyleParseError::StrayCloseParen(_)) => {}
245            other => panic!("expected StrayCloseParen, got {other:?}"),
246        }
247    }
248
249    #[test]
250    fn nested_parens_are_one_token() {
251        // Tokenizer's depth counter must survive `depth > 1` without
252        // collapsing to boolean "inside/outside" semantics.
253        match parse_style("fg:rgb((1,2,3))") {
254            Err(StyleParseError::InvalidFg(_)) => {}
255            other => {
256                panic!("expected InvalidFg (parse_color rejects double parens), got {other:?}")
257            }
258        }
259    }
260
261    #[test]
262    fn case_insensitive_tokens() {
263        let got = parse_style("ROLE:PRIMARY BOLD ITALIC").expect("ok");
264        assert_eq!(got.role, Some(Role::Primary));
265        assert!(got.bold);
266        assert!(got.italic);
267    }
268
269    #[test]
270    fn mixed_case_directive_prefix_parses() {
271        // Module doc promises case-insensitive tokens. `Role:` / `Fg:`
272        // mixed-case prefixes must parse the same as the lowercase form.
273        assert_eq!(
274            parse_style("Role:Accent Fg:#ff0000").expect("ok"),
275            Style {
276                role: Some(Role::Accent),
277                fg: Some(Color::TrueColor { r: 255, g: 0, b: 0 }),
278                ..Style::default()
279            }
280        );
281    }
282
283    #[test]
284    fn order_does_not_matter() {
285        let a = parse_style("role:info bold italic").expect("ok");
286        let b = parse_style("italic bold role:info").expect("ok");
287        let c = parse_style("bold role:info italic").expect("ok");
288        assert_eq!(a, b);
289        assert_eq!(b, c);
290    }
291
292    #[test]
293    fn all_four_decorations_compose() {
294        let got = parse_style("bold italic underline dim").expect("ok");
295        assert!(got.bold);
296        assert!(got.italic);
297        assert!(got.underline);
298        assert!(got.dim);
299    }
300
301    #[test]
302    fn extended_role_with_underscore_and_hyphen_both_work() {
303        assert_eq!(
304            parse_style("role:success_dim").unwrap().role,
305            Some(Role::SuccessDim)
306        );
307        assert_eq!(
308            parse_style("role:success-dim").unwrap().role,
309            Some(Role::SuccessDim)
310        );
311    }
312
313    #[test]
314    fn unknown_role_errors_with_input() {
315        match parse_style("role:mauve") {
316            Err(StyleParseError::UnknownRole(s)) => assert_eq!(s, "mauve"),
317            other => panic!("expected UnknownRole, got {other:?}"),
318        }
319    }
320
321    #[test]
322    fn invalid_fg_errors_with_input() {
323        match parse_style("fg:notacolor") {
324            Err(StyleParseError::InvalidFg(s)) => assert_eq!(s, "notacolor"),
325            other => panic!("expected InvalidFg, got {other:?}"),
326        }
327    }
328
329    #[test]
330    fn unknown_token_errors() {
331        match parse_style("role:primary wobbly") {
332            Err(StyleParseError::UnknownToken(s)) => assert_eq!(s, "wobbly"),
333            other => panic!("expected UnknownToken, got {other:?}"),
334        }
335    }
336
337    #[test]
338    fn malformed_role_directive_errors() {
339        match parse_style("role: bold") {
340            Err(StyleParseError::MalformedRole(s)) => assert_eq!(s, "role:"),
341            other => panic!("expected MalformedRole, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn malformed_fg_directive_errors() {
347        match parse_style("fg:") {
348            Err(StyleParseError::MalformedFg(s)) => assert_eq!(s, "fg:"),
349            other => panic!("expected MalformedFg, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn parser_populates_both_fg_and_role_when_both_specified() {
355        // Render-time precedence (fg outranks role) is tested in
356        // `theme::sgr_open`; here we pin the parser shape only.
357        let got = parse_style("role:primary fg:#ff8800 bold").expect("ok");
358        assert_eq!(got.role, Some(Role::Primary));
359        assert_eq!(
360            got.fg,
361            Some(Color::TrueColor {
362                r: 255,
363                g: 136,
364                b: 0
365            })
366        );
367        assert!(got.bold);
368    }
369
370    #[test]
371    fn duplicate_role_token_last_wins() {
372        assert_eq!(
373            parse_style("role:primary role:accent").unwrap().role,
374            Some(Role::Accent)
375        );
376    }
377
378    #[test]
379    fn duplicate_fg_token_last_wins() {
380        let got = parse_style("fg:#ff0000 fg:#00ff00").expect("ok");
381        assert_eq!(got.fg, Some(Color::TrueColor { r: 0, g: 255, b: 0 }));
382    }
383
384    #[test]
385    fn duplicate_decoration_token_is_idempotent() {
386        let got = parse_style("bold bold").expect("ok");
387        assert!(got.bold);
388    }
389
390    #[test]
391    fn error_display_quotes_offending_input() {
392        let err = StyleParseError::UnknownRole("mauve".into());
393        assert_eq!(err.to_string(), "unknown role 'mauve'");
394        let err = StyleParseError::InvalidFg("xyz".into());
395        assert_eq!(err.to_string(), "invalid fg color 'xyz'");
396    }
397}