Skip to main content

iris_cssom/
parser.rs

1//! CSS parser: convert CSS string → StyleSheet.
2//!
3//! Uses simple string-based parsing (no cssparser dependency at parse time)
4//! for broad compatibility across cssparser 0.33+ API changes.
5
6use crate::{CssRule, Declaration, StyleSheet};
7
8/// Parse CSS string into StyleSheet (simple string-based parser).
9pub fn parse_css(css: &str) -> Result<StyleSheet, String> {
10    Ok(parse_css_simple(css))
11}
12
13/// Simple CSS parser — splits by `{` / `}` and `:` / `;`.
14///
15/// Handles:
16/// - Multi-selector rules (`h1, h2 { ... }`)
17/// - @rules (skipped)
18/// - Nested at-rules like @media (skipped)
19pub fn parse_css_simple(css: &str) -> StyleSheet {
20    let mut sheet = StyleSheet::default();
21    let mut remaining = css;
22
23    while let Some(open_pos) = remaining.find('{') {
24        let before = remaining[..open_pos].trim();
25        remaining = &remaining[open_pos + 1..];
26
27        // Find matching closing brace
28        let close_pos = match find_matching_brace(remaining) {
29            Some(p) => p,
30            None => break,
31        };
32
33        let decl_str = remaining[..close_pos].trim();
34        remaining = &remaining[close_pos + 1..];
35
36        // Skip @rules
37        if before.starts_with('@') {
38            continue;
39        }
40
41        // Skip empty/whitespace selectors
42        if before.is_empty() {
43            continue;
44        }
45
46        let selectors: Vec<String> = before.split(',')
47            .map(|s| s.trim().to_string())
48            .filter(|s| !s.is_empty())
49            .collect();
50
51        let declarations = parse_declarations(decl_str);
52
53        if !selectors.is_empty() {
54            sheet.rules.push(CssRule { selectors, declarations });
55        }
56    }
57
58    sheet
59}
60
61/// Find the matching closing brace, handling nested braces.
62fn find_matching_brace(s: &str) -> Option<usize> {
63    let mut depth = 1i32;
64    for (i, c) in s.char_indices() {
65        match c {
66            '{' => depth += 1,
67            '}' => {
68                depth -= 1;
69                if depth == 0 { return Some(i); }
70            }
71            _ => {}
72        }
73    }
74    None
75}
76
77/// Parse CSS declarations from a "prop: val; prop: val" string.
78fn parse_declarations(s: &str) -> Vec<Declaration> {
79    let mut result = Vec::new();
80    let mut remaining = s.trim();
81
82    while !remaining.is_empty() {
83        // Find next colon (property: value separator)
84        let colon_pos = match remaining.find(':') {
85            Some(p) => p,
86            None => break,
87        };
88
89        let property = remaining[..colon_pos].trim().to_lowercase();
90        remaining = remaining[colon_pos + 1..].trim_start();
91
92        // Find semicolon or end of string
93        let (value, rest) = if let Some(sc_pos) = remaining.find(';') {
94            (remaining[..sc_pos].trim().to_string(), remaining[sc_pos + 1..].trim())
95        } else {
96            (remaining.trim().to_string(), "")
97        };
98
99        if !property.is_empty() {
100            result.push(Declaration { property, value });
101        }
102
103        remaining = rest;
104    }
105
106    result
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_parse_simple() {
115        let sheet = parse_css_simple(".foo { color: red; font-size: 16px; } .bar { margin: 10px; }");
116        assert_eq!(sheet.rules.len(), 2);
117        assert_eq!(sheet.rules[0].selectors[0], ".foo");
118        assert_eq!(sheet.rules[0].declarations[0].property, "color");
119    }
120
121    #[test]
122    fn test_complex_selector() {
123        let sheet = parse_css_simple("div.container > p.highlight { color: blue; }");
124        assert_eq!(sheet.rules.len(), 1);
125        assert_eq!(sheet.rules[0].declarations[0].value, "blue");
126    }
127
128    #[test]
129    fn test_multiple_selectors() {
130        let sheet = parse_css_simple("h1, h2, h3 { font-weight: bold; }");
131        assert_eq!(sheet.rules[0].selectors.len(), 3);
132    }
133
134    #[test]
135    fn test_at_rule_skipped() {
136        let sheet = parse_css_simple("@media screen { .a { color: red; } } .b { color: blue; }");
137        // @media is skipped as one block, .b should be parsed
138        assert_eq!(sheet.rules.len(), 1);
139        assert_eq!(sheet.rules[0].selectors[0], ".b");
140    }
141
142    #[test]
143    fn test_stylesheet_compute() {
144        let sheet = parse_css_simple(".btn { color: red; font-size: 14px; } .btn-primary { color: blue; }");
145        let map = sheet.compute(&["btn".into(), "btn-primary".into()], "button");
146        assert_eq!(map.get("color").unwrap(), "blue");
147        assert_eq!(map.get("font-size").unwrap(), "14px");
148    }
149
150    #[test]
151    fn test_empty_input() {
152        let sheet = parse_css_simple("");
153        assert_eq!(sheet.rules.len(), 0);
154    }
155
156    #[test]
157    fn test_nested_braces() {
158        let sheet = parse_css_simple(".a { x: 1; } .b { y: 2; }");
159        assert_eq!(sheet.rules.len(), 2);
160    }
161}