Skip to main content

orinium_browser/engine/layouter/
style_inspect.rs

1//! Read-only CSS cascade inspection for the DevTools Styles panel.
2//!
3//! Reports matching rules, including overridden declarations.
4
5use std::collections::HashMap;
6
7use super::builder::{element_info, element_sibling_infos};
8use super::css_resolver::{ResolvedDeclaration, RuleSet, StyleOrigin, resolve_inline_style};
9use super::dom_snapshot::{DomSnapshot, NodeId};
10use crate::engine::css::matcher::ElementChain;
11use crate::engine::css::values::CssValue;
12
13/// One property declaration as shown in the Styles panel.
14#[derive(Debug, Clone)]
15pub struct InspectedDeclaration {
16    pub name: String,
17    pub value: CssValue,
18    pub important: bool,
19    /// Whether this declaration is the cascade winner for its property.
20    pub applied: bool,
21}
22
23/// A rule (or the inline `style` attribute) whose declarations were cascaded.
24#[derive(Debug, Clone)]
25pub struct MatchedRule {
26    /// Rendered selector text, or `"element.style"` for inline styles.
27    pub selector: String,
28    pub origin: StyleOrigin,
29    /// Whether this rule is the synthetic inline-style entry.
30    pub inline: bool,
31    pub declarations: Vec<InspectedDeclaration>,
32}
33
34/// Total ordering mirroring the builder's cascade semantics: `!important`
35/// first, then origin (`Author` ranks above `UserAgent`), then inline styles
36/// over non-important stylesheet rules, then specificity and source order.
37///
38/// Inline entries use source orders starting at [`INLINE_ORDER_BASE`] so that
39/// repeated inline declarations resolve last-wins while still ranking above
40/// every normal stylesheet declaration.
41#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
42struct CascadeKey {
43    important: bool,
44    origin: StyleOrigin,
45    inline: bool,
46    specificity: (u32, u32, u32),
47    order: usize,
48}
49
50const INLINE_ORDER_BASE: usize = usize::MAX / 2;
51
52impl CascadeKey {
53    fn from_declaration(declaration: &ResolvedDeclaration) -> Self {
54        Self {
55            important: declaration.important,
56            origin: declaration.origin,
57            inline: false,
58            specificity: declaration.specificity,
59            order: declaration.order,
60        }
61    }
62}
63
64struct Pending {
65    name: String,
66    key: CascadeKey,
67    rule: usize,
68    declaration: usize,
69}
70
71/// Collects every rule matching `target` and flags winning declarations.
72///
73/// `inline_style_attr` is the target's raw `style` attribute value, taken
74/// from the same snapshot the layout was built from; pass `None` when the
75/// element has no attribute. `target` must be an element node.
76pub fn collect_matched_rules(
77    snapshot: &DomSnapshot,
78    target: NodeId,
79    rule_set: &RuleSet,
80    inline_style_attr: Option<&str>,
81) -> Vec<MatchedRule> {
82    let Some(chain) = chain_for_node(snapshot, target) else {
83        return Vec::new();
84    };
85    let Some(element) = chain.first() else {
86        return Vec::new();
87    };
88
89    // The synthetic inline entry leads the output like browser DevTools do.
90    let mut rules: Vec<MatchedRule> = Vec::new();
91    let mut pendings: Vec<Pending> = Vec::new();
92
93    if let Some(style_attr) = inline_style_attr {
94        let inline_declarations = resolve_inline_style(style_attr);
95        if !inline_declarations.is_empty() {
96            let mut declarations = Vec::with_capacity(inline_declarations.len());
97            for (offset, (name, value, important)) in inline_declarations.into_iter().enumerate() {
98                pendings.push(Pending {
99                    name: name.clone(),
100                    key: CascadeKey {
101                        important,
102                        origin: StyleOrigin::Author,
103                        inline: true,
104                        specificity: (u32::MAX, 0, 0),
105                        order: INLINE_ORDER_BASE + offset,
106                    },
107                    rule: 0,
108                    declaration: declarations.len(),
109                });
110                declarations.push(InspectedDeclaration {
111                    name,
112                    value,
113                    important,
114                    applied: false,
115                });
116            }
117            rules.push(MatchedRule {
118                selector: "element.style".to_string(),
119                origin: StyleOrigin::Author,
120                inline: true,
121                declarations,
122            });
123        }
124    }
125
126    let mut rule_index: HashMap<(String, StyleOrigin), usize> = HashMap::new();
127    for group in rule_set.query_candidates(element) {
128        if !group.selector.matches(&chain) {
129            continue;
130        }
131        for &decl_idx in &group.decls {
132            let declaration = &rule_set.declarations()[decl_idx];
133            let selector = declaration.selector.to_string();
134            let key = (selector, declaration.origin);
135            let rule = *rule_index.entry(key).or_insert_with(|| {
136                rules.push(MatchedRule {
137                    selector: declaration.selector.to_string(),
138                    origin: declaration.origin,
139                    inline: false,
140                    declarations: Vec::new(),
141                });
142                rules.len() - 1
143            });
144            pendings.push(Pending {
145                name: declaration.name.clone(),
146                key: CascadeKey::from_declaration(declaration),
147                rule,
148                declaration: rules[rule].declarations.len(),
149            });
150            rules[rule].declarations.push(InspectedDeclaration {
151                name: declaration.name.clone(),
152                value: declaration.value.clone(),
153                important: declaration.important,
154                applied: false,
155            });
156        }
157    }
158
159    mark_cascade_winners(&mut rules, &pendings);
160    rules
161}
162
163/// Flags the highest-ranking declaration per property name as applied.
164fn mark_cascade_winners(rules: &mut [MatchedRule], pendings: &[Pending]) {
165    let mut winners: HashMap<&str, (CascadeKey, usize)> = HashMap::new();
166    for (index, pending) in pendings.iter().enumerate() {
167        match winners.get(pending.name.as_str()) {
168            Some((existing_key, _)) if *existing_key >= pending.key => {}
169            _ => {
170                winners.insert(&pending.name, (pending.key.clone(), index));
171            }
172        }
173    }
174    for (_, (_, index)) in winners {
175        let pending = &pendings[index];
176        rules[pending.rule].declarations[pending.declaration].applied = true;
177    }
178}
179
180/// Rebuilds the [`ElementChain`] the builder would have walked with when
181/// styling `target`: ancestor elements outermost-first, each carrying the
182/// sibling indexes its structural pseudo-classes rely on.
183fn chain_for_node(snapshot: &DomSnapshot, target: NodeId) -> Option<ElementChain> {
184    let root = *snapshot.roots().first()?;
185    let mut path = Vec::new();
186    find_path(snapshot, root, target, &mut path)?;
187
188    let mut elements = Vec::with_capacity(path.len());
189    for (depth, &node_id) in path.iter().enumerate() {
190        let info = if depth == 0 {
191            element_info(&snapshot.node(node_id).kind)
192        } else {
193            let siblings = snapshot.children(path[depth - 1]);
194            let position = siblings.iter().position(|&sibling| sibling == node_id)?;
195            element_sibling_infos(snapshot, siblings)[position].clone()
196        };
197        elements.push(info);
198    }
199
200    // `from_vec` expects innermost-first; non-element ancestors (document,
201    // text) simply do not contribute chain links, matching the builder.
202    elements.reverse();
203    Some(ElementChain::from_vec(
204        elements.into_iter().flatten().collect(),
205    ))
206}
207
208fn find_path(
209    snapshot: &DomSnapshot,
210    current: NodeId,
211    target: NodeId,
212    path: &mut Vec<NodeId>,
213) -> Option<()> {
214    path.push(current);
215    if current == target {
216        return Some(());
217    }
218    for &child in snapshot.children(current) {
219        if find_path(snapshot, child, target, path).is_some() {
220            return Some(());
221        }
222    }
223    path.pop();
224    None
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::engine::css::parser::Parser as CssParser;
231    use crate::engine::html::parser::Parser as HtmlParser;
232    use crate::engine::layouter::css_resolver::{CssResolver, MediaEnvironment, ResolvedStyles};
233    use crate::engine::layouter::types::ColorScheme;
234
235    /// Leaks a tiny fixture so `RuleSet` can borrow it for `'static`; keeps
236    /// test plumbing free of self-referential lifetimes.
237    fn leak(resolved: ResolvedStyles) -> &'static ResolvedStyles {
238        Box::leak(Box::new(resolved))
239    }
240
241    fn resolved_for(css: &str) -> ResolvedStyles {
242        CssResolver::resolve(&CssParser::new(css).parse().unwrap())
243    }
244
245    fn resolved_for_origin(css: &str, origin: StyleOrigin) -> ResolvedStyles {
246        CssResolver::resolve_with_origin(&CssParser::new(css).parse().unwrap(), origin)
247    }
248
249    fn media() -> MediaEnvironment {
250        MediaEnvironment::new((800.0, 600.0), ColorScheme::Light)
251    }
252
253    fn snapshot_for(html: &str) -> DomSnapshot {
254        let dom = HtmlParser::new(html).parse();
255        DomSnapshot::from_tree(&dom.root).0
256    }
257
258    fn node_with_tag(snapshot: &DomSnapshot, tag: &str) -> NodeId {
259        nodes_with_tag(snapshot, tag)
260            .into_iter()
261            .next()
262            .unwrap_or_else(|| panic!("no <{tag}> in fixture"))
263    }
264
265    /// Pre-order positions of every element with `tag`; text nodes sit
266    /// between element ids, so callers must not assume consecutive ids.
267    fn nodes_with_tag(snapshot: &DomSnapshot, tag: &str) -> Vec<NodeId> {
268        snapshot
269            .nodes()
270            .iter()
271            .enumerate()
272            .filter(|(_, node)| node.kind.tag_name() == Some(tag))
273            .map(|(index, _)| index as NodeId)
274            .collect()
275    }
276
277    fn declaration<'a>(rule: &'a MatchedRule, name: &str) -> &'a InspectedDeclaration {
278        rule.declarations
279            .iter()
280            .find(|declaration| declaration.name == name)
281            .unwrap_or_else(|| panic!("no {name} declared by {}", rule.selector))
282    }
283
284    #[test]
285    fn later_specific_rule_overrides_and_flags_the_loser() {
286        let snapshot = snapshot_for("<body><p class=\"box\">t</p></body>");
287        let styles = leak(resolved_for("p { color: red; } .box { color: blue; }"));
288        let rules = RuleSet::from_declarations(styles, &media());
289        let target = node_with_tag(&snapshot, "p");
290
291        let matched = collect_matched_rules(&snapshot, target, &rules, None);
292
293        let tag_rule = matched.iter().find(|rule| rule.selector == "p").unwrap();
294        let class_rule = matched.iter().find(|rule| rule.selector == ".box").unwrap();
295        assert!(!declaration(tag_rule, "color").applied);
296        assert!(declaration(class_rule, "color").applied);
297    }
298
299    #[test]
300    fn chain_reconstruction_carries_sibling_indexes() {
301        let snapshot = snapshot_for("<ul><li>a</li><li>b</li><span>s</span></ul>");
302
303        fn indexes(snapshot: &DomSnapshot, id: NodeId) -> (usize, usize) {
304            let chain = chain_for_node(snapshot, id).expect("chain");
305            let element = chain.first().unwrap();
306            (element.element_index, element.element_count)
307        }
308
309        let lis = nodes_with_tag(&snapshot, "li");
310        assert_eq!(lis.len(), 2);
311        assert_eq!(indexes(&snapshot, lis[0]), (1, 3));
312        assert_eq!(indexes(&snapshot, lis[1]), (2, 3));
313
314        // The chain reaches ancestors: first() is the node itself.
315        let chain = chain_for_node(&snapshot, lis[1]).unwrap();
316        assert_eq!(chain.first().unwrap().tag_name, "li");
317    }
318
319    #[test]
320    fn structural_pseudo_classes_use_reconstructed_sibling_indexes() {
321        let snapshot = snapshot_for("<ul><li>a</li><li>b</li></ul>");
322        let styles = leak(resolved_for("li:nth-child(2) { color: green; }"));
323        let rules = RuleSet::from_declarations(styles, &media());
324        let lis = nodes_with_tag(&snapshot, "li");
325
326        let matched = collect_matched_rules(&snapshot, lis[1], &rules, None);
327        assert!(
328            matched.iter().any(
329                |rule| rule.selector == "li:nth-child(2)" && declaration(rule, "color").applied
330            ),
331            "second li must match :nth-child(2)"
332        );
333
334        let matched = collect_matched_rules(&snapshot, lis[0], &rules, None);
335        assert!(matched.is_empty(), "first li must not match :nth-child(2)");
336    }
337
338    #[test]
339    fn descendant_combinator_selectors_match_through_ancestors() {
340        let snapshot = snapshot_for("<div class=\"outer\"><section><p>t</p></section></div>");
341        let styles = leak(resolved_for("div.outer p { color: teal; }"));
342        let rules = RuleSet::from_declarations(styles, &media());
343
344        let matched = collect_matched_rules(&snapshot, node_with_tag(&snapshot, "p"), &rules, None);
345        assert_eq!(matched[0].selector, "div.outer p");
346        assert!(declaration(&matched[0], "color").applied);
347    }
348
349    #[test]
350    fn inline_styles_beat_author_rules_but_not_important_ones() {
351        let snapshot = snapshot_for("<body><p style=\"color: black\">t</p></body>");
352        let target = node_with_tag(&snapshot, "p");
353
354        let styles = leak(resolved_for("p { color: red; }"));
355        let rules = RuleSet::from_declarations(styles, &media());
356        let matched = collect_matched_rules(&snapshot, target, &rules, Some("color: black"));
357        let inline = matched.first().expect("inline entry leads");
358        assert!(inline.inline && inline.origin == StyleOrigin::Author);
359        assert_eq!(inline.selector, "element.style");
360        assert!(declaration(inline, "color").applied);
361
362        let styles = leak(resolved_for("p { color: red !important; }"));
363        let rules = RuleSet::from_declarations(styles, &media());
364        let matched = collect_matched_rules(&snapshot, target, &rules, Some("color: black"));
365        let inline = matched.first().unwrap();
366        let stylesheet = matched.iter().find(|rule| rule.selector == "p").unwrap();
367        assert!(!declaration(inline, "color").applied);
368        assert!(declaration(stylesheet, "color").applied);
369    }
370
371    #[test]
372    fn user_agent_origin_ranks_below_author_origin() {
373        let snapshot = snapshot_for("<body><p>t</p></body>");
374        let mut combined = resolved_for_origin("p { margin-top: 0px; }", StyleOrigin::UserAgent);
375        combined.extend(resolved_for_origin(
376            "p { margin-top: 4px; }",
377            StyleOrigin::Author,
378        ));
379        let styles = leak(combined);
380        let rules = RuleSet::from_declarations(styles, &media());
381
382        let matched = collect_matched_rules(&snapshot, node_with_tag(&snapshot, "p"), &rules, None);
383        let ua = matched
384            .iter()
385            .find(|rule| rule.origin == StyleOrigin::UserAgent)
386            .expect("user-agent rule reported");
387        let author_rule = matched
388            .iter()
389            .find(|rule| rule.origin == StyleOrigin::Author)
390            .expect("author rule reported");
391        assert!(!declaration(ua, "margin-top").applied);
392        assert!(declaration(author_rule, "margin-top").applied);
393    }
394}