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(
103    css: &str,
104    palette: Option<&crate::colorspace::ThemePalette>,
105) -> Result<ThemeVariants, StylesheetError> {
106    let mut input = ParserInput::new(css);
107    let mut parser = Parser::new(&mut input);
108
109    let mut css_parser = StyleSheetParser {
110        definitions: HashMap::new(),
111        current_mode: None,
112    };
113
114    let rule_list_parser = cssparser::StyleSheetParser::new(&mut parser, &mut css_parser);
115
116    for result in rule_list_parser {
117        if let Err(e) = result {
118            // For now, simpler error conversion.
119            return Err(StylesheetError::Parse {
120                path: None,
121                message: format!("CSS Parse Error: {:?}", e),
122            });
123        }
124    }
125
126    build_variants(&css_parser.definitions, palette)
127}
128
129struct StyleSheetParser {
130    definitions: HashMap<String, StyleDefinition>,
131    current_mode: Option<Mode>,
132}
133
134#[derive(Clone, Copy, PartialEq, Eq)]
135enum Mode {
136    Light,
137    Dark,
138}
139
140impl<'i> QualifiedRuleParser<'i> for StyleSheetParser {
141    type Prelude = Vec<String>;
142    type QualifiedRule = ();
143    type Error = ();
144
145    fn parse_prelude<'t>(
146        &mut self,
147        input: &mut Parser<'i, 't>,
148    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
149        let mut names = Vec::new();
150
151        while let Ok(token) = input.next() {
152            match token {
153                Token::Delim('.') => {
154                    let name = input.expect_ident()?;
155                    names.push(name.as_ref().to_string());
156                }
157                Token::Comma | Token::WhiteSpace(_) => continue,
158                _ => {
159                    // Ignore other tokens
160                }
161            }
162        }
163
164        if names.is_empty() {
165            return Err(input.new_custom_error::<(), ()>(()));
166        }
167        Ok(names)
168    }
169
170    fn parse_block<'t>(
171        &mut self,
172        prelude: Self::Prelude,
173        _start: &ParserState,
174        input: &mut Parser<'i, 't>,
175    ) -> Result<Self::QualifiedRule, ParseError<'i, Self::Error>> {
176        let mut decl_parser = StyleDeclarationParser;
177        let rule_parser = RuleBodyParser::new(input, &mut decl_parser);
178
179        let mut attributes = StyleAttributes::new();
180
181        for (_prop, val) in rule_parser.flatten() {
182            if let Some(c) = val.fg {
183                attributes.fg = Some(c);
184            }
185            if let Some(c) = val.bg {
186                attributes.bg = Some(c);
187            }
188            if let Some(b) = val.bold {
189                attributes.bold = Some(b);
190            }
191            if let Some(v) = val.dim {
192                attributes.dim = Some(v);
193            }
194            if let Some(v) = val.italic {
195                attributes.italic = Some(v);
196            }
197            if let Some(v) = val.underline {
198                attributes.underline = Some(v);
199            }
200            if let Some(v) = val.blink {
201                attributes.blink = Some(v);
202            }
203            if let Some(v) = val.reverse {
204                attributes.reverse = Some(v);
205            }
206            if let Some(v) = val.hidden {
207                attributes.hidden = Some(v);
208            }
209            if let Some(v) = val.strikethrough {
210                attributes.strikethrough = Some(v);
211            }
212        }
213
214        for name in prelude {
215            let def = self
216                .definitions
217                .entry(name)
218                .or_insert(StyleDefinition::Attributes {
219                    base: StyleAttributes::new(),
220                    light: None,
221                    dark: None,
222                });
223
224            if let StyleDefinition::Attributes {
225                ref mut base,
226                ref mut light,
227                ref mut dark,
228            } = def
229            {
230                match self.current_mode {
231                    None => *base = base.merge(&attributes),
232                    Some(Mode::Light) => {
233                        let l = light.get_or_insert(StyleAttributes::new());
234                        *l = l.merge(&attributes);
235                    }
236                    Some(Mode::Dark) => {
237                        let d = dark.get_or_insert(StyleAttributes::new());
238                        *d = d.merge(&attributes);
239                    }
240                }
241            }
242        }
243        Ok(())
244    }
245}
246
247impl<'i> AtRuleParser<'i> for StyleSheetParser {
248    type Prelude = Mode;
249    type AtRule = ();
250    type Error = ();
251
252    fn parse_prelude<'t>(
253        &mut self,
254        name: CowRcStr<'i>,
255        input: &mut Parser<'i, 't>,
256    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
257        if name.as_ref() == "media" {
258            // Peek and parse blocks
259            let mut found_mode: Option<Mode> = None;
260
261            loop {
262                match input.next() {
263                    Ok(Token::ParenthesisBlock) => {
264                        // We consumed ParenthesisBlock. Now we can call parse_nested_block.
265                        let nested_res = input.parse_nested_block(|input| {
266                            input.expect_ident_matching("prefers-color-scheme")?;
267                            input.expect_colon()?;
268                            let val = input.expect_ident()?;
269                            match val.as_ref() {
270                                "dark" => Ok(Mode::Dark),
271                                "light" => Ok(Mode::Light),
272                                _ => Err(input.new_custom_error::<(), ()>(())),
273                            }
274                        });
275                        if let Ok(m) = nested_res {
276                            found_mode = Some(m);
277                        }
278                    }
279                    Ok(Token::WhiteSpace(_)) | Ok(Token::Comment(_)) => continue,
280                    Err(_) => break, // End of input
281                    Ok(_) => {
282                        // Ignore other tokens
283                    }
284                }
285            }
286
287            if let Some(m) = found_mode {
288                return Ok(m);
289            }
290
291            Err(input.new_custom_error::<(), ()>(()))
292        } else {
293            Err(input.new_custom_error::<(), ()>(()))
294        }
295    }
296
297    fn parse_block<'t>(
298        &mut self,
299        mode: Self::Prelude,
300        _start: &ParserState,
301        input: &mut Parser<'i, 't>,
302    ) -> Result<Self::AtRule, ParseError<'i, Self::Error>> {
303        let old_mode = self.current_mode;
304        self.current_mode = Some(mode);
305
306        let list_parser = cssparser::StyleSheetParser::new(input, self);
307        for _ in list_parser {}
308
309        self.current_mode = old_mode;
310        Ok(())
311    }
312}
313
314struct StyleDeclarationParser;
315
316impl<'i> DeclarationParser<'i> for StyleDeclarationParser {
317    type Declaration = (String, StyleAttributes);
318    type Error = ();
319
320    fn parse_value<'t>(
321        &mut self,
322        name: CowRcStr<'i>,
323        input: &mut Parser<'i, 't>,
324    ) -> Result<Self::Declaration, ParseError<'i, Self::Error>> {
325        let mut attrs = StyleAttributes::new();
326        match name.as_ref() {
327            "fg" | "color" => {
328                attrs.fg = Some(parse_color(input)?);
329            }
330            "bg" | "background" | "background-color" => {
331                attrs.bg = Some(parse_color(input)?);
332            }
333            "bold" => {
334                if parse_bool_or_flag(input)? {
335                    attrs.bold = Some(true);
336                }
337            }
338            "dim" => {
339                if parse_bool_or_flag(input)? {
340                    attrs.dim = Some(true);
341                }
342            }
343            "italic" => {
344                if parse_bool_or_flag(input)? {
345                    attrs.italic = Some(true);
346                }
347            }
348            "underline" => {
349                if parse_bool_or_flag(input)? {
350                    attrs.underline = Some(true);
351                }
352            }
353            "blink" => {
354                if parse_bool_or_flag(input)? {
355                    attrs.blink = Some(true);
356                }
357            }
358            "reverse" => {
359                if parse_bool_or_flag(input)? {
360                    attrs.reverse = Some(true);
361                }
362            }
363            "hidden" => {
364                if parse_bool_or_flag(input)? {
365                    attrs.hidden = Some(true);
366                }
367            }
368            "strikethrough" => {
369                if parse_bool_or_flag(input)? {
370                    attrs.strikethrough = Some(true);
371                }
372            }
373
374            "font-weight" => {
375                let val = input.expect_ident()?;
376                if val.as_ref() == "bold" {
377                    attrs.bold = Some(true);
378                }
379            }
380            "font-style" => {
381                let val = input.expect_ident()?;
382                if val.as_ref() == "italic" {
383                    attrs.italic = Some(true);
384                }
385            }
386            "text-decoration" => {
387                let val = input.expect_ident()?;
388                match val.as_ref() {
389                    "underline" => attrs.underline = Some(true),
390                    "line-through" => attrs.strikethrough = Some(true),
391                    _ => {}
392                }
393            }
394            "visibility" => {
395                let val = input.expect_ident()?;
396                if val.as_ref() == "hidden" {
397                    attrs.hidden = Some(true);
398                }
399            }
400
401            _ => return Err(input.new_custom_error::<(), ()>(())),
402        }
403        Ok((name.as_ref().to_string(), attrs))
404    }
405}
406
407impl<'i> AtRuleParser<'i> for StyleDeclarationParser {
408    type Prelude = ();
409    type AtRule = (String, StyleAttributes);
410    type Error = ();
411}
412
413impl<'i> QualifiedRuleParser<'i> for StyleDeclarationParser {
414    type Prelude = ();
415    type QualifiedRule = (String, StyleAttributes);
416    type Error = ();
417}
418
419impl<'i> RuleBodyItemParser<'i, (String, StyleAttributes), ()> for StyleDeclarationParser {
420    fn parse_declarations(&self) -> bool {
421        true
422    }
423    fn parse_qualified(&self) -> bool {
424        false
425    }
426}
427
428fn parse_color<'i, 't>(input: &mut Parser<'i, 't>) -> Result<ColorDef, ParseError<'i, ()>> {
429    let token = match input.next() {
430        Ok(t) => t,
431        Err(_) => return Err(input.new_custom_error::<(), ()>(())),
432    };
433
434    match token {
435        Token::Function(ref name) if name.as_ref() == "cube" => {
436            input
437                .parse_nested_block(|input| {
438                    let r = input.expect_percentage()?;
439                    input.expect_comma()?;
440                    let g = input.expect_percentage()?;
441                    input.expect_comma()?;
442                    let b = input.expect_percentage()?;
443                    // cssparser percentages are 0.0–1.0, convert to 0–100 for from_percentages
444                    crate::colorspace::CubeCoord::from_percentages(
445                        r as f64 * 100.0,
446                        g as f64 * 100.0,
447                        b as f64 * 100.0,
448                    )
449                    .map(ColorDef::Cube)
450                    .map_err(|_| input.new_custom_error::<(), ()>(()))
451                })
452                .map_err(|_: ParseError<'i, ()>| input.new_custom_error::<(), ()>(()))
453        }
454        Token::Ident(name) => {
455            ColorDef::parse_string(name.as_ref()).map_err(|_| input.new_custom_error::<(), ()>(()))
456        }
457        Token::Hash(val) | Token::IDHash(val) => ColorDef::parse_string(&format!("#{}", val))
458            .map_err(|_| input.new_custom_error::<(), ()>(())),
459        _ => Err(input.new_custom_error::<(), ()>(())),
460    }
461}
462
463fn parse_bool_or_flag<'i, 't>(input: &mut Parser<'i, 't>) -> Result<bool, ParseError<'i, ()>> {
464    match input.expect_ident() {
465        Ok(val) => Ok(val.as_ref() == "true"),
466        Err(_) => Err(input.new_custom_error::<(), ()>(())),
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::{ColorMode, StyleValue};
474
475    #[test]
476    fn test_parse_simple() {
477        let css = ".error { color: red; font-weight: bold; }";
478        let variants = parse_css(css, None).unwrap();
479        let base = variants.base();
480
481        // Ensure "error" style exists
482        assert!(base.contains_key("error"));
483
484        let style = base.get("error").unwrap().clone().force_styling(true);
485        let styled = style.apply_to("text").to_string();
486        // Check for red (31) and bold (1).
487        assert!(styled.contains("\x1b[31m"));
488        assert!(styled.contains("\x1b[1m"));
489    }
490
491    #[test]
492    fn test_parse_adaptive() {
493        let css =
494            ".text { color: red; } @media (prefers-color-scheme: dark) { .text { color: white; } }";
495        let variants = parse_css(css, None).unwrap();
496
497        let light = variants.resolve(Some(ColorMode::Light));
498        let dark = variants.resolve(Some(ColorMode::Dark));
499
500        // Light (base) -> Red
501        if let StyleValue::Concrete(s) = light.get("text").unwrap() {
502            let out = s.clone().force_styling(true).apply_to("x").to_string();
503            assert!(out.contains("\x1b[31m")); // Red
504        } else {
505            panic!("Expected Concrete style for light mode");
506        }
507
508        // Dark -> White
509        if let StyleValue::Concrete(s) = dark.get("text").unwrap() {
510            let out = s.clone().force_styling(true).apply_to("x").to_string();
511            assert!(out.contains("\x1b[37m")); // White
512        } else {
513            panic!("Expected Concrete style for dark mode");
514        }
515    }
516
517    #[test]
518    fn test_multiple_selectors() {
519        let css = ".a, .b { color: blue; }";
520        let variants = parse_css(css, None).unwrap();
521        let base = variants.base();
522        assert!(base.contains_key("a"));
523        assert!(base.contains_key("b"));
524    }
525
526    #[test]
527    fn test_all_properties() {
528        let css = r#"
529        .all-props {
530            fg: red;
531            bg: blue;
532            bold: true;
533            dim: true;
534            italic: true;
535            underline: true;
536            blink: true;
537            reverse: true;
538            hidden: true;
539            strikethrough: true;
540        }
541        "#;
542        let variants = parse_css(css, None).unwrap();
543        let base = variants.base();
544        assert!(base.contains_key("all-props"));
545
546        // We can't easily inspect the attributes directly without making fields public
547        // or adding accessors to StyleValue/StyleAttributes.
548        // But successful parsing covers the code paths.
549        // We can verify effect by applying to string.
550        let style = base.get("all-props").unwrap().clone().force_styling(true);
551        let out = style.apply_to("text").to_string();
552
553        assert!(out.contains("\x1b[31m")); // fg red
554        assert!(out.contains("\x1b[44m")); // bg blue
555        assert!(out.contains("\x1b[1m")); // bold
556        assert!(out.contains("\x1b[2m")); // dim
557        assert!(out.contains("\x1b[3m")); // italic
558        assert!(out.contains("\x1b[4m")); // underline
559        assert!(out.contains("\x1b[5m")); // blink
560        assert!(out.contains("\x1b[7m")); // reverse
561        assert!(out.contains("\x1b[8m")); // hidden
562        assert!(out.contains("\x1b[9m")); // strikethrough
563    }
564
565    #[test]
566    fn test_css_aliases() {
567        let css = r#"
568        .aliases {
569            background-color: green;
570            font-weight: bold;
571            font-style: italic;
572            text-decoration: underline;
573            visibility: hidden;
574        }
575        "#;
576        let variants = parse_css(css, None).unwrap();
577        let base = variants.base();
578        let style = base.get("aliases").unwrap().clone().force_styling(true);
579        let out = style.apply_to("text").to_string();
580
581        assert!(out.contains("\x1b[42m")); // bg green
582        assert!(out.contains("\x1b[1m")); // bold
583        assert!(out.contains("\x1b[3m")); // italic
584        assert!(out.contains("\x1b[4m")); // underline
585        assert!(out.contains("\x1b[8m")); // hidden
586    }
587
588    #[test]
589    fn test_text_decoration_line_through() {
590        let css = ".strike { text-decoration: line-through; }";
591        let variants = parse_css(css, None).unwrap();
592        let style = variants
593            .base()
594            .get("strike")
595            .unwrap()
596            .clone()
597            .force_styling(true);
598        let out = style.apply_to("text").to_string();
599        assert!(out.contains("\x1b[9m"));
600    }
601
602    #[test]
603    fn test_invalid_syntax_recovery() {
604        // missing colon, invalid values, unknown properties should not panic
605        let css = r#"
606        .broken {
607            color: ;
608            unknown: prop;
609            bold: not-a-bool;
610        }
611        .valid { color: cyan; }
612        "#;
613
614        // cssparser is robust and may skip invalid declarations
615        let variants = parse_css(css, None).unwrap();
616        assert!(variants.base().contains_key("valid"));
617    }
618
619    #[test]
620    fn test_empty_selector_error() {
621        // Just dots without name
622        let css = ". { color: red; }";
623        let res = parse_css(css, None);
624        assert!(res.is_err());
625    }
626
627    #[test]
628    fn test_no_dot_selector() {
629        // Tag selector not supported, should skip or error
630        let css = "body { color: red; }";
631        // Our parser expects '.' delimiters in parse_prelude.
632        // If it doesn't find '.', it consumes tokens.
633        // If names is empty, it returns error.
634        let res = parse_css(css, None);
635        assert!(res.is_err());
636    }
637
638    #[test]
639    fn test_invalid_color() {
640        let css = ".bad-color { color: not-a-color; }";
641        // Should ignore the invalid property but parse the rule
642        let variants = parse_css(css, None).unwrap();
643        assert!(variants.base().contains_key("bad-color"));
644    }
645
646    #[test]
647    fn test_hex_colors() {
648        let css = ".hex { color: #ff0000; bg: #00ff00; }";
649        let variants = parse_css(css, None).unwrap();
650        let style = variants.base().get("hex").unwrap();
651        let out = style.apply_to("x").to_string();
652        // Just verify it parsed something, specific hex to ansi conversion depends on color support
653        assert!(!out.is_empty());
654    }
655
656    #[test]
657    fn test_comments() {
658        let css = r#"
659        /* This is a comment */
660        .commented {
661            color: red; /* Inline comment */
662        }
663        "#;
664        let variants = parse_css(css, None).unwrap();
665        assert!(variants.base().contains_key("commented"));
666    }
667
668    // =========================================================================
669    // Cube color CSS tests
670    // =========================================================================
671
672    #[test]
673    fn test_css_cube_color() {
674        let css = ".warm { color: cube(60%, 20%, 0%); }";
675        let variants = parse_css(css, None).unwrap();
676        assert!(variants.base().contains_key("warm"));
677    }
678
679    #[test]
680    fn test_css_cube_color_bg() {
681        let css = ".panel { background-color: cube(10%, 10%, 50%); }";
682        let variants = parse_css(css, None).unwrap();
683        assert!(variants.base().contains_key("panel"));
684    }
685
686    #[test]
687    fn test_css_cube_with_other_props() {
688        let css = ".styled { color: cube(80%, 30%, 0%); font-weight: bold; }";
689        let variants = parse_css(css, None).unwrap();
690        let style = variants
691            .base()
692            .get("styled")
693            .unwrap()
694            .clone()
695            .force_styling(true);
696        let out = style.apply_to("text").to_string();
697        // Should have bold
698        assert!(out.contains("\x1b[1m"));
699    }
700
701    #[test]
702    fn test_css_cube_adaptive() {
703        let css = r#"
704        .text { color: cube(50%, 50%, 50%); }
705        @media (prefers-color-scheme: dark) {
706            .text { color: cube(80%, 80%, 80%); }
707        }
708        "#;
709        let variants = parse_css(css, None).unwrap();
710        assert!(variants.base().contains_key("text"));
711        assert!(variants.dark().contains_key("text"));
712    }
713
714    use proptest::prelude::*;
715
716    proptest! {
717        #[test]
718        fn test_random_css_input_no_panic(s in "\\PC*") {
719            // Should never panic, even with garbage input
720            let _ = parse_css(&s, None);
721        }
722
723        #[test]
724        fn test_valid_structure_random_values(
725            color in "[a-zA-Z]+",
726            bool_val in "true|false",
727            prop_name in "[a-z-]+"
728        ) {
729            let css = format!(".prop {{ color: {}; bold: {}; {}: {}; }}", color, bool_val, prop_name, bool_val);
730            let _ = parse_css(&css, None);
731        }
732    }
733}