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