Skip to main content

cssbox_dom/
cascade.rs

1//! CSS cascade: selector matching, specificity, and style resolution.
2
3use cssbox_core::style::ComputedStyle;
4
5use crate::css::{apply_declarations, parse_style_attribute, parse_stylesheet, CssRule};
6use crate::dom::{DomNodeId, DomTree};
7
8/// Represents matched CSS rules with specificity for cascade ordering.
9#[derive(Debug, Clone)]
10struct MatchedRule {
11    declarations: Vec<crate::css::CssDeclaration>,
12    specificity: Specificity,
13    source_order: usize,
14}
15
16/// CSS specificity (a, b, c) — id, class, type selectors.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18struct Specificity {
19    inline: bool,
20    id: u32,
21    class: u32,
22    type_sel: u32,
23}
24
25impl Specificity {
26    fn new(id: u32, class: u32, type_sel: u32) -> Self {
27        Self {
28            inline: false,
29            id,
30            class,
31            type_sel,
32        }
33    }
34}
35
36/// Compute the specificity of a CSS selector string.
37fn compute_specificity(selector: &str) -> Specificity {
38    let mut id_count = 0u32;
39    let mut class_count = 0u32;
40    let mut type_count = 0u32;
41
42    // Simplified specificity calculation
43    for part in selector.split(|c: char| c.is_whitespace() || c == '>' || c == '+' || c == '~') {
44        let part = part.trim();
45        if part.is_empty() {
46            continue;
47        }
48
49        for segment in split_selector_segments(part) {
50            if segment.starts_with('#') {
51                id_count += 1;
52            } else if segment.starts_with('.')
53                || segment.starts_with('[')
54                || segment.starts_with(':')
55            {
56                class_count += 1;
57            } else if segment == "*" {
58                // Universal selector: no specificity
59            } else {
60                type_count += 1;
61            }
62        }
63    }
64
65    Specificity::new(id_count, class_count, type_count)
66}
67
68fn split_selector_segments(selector: &str) -> Vec<&str> {
69    let mut segments = Vec::new();
70    let mut start = 0;
71    let bytes = selector.as_bytes();
72
73    for i in 1..bytes.len() {
74        if bytes[i] == b'#' || bytes[i] == b'.' || bytes[i] == b'[' || bytes[i] == b':' {
75            if start < i {
76                segments.push(&selector[start..i]);
77            }
78            start = i;
79        }
80    }
81    if start < selector.len() {
82        segments.push(&selector[start..]);
83    }
84
85    segments
86}
87
88/// Check if a simple selector matches a DOM node.
89fn selector_matches(tree: &DomTree, node: DomNodeId, selector: &str) -> bool {
90    let _dom_node = tree.node(node);
91
92    // Parse the selector into simple parts
93    // Handle complex selectors by splitting on combinators
94    let parts: Vec<&str> = selector.split_whitespace().collect();
95
96    if parts.len() == 1 {
97        return simple_selector_matches(tree, node, parts[0]);
98    }
99
100    // Descendant selector (a b)
101    if parts.len() >= 2 {
102        let last = parts.last().unwrap();
103        if !simple_selector_matches(tree, node, last) {
104            return false;
105        }
106
107        // Check ancestors for remaining parts
108        let ancestor_selector = parts[..parts.len() - 1].join(" ");
109        let mut current = tree.parent(node);
110        while let Some(parent) = current {
111            if selector_matches(tree, parent, &ancestor_selector) {
112                return true;
113            }
114            current = tree.parent(parent);
115        }
116        return false;
117    }
118
119    false
120}
121
122/// Check if a single simple selector matches a node.
123fn simple_selector_matches(tree: &DomTree, node: DomNodeId, selector: &str) -> bool {
124    let dom_node = tree.node(node);
125
126    if selector == "*" {
127        return dom_node.is_element();
128    }
129
130    let segments = split_selector_segments(selector);
131
132    for segment in &segments {
133        if let Some(id) = segment.strip_prefix('#') {
134            // ID selector
135            match dom_node.get_attribute("id") {
136                Some(v) if v == id => {}
137                _ => return false,
138            }
139        } else if let Some(class) = segment.strip_prefix('.') {
140            // Class selector
141            match dom_node.get_attribute("class") {
142                Some(classes) => {
143                    if !classes.split_whitespace().any(|c| c == class) {
144                        return false;
145                    }
146                }
147                None => return false,
148            }
149        } else if segment.starts_with('[') {
150            // Attribute selector (simplified)
151            let inner = segment.trim_start_matches('[').trim_end_matches(']');
152            if let Some((attr, val)) = inner.split_once('=') {
153                let val = val.trim_matches('"').trim_matches('\'');
154                match dom_node.get_attribute(attr) {
155                    Some(v) if v == val => {}
156                    _ => return false,
157                }
158            } else if dom_node.get_attribute(inner).is_none() {
159                return false;
160            }
161        } else {
162            // Type selector
163            match dom_node.tag_name() {
164                Some(tag) if tag.eq_ignore_ascii_case(segment) => {}
165                _ => return false,
166            }
167        }
168    }
169
170    !segments.is_empty()
171}
172
173/// Resolve computed styles for all elements in a DOM tree.
174pub fn resolve_styles(tree: &DomTree, stylesheets: &[String]) -> Vec<(DomNodeId, ComputedStyle)> {
175    // Parse all stylesheets
176    let mut rules: Vec<(CssRule, usize)> = Vec::new();
177    for (i, css) in stylesheets.iter().enumerate() {
178        for rule in parse_stylesheet(css) {
179            rules.push((rule, i));
180        }
181    }
182
183    let mut styles = Vec::new();
184
185    for node_id in tree.iter_dfs() {
186        let dom_node = tree.node(node_id);
187        if !dom_node.is_element() {
188            continue;
189        }
190
191        let mut style = default_style_for_tag(dom_node.tag_name().unwrap_or(""));
192
193        // Collect matching rules
194        let mut matched: Vec<MatchedRule> = Vec::new();
195
196        for (order, (rule, _sheet_idx)) in rules.iter().enumerate() {
197            // Handle comma-separated selectors
198            for selector in rule.selector.split(',') {
199                let selector = selector.trim();
200                if selector_matches(tree, node_id, selector) {
201                    matched.push(MatchedRule {
202                        declarations: rule.declarations.clone(),
203                        specificity: compute_specificity(selector),
204                        source_order: order,
205                    });
206                }
207            }
208        }
209
210        // Sort by specificity then source order
211        matched.sort_by(|a, b| {
212            a.specificity
213                .cmp(&b.specificity)
214                .then(a.source_order.cmp(&b.source_order))
215        });
216
217        // Apply matched rules in order
218        for rule in &matched {
219            apply_declarations(&mut style, &rule.declarations);
220        }
221
222        // Apply inline styles (highest specificity)
223        if let Some(inline_style) = dom_node.get_attribute("style") {
224            let inline_decls = parse_style_attribute(inline_style);
225            apply_declarations(&mut style, &inline_decls);
226        }
227
228        styles.push((node_id, style));
229    }
230
231    styles
232}
233
234/// Default computed style based on HTML tag name.
235fn default_style_for_tag(tag: &str) -> ComputedStyle {
236    use cssbox_core::style::Display;
237    use cssbox_core::values::LengthPercentageAuto;
238
239    let mut style = ComputedStyle::default();
240
241    match tag.to_lowercase().as_str() {
242        "html" | "body" | "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol"
243        | "li" | "article" | "section" | "nav" | "aside" | "header" | "footer" | "main"
244        | "figure" | "figcaption" | "blockquote" | "pre" | "hr" | "form" | "fieldset"
245        | "legend" | "details" | "summary" | "dl" | "dt" | "dd" | "address" => {
246            style.display = Display::BLOCK;
247        }
248        "table" => {
249            style.display = Display::TABLE;
250        }
251        "tr" => {
252            style.display = Display::TABLE_ROW;
253        }
254        "td" | "th" => {
255            style.display = Display::TABLE_CELL;
256        }
257        "thead" => {
258            style.display = Display::TABLE_HEADER_GROUP;
259        }
260        "tbody" => {
261            style.display = Display::TABLE_ROW_GROUP;
262        }
263        "tfoot" => {
264            style.display = Display::TABLE_FOOTER_GROUP;
265        }
266        "colgroup" => {
267            style.display = Display::TABLE_COLUMN_GROUP;
268        }
269        "col" => {
270            style.display = Display::TABLE_COLUMN;
271        }
272        "caption" => {
273            style.display = Display::TABLE_CAPTION;
274        }
275        _ => {
276            style.display = Display::INLINE;
277        }
278    }
279
280    // Default margins for some elements
281    match tag.to_lowercase().as_str() {
282        "body" => {
283            style.margin_top = LengthPercentageAuto::px(8.0);
284            style.margin_right = LengthPercentageAuto::px(8.0);
285            style.margin_bottom = LengthPercentageAuto::px(8.0);
286            style.margin_left = LengthPercentageAuto::px(8.0);
287        }
288        "p" | "blockquote" | "figure" | "ul" | "ol" | "dl" => {
289            style.margin_top = LengthPercentageAuto::px(16.0);
290            style.margin_bottom = LengthPercentageAuto::px(16.0);
291        }
292        "h1" => {
293            style.margin_top = LengthPercentageAuto::px(21.44);
294            style.margin_bottom = LengthPercentageAuto::px(21.44);
295        }
296        _ => {}
297    }
298
299    style
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_specificity_calculation() {
308        assert!(compute_specificity("#id") > compute_specificity(".class"));
309        assert!(compute_specificity(".class") > compute_specificity("div"));
310        assert!(compute_specificity("div.class") > compute_specificity("div"));
311    }
312
313    #[test]
314    fn test_simple_selector_matching() {
315        let mut tree = DomTree::new();
316        let root = tree.root();
317        let mut attrs = std::collections::HashMap::new();
318        attrs.insert("id".to_string(), "test".to_string());
319        attrs.insert("class".to_string(), "box red".to_string());
320        let div = tree.add_element(root, "div", attrs);
321
322        assert!(simple_selector_matches(&tree, div, "div"));
323        assert!(simple_selector_matches(&tree, div, "#test"));
324        assert!(simple_selector_matches(&tree, div, ".box"));
325        assert!(simple_selector_matches(&tree, div, ".red"));
326        assert!(simple_selector_matches(&tree, div, "div.box"));
327        assert!(!simple_selector_matches(&tree, div, "span"));
328        assert!(!simple_selector_matches(&tree, div, ".missing"));
329    }
330
331    #[test]
332    fn test_default_style_for_div() {
333        let style = default_style_for_tag("div");
334        assert_eq!(style.display, cssbox_core::style::Display::BLOCK);
335    }
336}