Skip to main content

standout_render/style/
css_parser.rs

1//! CSS stylesheet parsing.
2//!
3//! # Motivation
4//!
5//! While YAML is excellent for structured data, it can be verbose for defining style rules.
6//! CSS is the industry standard for styling, offering a syntax that is both familiar
7//! to developers and concise for defining visual attributes.
8//!
9//! By supporting CSS, `standout` allows developers to leverage their existing knowledge
10//! and potentially use standard tooling (like syntax highlighters) to define their terminal
11//! themes.
12//!
13//! # Design
14//!
15//! This module implements a subset of CSS level 3, tailored for terminal styling.
16//! It maps CSS selectors to `standout` style types and CSS properties to
17//! terminal attributes (ANSI codes).
18//!
19//! The parser is built on top of `cssparser` (the same tokenizer used by Firefox),
20//! ensuring robust handling of syntax, comments, and escapes.
21//!
22//! ## Mapping
23//!
24//! - Selectors: CSS class selectors (`.my-style`) map directly to style names in the theme.
25//!   Currently, simple class selectors are supported.
26//!   - `.error` -> defines style "error"
27//!   - `.title, .header` -> defines styles "title" and "header"
28//!
29//! - Properties: Standard CSS properties are mapped to terminal equivalents.
30//!   - `color` -> Foreground color
31//!   - `background-color` -> Background color
32//!   - `font-weight: bold` -> Bold text
33//!   - `text-decoration: underline` -> Underlined text
34//!   - `visibility: hidden` -> Hidden text
35//!
36//! - Adaptive Styles: Media queries are used to define light/dark mode overrides.
37//!   - `@media (prefers-color-scheme: dark) { ... }`
38//!
39//! # Supported Attributes
40//!
41//! The following properties are supported:
42//!
43//! | CSS Property | Value | Effect |
44//! |--------------|-------|--------|
45//! | `color`, `fg` | Color (Hex, Named, Integer) | Sets the text color |
46//! | `background-color`, `bg` | Color (Hex, Named, Integer) | Sets the background color |
47//! | `font-weight` | `bold` | Makes text bold |
48//! | `font-style` | `italic` | Makes text *italic* |
49//! | `text-decoration` | `underline`, `line-through` | Underlines or strikes through text |
50//! | `visibility` | `hidden` | Hides the text |
51//! | `bold`, `italic`, `dim`, `blink`, `reverse`, `hidden` | `true`, `false` | Direct control over ANSI flags |
52//!
53//! # Example
54//!
55//! ```css
56//! /* Base styles applied to all themes */
57//! .title {
58//!     font-weight: bold;
59//!     color: #ff00ff; /* Magenta */
60//! }
61//!
62//! .error {
63//!     color: red;
64//!     font-weight: bold;
65//! }
66//!
67//! /* Semantic alias */
68//! .critical {
69//!     color: red;
70//!     text-decoration: underline;
71//!     animation: blink; /* parsing 'blink' property directly is also supported */
72//! }
73//!
74//! /* Adaptive Overrides */
75//! @media (prefers-color-scheme: dark) {
76//!     .title {
77//!         color: #ffcccc; /* Lighter magenta for dark backgrounds */
78//!     }
79//! }
80//!
81//! @media (prefers-color-scheme: light) {
82//!     .title {
83//!         color: #880088; /* Darker magenta for light backgrounds */
84//!     }
85//! }
86//! ```
87//!
88use std::collections::HashMap;
89
90use cssparser::{
91    AtRuleParser, CowRcStr, DeclarationParser, ParseError, Parser, ParserInput, ParserState,
92    QualifiedRuleParser, RuleBodyItemParser, RuleBodyParser, Token,
93};
94
95use super::attributes::StyleAttributes;
96use super::color::ColorDef;
97use super::definition::StyleDefinition;
98use super::error::StylesheetError;
99use super::parser::{build_variants, ThemeVariants};
100
101/// Parses a CSS stylesheet and builds theme variants.
102pub fn parse_css(css: &str) -> Result<ThemeVariants, StylesheetError> {
103    let mut input = ParserInput::new(css);
104    let mut parser = Parser::new(&mut input);
105
106    let mut css_parser = StyleSheetParser {
107        definitions: HashMap::new(),
108        current_mode: None,
109    };
110
111    let rule_list_parser = cssparser::StyleSheetParser::new(&mut parser, &mut css_parser);
112
113    for result in rule_list_parser {
114        if let Err(e) = result {
115            // For now, simpler error conversion.
116            return Err(StylesheetError::Parse {
117                path: None,
118                message: format!("CSS Parse Error: {:?}", e),
119            });
120        }
121    }
122
123    build_variants(&css_parser.definitions)
124}
125
126struct StyleSheetParser {
127    definitions: HashMap<String, StyleDefinition>,
128    current_mode: Option<Mode>,
129}
130
131#[derive(Clone, Copy, PartialEq, Eq)]
132enum Mode {
133    Light,
134    Dark,
135}
136
137impl<'i> QualifiedRuleParser<'i> for StyleSheetParser {
138    type Prelude = Vec<String>;
139    type QualifiedRule = ();
140    type Error = ();
141
142    fn parse_prelude<'t>(
143        &mut self,
144        input: &mut Parser<'i, 't>,
145    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
146        let mut names = Vec::new();
147
148        while let Ok(token) = input.next() {
149            match token {
150                Token::Delim('.') => {
151                    let name = input.expect_ident()?;
152                    names.push(name.as_ref().to_string());
153                }
154                Token::Comma | Token::WhiteSpace(_) => continue,
155                _ => {
156                    // Ignore other tokens
157                }
158            }
159        }
160
161        if names.is_empty() {
162            return Err(input.new_custom_error::<(), ()>(()));
163        }
164        Ok(names)
165    }
166
167    fn parse_block<'t>(
168        &mut self,
169        prelude: Self::Prelude,
170        _start: &ParserState,
171        input: &mut Parser<'i, 't>,
172    ) -> Result<Self::QualifiedRule, ParseError<'i, Self::Error>> {
173        let mut decl_parser = StyleDeclarationParser;
174        let rule_parser = RuleBodyParser::new(input, &mut decl_parser);
175
176        let mut attributes = StyleAttributes::new();
177
178        for (_prop, val) in rule_parser.flatten() {
179            if let Some(c) = val.fg {
180                attributes.fg = Some(c);
181            }
182            if let Some(c) = val.bg {
183                attributes.bg = Some(c);
184            }
185            if let Some(b) = val.bold {
186                attributes.bold = Some(b);
187            }
188            if let Some(v) = val.dim {
189                attributes.dim = Some(v);
190            }
191            if let Some(v) = val.italic {
192                attributes.italic = Some(v);
193            }
194            if let Some(v) = val.underline {
195                attributes.underline = Some(v);
196            }
197            if let Some(v) = val.blink {
198                attributes.blink = Some(v);
199            }
200            if let Some(v) = val.reverse {
201                attributes.reverse = Some(v);
202            }
203            if let Some(v) = val.hidden {
204                attributes.hidden = Some(v);
205            }
206            if let Some(v) = val.strikethrough {
207                attributes.strikethrough = Some(v);
208            }
209        }
210
211        for name in prelude {
212            let def = self
213                .definitions
214                .entry(name)
215                .or_insert(StyleDefinition::Attributes {
216                    base: StyleAttributes::new(),
217                    light: None,
218                    dark: None,
219                });
220
221            if let StyleDefinition::Attributes {
222                ref mut base,
223                ref mut light,
224                ref mut dark,
225            } = def
226            {
227                match self.current_mode {
228                    None => *base = base.merge(&attributes),
229                    Some(Mode::Light) => {
230                        let l = light.get_or_insert(StyleAttributes::new());
231                        *l = l.merge(&attributes);
232                    }
233                    Some(Mode::Dark) => {
234                        let d = dark.get_or_insert(StyleAttributes::new());
235                        *d = d.merge(&attributes);
236                    }
237                }
238            }
239        }
240        Ok(())
241    }
242}
243
244impl<'i> AtRuleParser<'i> for StyleSheetParser {
245    type Prelude = Mode;
246    type AtRule = ();
247    type Error = ();
248
249    fn parse_prelude<'t>(
250        &mut self,
251        name: CowRcStr<'i>,
252        input: &mut Parser<'i, 't>,
253    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
254        if name.as_ref() == "media" {
255            // Peek and parse blocks
256            let mut found_mode: Option<Mode> = None;
257
258            loop {
259                match input.next() {
260                    Ok(Token::ParenthesisBlock) => {
261                        // We consumed ParenthesisBlock. Now we can call parse_nested_block.
262                        let nested_res = input.parse_nested_block(|input| {
263                            input.expect_ident_matching("prefers-color-scheme")?;
264                            input.expect_colon()?;
265                            let val = input.expect_ident()?;
266                            match val.as_ref() {
267                                "dark" => Ok(Mode::Dark),
268                                "light" => Ok(Mode::Light),
269                                _ => Err(input.new_custom_error::<(), ()>(())),
270                            }
271                        });
272                        if let Ok(m) = nested_res {
273                            found_mode = Some(m);
274                        }
275                    }
276                    Ok(Token::WhiteSpace(_)) | Ok(Token::Comment(_)) => continue,
277                    Err(_) => break, // End of input
278                    Ok(_) => {
279                        // Ignore other tokens
280                    }
281                }
282            }
283
284            if let Some(m) = found_mode {
285                return Ok(m);
286            }
287
288            Err(input.new_custom_error::<(), ()>(()))
289        } else {
290            Err(input.new_custom_error::<(), ()>(()))
291        }
292    }
293
294    fn parse_block<'t>(
295        &mut self,
296        mode: Self::Prelude,
297        _start: &ParserState,
298        input: &mut Parser<'i, 't>,
299    ) -> Result<Self::AtRule, ParseError<'i, Self::Error>> {
300        let old_mode = self.current_mode;
301        self.current_mode = Some(mode);
302
303        let list_parser = cssparser::StyleSheetParser::new(input, self);
304        for _ in list_parser {}
305
306        self.current_mode = old_mode;
307        Ok(())
308    }
309}
310
311struct StyleDeclarationParser;
312
313impl<'i> DeclarationParser<'i> for StyleDeclarationParser {
314    type Declaration = (String, StyleAttributes);
315    type Error = ();
316
317    fn parse_value<'t>(
318        &mut self,
319        name: CowRcStr<'i>,
320        input: &mut Parser<'i, 't>,
321    ) -> Result<Self::Declaration, ParseError<'i, Self::Error>> {
322        let mut attrs = StyleAttributes::new();
323        match name.as_ref() {
324            "fg" | "color" => {
325                attrs.fg = Some(parse_color(input)?);
326            }
327            "bg" | "background" | "background-color" => {
328                attrs.bg = Some(parse_color(input)?);
329            }
330            "bold" => {
331                if parse_bool_or_flag(input)? {
332                    attrs.bold = Some(true);
333                }
334            }
335            "dim" => {
336                if parse_bool_or_flag(input)? {
337                    attrs.dim = Some(true);
338                }
339            }
340            "italic" => {
341                if parse_bool_or_flag(input)? {
342                    attrs.italic = Some(true);
343                }
344            }
345            "underline" => {
346                if parse_bool_or_flag(input)? {
347                    attrs.underline = Some(true);
348                }
349            }
350            "blink" => {
351                if parse_bool_or_flag(input)? {
352                    attrs.blink = Some(true);
353                }
354            }
355            "reverse" => {
356                if parse_bool_or_flag(input)? {
357                    attrs.reverse = Some(true);
358                }
359            }
360            "hidden" => {
361                if parse_bool_or_flag(input)? {
362                    attrs.hidden = Some(true);
363                }
364            }
365            "strikethrough" => {
366                if parse_bool_or_flag(input)? {
367                    attrs.strikethrough = Some(true);
368                }
369            }
370
371            "font-weight" => {
372                let val = input.expect_ident()?;
373                if val.as_ref() == "bold" {
374                    attrs.bold = Some(true);
375                }
376            }
377            "font-style" => {
378                let val = input.expect_ident()?;
379                if val.as_ref() == "italic" {
380                    attrs.italic = Some(true);
381                }
382            }
383            "text-decoration" => {
384                let val = input.expect_ident()?;
385                match val.as_ref() {
386                    "underline" => attrs.underline = Some(true),
387                    "line-through" => attrs.strikethrough = Some(true),
388                    _ => {}
389                }
390            }
391            "visibility" => {
392                let val = input.expect_ident()?;
393                if val.as_ref() == "hidden" {
394                    attrs.hidden = Some(true);
395                }
396            }
397
398            _ => return Err(input.new_custom_error::<(), ()>(())),
399        }
400        Ok((name.as_ref().to_string(), attrs))
401    }
402}
403
404impl<'i> AtRuleParser<'i> for StyleDeclarationParser {
405    type Prelude = ();
406    type AtRule = (String, StyleAttributes);
407    type Error = ();
408}
409
410impl<'i> QualifiedRuleParser<'i> for StyleDeclarationParser {
411    type Prelude = ();
412    type QualifiedRule = (String, StyleAttributes);
413    type Error = ();
414}
415
416impl<'i> RuleBodyItemParser<'i, (String, StyleAttributes), ()> for StyleDeclarationParser {
417    fn parse_declarations(&self) -> bool {
418        true
419    }
420    fn parse_qualified(&self) -> bool {
421        false
422    }
423}
424
425fn parse_color<'i, 't>(input: &mut Parser<'i, 't>) -> Result<ColorDef, ParseError<'i, ()>> {
426    let token = match input.next() {
427        Ok(t) => t,
428        Err(_) => return Err(input.new_custom_error::<(), ()>(())),
429    };
430
431    match token {
432        Token::Ident(name) => {
433            ColorDef::parse_string(name.as_ref()).map_err(|_| input.new_custom_error::<(), ()>(()))
434        }
435        Token::Hash(val) | Token::IDHash(val) => ColorDef::parse_string(&format!("#{}", val))
436            .map_err(|_| input.new_custom_error::<(), ()>(())),
437        _ => Err(input.new_custom_error::<(), ()>(())),
438    }
439}
440
441fn parse_bool_or_flag<'i, 't>(input: &mut Parser<'i, 't>) -> Result<bool, ParseError<'i, ()>> {
442    match input.expect_ident() {
443        Ok(val) => Ok(val.as_ref() == "true"),
444        Err(_) => Err(input.new_custom_error::<(), ()>(())),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{ColorMode, StyleValue};
452
453    #[test]
454    fn test_parse_simple() {
455        let css = ".error { color: red; font-weight: bold; }";
456        let variants = parse_css(css).unwrap();
457        let base = variants.base();
458
459        // Ensure "error" style exists
460        assert!(base.contains_key("error"));
461
462        let style = base.get("error").unwrap().clone().force_styling(true);
463        let styled = style.apply_to("text").to_string();
464        // Check for red (31) and bold (1).
465        assert!(styled.contains("\x1b[31m"));
466        assert!(styled.contains("\x1b[1m"));
467    }
468
469    #[test]
470    fn test_parse_adaptive() {
471        let css =
472            ".text { color: red; } @media (prefers-color-scheme: dark) { .text { color: white; } }";
473        let variants = parse_css(css).unwrap();
474
475        let light = variants.resolve(Some(ColorMode::Light));
476        let dark = variants.resolve(Some(ColorMode::Dark));
477
478        // Light (base) -> Red
479        if let StyleValue::Concrete(s) = light.get("text").unwrap() {
480            let out = s.clone().force_styling(true).apply_to("x").to_string();
481            assert!(out.contains("\x1b[31m")); // Red
482        } else {
483            panic!("Expected Concrete style for light mode");
484        }
485
486        // Dark -> White
487        if let StyleValue::Concrete(s) = dark.get("text").unwrap() {
488            let out = s.clone().force_styling(true).apply_to("x").to_string();
489            assert!(out.contains("\x1b[37m")); // White
490        } else {
491            panic!("Expected Concrete style for dark mode");
492        }
493    }
494
495    #[test]
496    fn test_multiple_selectors() {
497        let css = ".a, .b { color: blue; }";
498        let variants = parse_css(css).unwrap();
499        let base = variants.base();
500        assert!(base.contains_key("a"));
501        assert!(base.contains_key("b"));
502    }
503
504    #[test]
505    fn test_all_properties() {
506        let css = r#"
507        .all-props {
508            fg: red;
509            bg: blue;
510            bold: true;
511            dim: true;
512            italic: true;
513            underline: true;
514            blink: true;
515            reverse: true;
516            hidden: true;
517            strikethrough: true;
518        }
519        "#;
520        let variants = parse_css(css).unwrap();
521        let base = variants.base();
522        assert!(base.contains_key("all-props"));
523
524        // We can't easily inspect the attributes directly without making fields public
525        // or adding accessors to StyleValue/StyleAttributes.
526        // But successful parsing covers the code paths.
527        // We can verify effect by applying to string.
528        let style = base.get("all-props").unwrap().clone().force_styling(true);
529        let out = style.apply_to("text").to_string();
530
531        assert!(out.contains("\x1b[31m")); // fg red
532        assert!(out.contains("\x1b[44m")); // bg blue
533        assert!(out.contains("\x1b[1m")); // bold
534        assert!(out.contains("\x1b[2m")); // dim
535        assert!(out.contains("\x1b[3m")); // italic
536        assert!(out.contains("\x1b[4m")); // underline
537        assert!(out.contains("\x1b[5m")); // blink
538        assert!(out.contains("\x1b[7m")); // reverse
539        assert!(out.contains("\x1b[8m")); // hidden
540        assert!(out.contains("\x1b[9m")); // strikethrough
541    }
542
543    #[test]
544    fn test_css_aliases() {
545        let css = r#"
546        .aliases {
547            background-color: green;
548            font-weight: bold;
549            font-style: italic;
550            text-decoration: underline;
551            visibility: hidden;
552        }
553        "#;
554        let variants = parse_css(css).unwrap();
555        let base = variants.base();
556        let style = base.get("aliases").unwrap().clone().force_styling(true);
557        let out = style.apply_to("text").to_string();
558
559        assert!(out.contains("\x1b[42m")); // bg green
560        assert!(out.contains("\x1b[1m")); // bold
561        assert!(out.contains("\x1b[3m")); // italic
562        assert!(out.contains("\x1b[4m")); // underline
563        assert!(out.contains("\x1b[8m")); // hidden
564    }
565
566    #[test]
567    fn test_text_decoration_line_through() {
568        let css = ".strike { text-decoration: line-through; }";
569        let variants = parse_css(css).unwrap();
570        let style = variants
571            .base()
572            .get("strike")
573            .unwrap()
574            .clone()
575            .force_styling(true);
576        let out = style.apply_to("text").to_string();
577        assert!(out.contains("\x1b[9m"));
578    }
579
580    #[test]
581    fn test_invalid_syntax_recovery() {
582        // missing colon, invalid values, unknown properties should not panic
583        let css = r#"
584        .broken {
585            color: ;
586            unknown: prop;
587            bold: not-a-bool;
588        }
589        .valid { color: cyan; }
590        "#;
591
592        // cssparser is robust and may skip invalid declarations
593        let variants = parse_css(css).unwrap();
594        assert!(variants.base().contains_key("valid"));
595    }
596
597    #[test]
598    fn test_empty_selector_error() {
599        // Just dots without name
600        let css = ". { color: red; }";
601        let res = parse_css(css);
602        assert!(res.is_err());
603    }
604
605    #[test]
606    fn test_no_dot_selector() {
607        // Tag selector not supported, should skip or error
608        let css = "body { color: red; }";
609        // Our parser expects '.' delimiters in parse_prelude.
610        // If it doesn't find '.', it consumes tokens.
611        // If names is empty, it returns error.
612        let res = parse_css(css);
613        assert!(res.is_err());
614    }
615
616    #[test]
617    fn test_invalid_color() {
618        let css = ".bad-color { color: not-a-color; }";
619        // Should ignore the invalid property but parse the rule
620        let variants = parse_css(css).unwrap();
621        assert!(variants.base().contains_key("bad-color"));
622    }
623
624    #[test]
625    fn test_hex_colors() {
626        let css = ".hex { color: #ff0000; bg: #00ff00; }";
627        let variants = parse_css(css).unwrap();
628        let style = variants.base().get("hex").unwrap();
629        let out = style.apply_to("x").to_string();
630        // Just verify it parsed something, specific hex to ansi conversion depends on color support
631        assert!(!out.is_empty());
632    }
633
634    #[test]
635    fn test_comments() {
636        let css = r#"
637        /* This is a comment */
638        .commented {
639            color: red; /* Inline comment */
640        }
641        "#;
642        let variants = parse_css(css).unwrap();
643        assert!(variants.base().contains_key("commented"));
644    }
645
646    use proptest::prelude::*;
647
648    proptest! {
649        #[test]
650        fn test_random_css_input_no_panic(s in "\\PC*") {
651            // Should never panic, even with garbage input
652            let _ = parse_css(&s);
653        }
654
655        #[test]
656        fn test_valid_structure_random_values(
657            color in "[a-zA-Z]+",
658            bool_val in "true|false",
659            prop_name in "[a-z-]+"
660        ) {
661            let css = format!(".prop {{ color: {}; bold: {}; {}: {}; }}", color, bool_val, prop_name, bool_val);
662            let _ = parse_css(&css);
663        }
664    }
665}