Skip to main content

azul_core/
style.rs

1//! DOM tree to CSS style tree cascading.
2//!
3//! Implements CSS selector matching (`matches_html_element`) and cascade-info
4//! construction (`construct_html_cascade_tree`). Used by `styled_dom` and
5//! `prop_cache` to resolve which CSS rules apply to each DOM node.
6
7use alloc::vec::Vec;
8
9use azul_css::css::{
10    AttributeMatchOp, CssAttributeSelector, CssContentGroup, CssNthChildSelector,
11    CssNthChildSelector::{Number, Even, Odd, Pattern}, CssPath, CssPathPseudoSelector, CssPathSelector,
12};
13
14use crate::{
15    dom::NodeData,
16    id::{NodeDataContainer, NodeDataContainerRef, NodeHierarchyRef, NodeId},
17    styled_dom::NodeHierarchyItem,
18};
19
20/// Has all the necessary information about the style CSS path
21#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22#[repr(C)]
23pub struct CascadeInfo {
24    pub index_in_parent: u32,
25    pub is_last_child: bool,
26}
27
28impl_option!(
29    CascadeInfo,
30    OptionCascadeInfo,
31    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
32);
33
34impl_vec!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor, CascadeInfoVecDestructorType, CascadeInfoVecSlice, OptionCascadeInfo);
35impl_vec_mut!(CascadeInfo, CascadeInfoVec);
36impl_vec_debug!(CascadeInfo, CascadeInfoVec);
37impl_vec_partialord!(CascadeInfo, CascadeInfoVec);
38impl_vec_clone!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor);
39impl_vec_partialeq!(CascadeInfo, CascadeInfoVec);
40
41impl CascadeInfoVec {
42    #[must_use] pub fn as_container(&self) -> NodeDataContainerRef<'_, CascadeInfo> {
43        NodeDataContainerRef {
44            internal: self.as_ref(),
45        }
46    }
47}
48
49/// Returns if the style CSS path matches the DOM node (i.e. if the DOM node should be styled by
50/// that element)
51#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
52#[must_use] pub fn matches_html_element(
53    css_path: &CssPath,
54    node_id: NodeId,
55    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
56    node_data: &NodeDataContainerRef<'_, NodeData>,
57    html_node_tree: &NodeDataContainerRef<'_, CascadeInfo>,
58    expected_path_ending: Option<CssPathPseudoSelector>,
59) -> bool {
60    use self::CssGroupSplitReason::{DirectChildren, Children, AdjacentSibling, GeneralSibling};
61
62    if css_path.selectors.is_empty() {
63        return false;
64    }
65
66    // Skip anonymous nodes - they are not part of the original DOM tree
67    // and should not participate in CSS selector matching
68    if node_data[node_id].is_anonymous() {
69        return false;
70    }
71
72    // Collect all selector groups (processed right-to-left from the CSS path).
73    let groups: Vec<(CssContentGroup<'_>, CssGroupSplitReason)> =
74        CssGroupIterator::new(css_path.selectors.as_ref()).collect();
75
76    if groups.is_empty() {
77        return false;
78    }
79
80    // The rightmost group must match the target node directly.
81    let (ref first_group, first_reason) = groups[0];
82    let is_last_content_group = groups.len() == 1;
83    if !selector_group_matches(
84        first_group,
85        html_node_tree[node_id],
86        &node_data[node_id],
87        node_id,
88        expected_path_ending.as_ref(),
89        is_last_content_group,
90    ) {
91        return false;
92    }
93
94    // Navigate from the target node upward/sideways through the DOM,
95    // matching each remaining selector group with its combinator.
96    let mut current_node = node_id;
97
98    for (group_idx, (content_group, _reason)) in groups.iter().enumerate().skip(1) {
99        // The combinator comes from the PREVIOUS group's reason
100        let combinator = groups[group_idx - 1].1;
101        let is_last = group_idx == groups.len() - 1;
102
103        match combinator {
104            DirectChildren => {
105                // Parent must match directly (child combinator `>`)
106                let parent = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
107                match parent {
108                    Some(p) if selector_group_matches(
109                        content_group, html_node_tree[p], &node_data[p], p,
110                        expected_path_ending.as_ref(), is_last,
111                    ) => { current_node = p; }
112                    _ => return false,
113                }
114            }
115            Children => {
116                // Search up ancestor chain for a match (descendant combinator ` `)
117                let mut ancestor = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
118                let mut found = false;
119                while let Some(anc) = ancestor {
120                    if selector_group_matches(
121                        content_group, html_node_tree[anc], &node_data[anc], anc,
122                        expected_path_ending.as_ref(), is_last,
123                    ) {
124                        current_node = anc;
125                        found = true;
126                        break;
127                    }
128                    ancestor = find_non_anonymous_parent(anc, node_hierarchy, node_data);
129                }
130                if !found {
131                    return false;
132                }
133            }
134            AdjacentSibling => {
135                // Immediate previous sibling must match (adjacent sibling `+`)
136                let sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
137                match sibling {
138                    Some(s) if selector_group_matches(
139                        content_group, html_node_tree[s], &node_data[s], s,
140                        expected_path_ending.as_ref(), is_last,
141                    ) => { current_node = s; }
142                    _ => return false,
143                }
144            }
145            GeneralSibling => {
146                // Search previous siblings for a match (general sibling `~`)
147                let mut sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
148                let mut found = false;
149                while let Some(sib) = sibling {
150                    if selector_group_matches(
151                        content_group, html_node_tree[sib], &node_data[sib], sib,
152                        expected_path_ending.as_ref(), is_last,
153                    ) {
154                        current_node = sib;
155                        found = true;
156                        break;
157                    }
158                    sibling = find_non_anonymous_prev_sibling(sib, node_hierarchy, node_data);
159                }
160                if !found {
161                    return false;
162                }
163            }
164        }
165    }
166
167    true
168}
169
170/// Find the first non-anonymous parent of a node.
171fn find_non_anonymous_parent(
172    node_id: NodeId,
173    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
174    node_data: &NodeDataContainerRef<'_, NodeData>,
175) -> Option<NodeId> {
176    let mut next = node_hierarchy[node_id].parent_id();
177    while let Some(n) = next {
178        if !node_data[n].is_anonymous() {
179            return Some(n);
180        }
181        next = node_hierarchy[n].parent_id();
182    }
183    None
184}
185
186/// Find the first non-anonymous previous sibling of a node.
187fn find_non_anonymous_prev_sibling(
188    node_id: NodeId,
189    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
190    node_data: &NodeDataContainerRef<'_, NodeData>,
191) -> Option<NodeId> {
192    let mut next = node_hierarchy[node_id].previous_sibling_id();
193    while let Some(n) = next {
194        if !node_data[n].is_anonymous() {
195            return Some(n);
196        }
197        next = node_hierarchy[n].previous_sibling_id();
198    }
199    None
200}
201
202/// A CSS group is a group of css selectors in a path that specify the rule that a
203/// certain node has to match, i.e. "div.main.foo" has to match three requirements:
204///
205/// - the node has to be of type div
206/// - the node has to have the class "main"
207/// - the node has to have the class "foo"
208///
209/// If any of these requirements are not met, the CSS block is discarded.
210///
211/// The `CssGroupIterator` splits the CSS path into semantic blocks, i.e.:
212///
213/// `"body > .foo.main > #baz"` will be split into `["body", ".foo.main", "#baz"]`
214#[derive(Debug)]
215pub struct CssGroupIterator<'a> {
216    pub css_path: &'a [CssPathSelector],
217    current_idx: usize,
218    last_reason: CssGroupSplitReason,
219}
220
221#[derive(Debug, Copy, Clone, PartialEq, Eq)]
222pub enum CssGroupSplitReason {
223    /// ".foo .main" - match any children
224    Children,
225    /// ".foo > .main" - match only direct children
226    DirectChildren,
227    /// ".foo + .main" - match adjacent sibling (immediately preceding)
228    AdjacentSibling,
229    /// ".foo ~ .main" - match general sibling (any preceding sibling)
230    GeneralSibling,
231}
232
233impl<'a> CssGroupIterator<'a> {
234    #[must_use] pub const fn new(css_path: &'a [CssPathSelector]) -> Self {
235        let initial_len = css_path.len();
236        Self {
237            css_path,
238            current_idx: initial_len,
239            last_reason: CssGroupSplitReason::Children,
240        }
241    }
242}
243
244impl<'a> Iterator for CssGroupIterator<'a> {
245    type Item = (CssContentGroup<'a>, CssGroupSplitReason);
246
247    fn next(&mut self) -> Option<(CssContentGroup<'a>, CssGroupSplitReason)> {
248        use self::CssPathSelector::{Children, DirectChildren, AdjacentSibling, GeneralSibling};
249
250        let mut new_idx = self.current_idx;
251
252        if new_idx == 0 {
253            return None;
254        }
255
256        let mut current_path = Vec::new();
257
258        while new_idx != 0 {
259            match self.css_path.get(new_idx - 1)? {
260                Children => {
261                    self.last_reason = CssGroupSplitReason::Children;
262                    break;
263                }
264                DirectChildren => {
265                    self.last_reason = CssGroupSplitReason::DirectChildren;
266                    break;
267                }
268                AdjacentSibling => {
269                    self.last_reason = CssGroupSplitReason::AdjacentSibling;
270                    break;
271                }
272                GeneralSibling => {
273                    self.last_reason = CssGroupSplitReason::GeneralSibling;
274                    break;
275                }
276                other => current_path.push(other),
277            }
278            new_idx -= 1;
279        }
280
281        // NOTE: Order inside of a ContentGroup is not important
282        // for matching elements, only important for testing
283        #[cfg(test)]
284        current_path.reverse();
285
286        if new_idx == 0 {
287            if current_path.is_empty() {
288                None
289            } else {
290                // Last element of path
291                self.current_idx = 0;
292                Some((current_path, self.last_reason))
293            }
294        } else {
295            // skip the "Children | DirectChildren" element itself
296            self.current_idx = new_idx - 1;
297            Some((current_path, self.last_reason))
298        }
299    }
300}
301
302#[must_use] pub fn construct_html_cascade_tree(
303    node_hierarchy: &NodeHierarchyRef<'_>,
304    node_depths_sorted: &[(usize, NodeId)],
305    node_data: &NodeDataContainerRef<'_, NodeData>,
306) -> NodeDataContainer<CascadeInfo> {
307    let mut nodes = (0..node_hierarchy.len())
308        .map(|_| CascadeInfo {
309            index_in_parent: 0,
310            is_last_child: false,
311        })
312        .collect::<Vec<_>>();
313
314    for (_depth, parent_id) in node_depths_sorted {
315        // Per CSS Selectors Level 4 §13: "Standalone text and other non-element
316        // nodes are not counted when calculating the position of an element in
317        // the list of children of its parent."
318        //
319        // We count only element siblings when computing index_in_parent.
320        let element_index_in_parent = parent_id
321            .preceding_siblings(node_hierarchy)
322            .filter(|sib_id| !node_data[*sib_id].is_text_node())
323            .count();
324
325        let parent_html_matcher = CascadeInfo {
326            index_in_parent: u32::try_from(element_index_in_parent.saturating_sub(1))
327                .unwrap_or(u32::MAX),
328            // Necessary for :last selectors — find last element sibling
329            is_last_child: {
330                let mut is_last_element = true;
331                let mut next = node_hierarchy[*parent_id].next_sibling;
332                while let Some(sib_id) = next {
333                    if !node_data[sib_id].is_text_node() {
334                        is_last_element = false;
335                        break;
336                    }
337                    next = node_hierarchy[sib_id].next_sibling;
338                }
339                is_last_element
340            },
341        };
342
343        nodes[parent_id.index()] = parent_html_matcher;
344
345        // Count only element children for index_in_parent
346        let mut element_idx: u32 = 0;
347        for child_id in parent_id.children(node_hierarchy) {
348            let is_text = node_data[child_id].is_text_node();
349
350            // Find whether this is the last element child (skip trailing text nodes)
351            let is_last_element_child = if is_text {
352                false
353            } else {
354                let mut is_last = true;
355                let mut next = node_hierarchy[child_id].next_sibling;
356                while let Some(sib_id) = next {
357                    if !node_data[sib_id].is_text_node() {
358                        is_last = false;
359                        break;
360                    }
361                    next = node_hierarchy[sib_id].next_sibling;
362                }
363                is_last
364            };
365
366            let child_html_matcher = CascadeInfo {
367                index_in_parent: element_idx,
368                is_last_child: is_last_element_child,
369            };
370
371            nodes[child_id.index()] = child_html_matcher;
372
373            if !is_text {
374                element_idx += 1;
375            }
376        }
377    }
378
379    NodeDataContainer { internal: nodes }
380}
381
382/// Checks whether the last selector in `path` matches the given pseudo-selector `target`.
383///
384/// Known limitation: this only inspects the final selector in the path, so compound
385/// selectors like `div:hover:first-child` may not be filtered correctly when `target`
386/// is `None` — only the very last pseudo-selector is tested.
387#[inline]
388#[must_use] pub fn rule_ends_with(path: &CssPath, target: Option<CssPathPseudoSelector>) -> bool {
389    // Helper to check if a pseudo-selector is "interactive" (requires user interaction state)
390    // vs "structural" (based on DOM structure only)
391    const fn is_interactive_pseudo(p: &CssPathPseudoSelector) -> bool {
392        matches!(
393            p,
394            CssPathPseudoSelector::Hover
395                | CssPathPseudoSelector::Active
396                | CssPathPseudoSelector::Focus
397                | CssPathPseudoSelector::Backdrop
398                | CssPathPseudoSelector::Dragging
399                | CssPathPseudoSelector::DragOver
400        )
401    }
402
403    let Some(last) = path.selectors.as_ref().last() else {
404        return false;
405    };
406    target.map_or_else(
407        || match last {
408            // Only reject interactive pseudo-selectors (hover, active, focus).
409            // Structural pseudo-selectors (nth-child, first, last) should be allowed.
410            CssPathSelector::PseudoSelector(p) => !is_interactive_pseudo(p),
411            _ => true,
412        },
413        |s| matches!(last, CssPathSelector::PseudoSelector(q) if *q == s),
414    )
415}
416
417/// Matches a single group of CSS selectors against a DOM node.
418///
419/// Returns true if all selectors in the group match the given node.
420/// Combinator selectors (>, +, ~, space) should not appear in the group.
421fn selector_group_matches(
422    selectors: &[&CssPathSelector],
423    html_node: CascadeInfo,
424    node_data: &NodeData,
425    node_id: NodeId,
426    expected_path_ending: Option<&CssPathPseudoSelector>,
427    is_last_content_group: bool,
428) -> bool {
429    selectors.iter().all(|selector| {
430        match_single_selector(
431            selector,
432            html_node,
433            node_data,
434            node_id,
435            expected_path_ending,
436            is_last_content_group,
437        )
438    })
439}
440
441/// Matches a single CSS selector against a DOM node.
442fn match_single_selector(
443    selector: &CssPathSelector,
444    html_node: CascadeInfo,
445    node_data: &NodeData,
446    node_id: NodeId,
447    expected_path_ending: Option<&CssPathPseudoSelector>,
448    is_last_content_group: bool,
449) -> bool {
450    use self::CssPathSelector::{Global, Root, Type, Class, Id, PseudoSelector, Attribute, DirectChildren, Children, AdjacentSibling, GeneralSibling};
451
452    match selector {
453        Global => true,
454        // `Root(range)` (scope marker, #47): matches any node WITHIN the subtree
455        // range `[start, end]`. The range is chosen when the scope is pushed
456        // (`CssPath::push_front_scope`):
457        //  - a bare-decl `with_css` rule (`* { … }`) is scoped node-only (`[start,
458        //    start]`) → inline-style semantics: it applies to the OWNER only, so a
459        //    non-root `background` can't leak to descendants/siblings (#47 leak fix).
460        //  - a component rule with a real selector (`.menu-item`, from
461        //    `add_component_css`) is scoped to the whole subtree (`[start, end]`) so
462        //    its selector matches descendants of the owner (a menu container styling
463        //    its `.menu-item` children). Compounded with the rest of the path,
464        //    `[Root(range), Class(x)]` means "a node in range that also matches `.x`".
465        Root(range) => range.contains(node_id.index()),
466        Type(t) => node_data.get_node_type().get_path() == *t,
467        Class(c) => node_data.has_class(c.as_str()),
468        Id(id) => node_data.has_id(id.as_str()),
469        PseudoSelector(p) => {
470            match_pseudo_selector(p, html_node, expected_path_ending, is_last_content_group)
471        }
472        Attribute(a) => match_attribute_selector(a, node_data),
473        DirectChildren | Children | AdjacentSibling | GeneralSibling => false,
474    }
475}
476
477/// Matches an attribute selector (`[name]`, `[name="v"]`, `[name~="v"]`, ...) against a node.
478///
479/// Some attributes (notably `class`) are stored as multiple separate entries in
480/// `node_data.attributes()` rather than a single space-joined string. We collect
481/// every matching value and treat the matcher as "any value satisfies the op",
482/// so that `[class~="primary"]` matches a node with classes `foo primary bar`.
483fn match_attribute_selector(sel: &CssAttributeSelector, node_data: &NodeData) -> bool {
484    let name = sel.name.as_str();
485    let target = sel.value.as_ref().map(azul_css::AzString::as_str);
486
487    let check = |actual: &str| -> bool {
488        match (&sel.op, target) {
489            (AttributeMatchOp::Exists, _) => true,
490            (AttributeMatchOp::Eq, Some(t)) => actual == t,
491            (AttributeMatchOp::Includes, Some(t)) => {
492                if t.is_empty() || t.contains(char::is_whitespace) {
493                    return false;
494                }
495                actual.split_whitespace().any(|word| word == t)
496            }
497            (AttributeMatchOp::DashMatch, Some(t)) => {
498                actual == t || actual.starts_with(&alloc::format!("{t}-"))
499            }
500            (AttributeMatchOp::Prefix, Some(t)) => !t.is_empty() && actual.starts_with(t),
501            (AttributeMatchOp::Suffix, Some(t)) => !t.is_empty() && actual.ends_with(t),
502            (AttributeMatchOp::Substring, Some(t)) => !t.is_empty() && actual.contains(t),
503            // Operator with a missing value (parser should reject these — be defensive).
504            (_, None) => false,
505        }
506    };
507
508    for attr in node_data.attributes() {
509        if attr.name() != name {
510            continue;
511        }
512        if check(attr.value().as_str()) {
513            return true;
514        }
515    }
516
517    false
518}
519
520/// Matches a pseudo-selector (:first, :last, :nth-child, :hover, etc.) against a node.
521fn match_pseudo_selector(
522    pseudo: &CssPathPseudoSelector,
523    html_node: CascadeInfo,
524    expected_path_ending: Option<&CssPathPseudoSelector>,
525    is_last_content_group: bool,
526) -> bool {
527    match pseudo {
528        CssPathPseudoSelector::First => match_first_child(html_node),
529        CssPathPseudoSelector::Last => match_last_child(html_node),
530        CssPathPseudoSelector::NthChild(pattern) => match_nth_child(html_node, pattern),
531        CssPathPseudoSelector::Hover => match_interactive_pseudo(
532            &CssPathPseudoSelector::Hover,
533            expected_path_ending,
534            is_last_content_group,
535        ),
536        CssPathPseudoSelector::Active => match_interactive_pseudo(
537            &CssPathPseudoSelector::Active,
538            expected_path_ending,
539            is_last_content_group,
540        ),
541        CssPathPseudoSelector::Focus => match_interactive_pseudo(
542            &CssPathPseudoSelector::Focus,
543            expected_path_ending,
544            is_last_content_group,
545        ),
546        CssPathPseudoSelector::Backdrop => match_interactive_pseudo(
547            &CssPathPseudoSelector::Backdrop,
548            expected_path_ending,
549            is_last_content_group,
550        ),
551        CssPathPseudoSelector::Dragging => match_interactive_pseudo(
552            &CssPathPseudoSelector::Dragging,
553            expected_path_ending,
554            is_last_content_group,
555        ),
556        CssPathPseudoSelector::DragOver => match_interactive_pseudo(
557            &CssPathPseudoSelector::DragOver,
558            expected_path_ending,
559            is_last_content_group,
560        ),
561        CssPathPseudoSelector::Lang(lang) => {
562            // :lang() is matched via DynamicSelector at runtime, not during CSS cascade
563            // During cascade, we just check if this is the expected ending
564            if let Some(CssPathPseudoSelector::Lang(expected_lang)) = expected_path_ending {
565                return lang == expected_lang;
566            }
567            // If not specifically looking for :lang, it doesn't match structurally
568            false
569        }
570    }
571}
572
573/// Returns true if the node is the first child of its parent.
574const fn match_first_child(html_node: CascadeInfo) -> bool {
575    html_node.index_in_parent == 0
576}
577
578/// Returns true if the node is the last child of its parent.
579const fn match_last_child(html_node: CascadeInfo) -> bool {
580    html_node.is_last_child
581}
582
583/// Matches :nth-child(n), :nth-child(even), :nth-child(odd), or :nth-child(An+B) patterns.
584fn match_nth_child(html_node: CascadeInfo, pattern: &CssNthChildSelector) -> bool {
585    use azul_css::css::CssNthChildPattern;
586
587    // nth-child is 1-indexed, index_in_parent is 0-indexed
588    let index = html_node.index_in_parent + 1;
589
590    match pattern {
591        Number(n) => index == *n,
592        Even => index.is_multiple_of(2),
593        Odd => index % 2 == 1,
594        Pattern(CssNthChildPattern {
595            pattern_repeat,
596            offset,
597        }) => {
598            if *pattern_repeat == 0 {
599                index == *offset
600            } else {
601                index >= *offset && (index - offset).is_multiple_of(*pattern_repeat)
602            }
603        }
604    }
605}
606
607/// Matches interactive pseudo-selectors (:hover, :active, :focus).
608/// These only apply if they appear in the last content group of the CSS path.
609fn match_interactive_pseudo(
610    pseudo: &CssPathPseudoSelector,
611    expected_path_ending: Option<&CssPathPseudoSelector>,
612    is_last_content_group: bool,
613) -> bool {
614    is_last_content_group && expected_path_ending == Some(pseudo)
615}
616
617#[cfg(test)]
618#[allow(clippy::pedantic, clippy::nursery, clippy::too_many_lines)]
619mod autotest_generated {
620    use azul_css::{
621        css::{CssNthChildPattern, CssScopeRange, NodeTypeTag},
622        OptionString,
623    };
624
625    use super::*;
626    use crate::{
627        dom::{AttributeNameValue, AttributeType, NodeType},
628        id::Node,
629    };
630
631    // ---------------------------------------------------------------------
632    // helpers
633    // ---------------------------------------------------------------------
634
635    fn node(
636        parent: Option<usize>,
637        prev: Option<usize>,
638        next: Option<usize>,
639        last_child: Option<usize>,
640    ) -> Node {
641        Node {
642            parent: parent.map(NodeId::new),
643            previous_sibling: prev.map(NodeId::new),
644            next_sibling: next.map(NodeId::new),
645            last_child: last_child.map(NodeId::new),
646        }
647    }
648
649    fn items(nodes: &[Node]) -> Vec<NodeHierarchyItem> {
650        nodes.iter().map(|n| NodeHierarchyItem::from(*n)).collect()
651    }
652
653    fn div_with(id: Option<&str>, class: Option<&str>) -> NodeData {
654        let mut nd = NodeData::create_div();
655        if let Some(i) = id {
656            nd.add_id(i.into());
657        }
658        if let Some(c) = class {
659            nd.add_class(c.into());
660        }
661        nd
662    }
663
664    fn node_with_attrs(attrs: Vec<AttributeType>) -> NodeData {
665        let mut nd = NodeData::create_div();
666        nd.set_attributes(attrs.into());
667        nd
668    }
669
670    fn custom(name: &str, value: &str) -> AttributeType {
671        AttributeType::Custom(AttributeNameValue {
672            attr_name: name.into(),
673            value: value.into(),
674        })
675    }
676
677    fn attr_sel(name: &str, op: AttributeMatchOp, value: Option<&str>) -> CssAttributeSelector {
678        CssAttributeSelector {
679            name: name.into(),
680            op,
681            value: value.map_or(OptionString::None, |v| OptionString::Some(v.into())),
682        }
683    }
684
685    fn info(index_in_parent: u32, is_last_child: bool) -> CascadeInfo {
686        CascadeInfo {
687            index_in_parent,
688            is_last_child,
689        }
690    }
691
692    /// The shared fixture DOM. Note node 2 is a **text node** sitting between
693    /// two element siblings — the whole point is to exercise the "text nodes
694    /// are not counted as element siblings" rule (CSS Selectors L4 §13).
695    ///
696    /// ```text
697    /// 0 body
698    /// ├── 1 div#first.a
699    /// ├── 2 "hello"          (text)
700    /// ├── 3 div.b
701    /// │   └── 4 p.inner
702    /// └── 5 div.c
703    /// ```
704    fn sample_hierarchy() -> Vec<Node> {
705        vec![
706            node(None, None, None, Some(5)),
707            node(Some(0), None, Some(2), None),
708            node(Some(0), Some(1), Some(3), None),
709            node(Some(0), Some(2), Some(5), Some(4)),
710            node(Some(3), None, None, None),
711            node(Some(0), Some(3), None, None),
712        ]
713    }
714
715    fn sample_node_data() -> Vec<NodeData> {
716        let mut p = NodeData::create_node(NodeType::P);
717        p.add_class("inner".into());
718        vec![
719            NodeData::create_body(),
720            div_with(Some("first"), Some("a")),
721            NodeData::create_text("hello"),
722            div_with(None, Some("b")),
723            p,
724            div_with(None, Some("c")),
725        ]
726    }
727
728    /// Runs `matches_html_element` against the sample fixture.
729    fn matches(
730        selectors: Vec<CssPathSelector>,
731        node_index: usize,
732        expected_path_ending: Option<CssPathPseudoSelector>,
733    ) -> bool {
734        let hierarchy = sample_hierarchy();
735        let hier_items = items(&hierarchy);
736        let data = sample_node_data();
737
738        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
739        let data_ref = NodeDataContainerRef::from_slice(&data);
740        let depths = hierarchy_ref.get_parents_sorted_by_depth();
741        let cascade = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
742
743        matches_html_element(
744            &CssPath::new(selectors),
745            NodeId::new(node_index),
746            &NodeDataContainerRef::from_slice(&hier_items),
747            &data_ref,
748            &cascade.as_ref(),
749            expected_path_ending,
750        )
751    }
752
753    // ---------------------------------------------------------------------
754    // CascadeInfoVec::as_container  (getter)
755    // ---------------------------------------------------------------------
756
757    #[test]
758    fn cascade_info_vec_as_container_preserves_every_entry() {
759        let v: CascadeInfoVec = vec![info(0, false), info(1, false), info(2, true)].into();
760        let c = v.as_container();
761
762        assert_eq!(c.len(), 3);
763        assert!(!c.is_empty());
764        assert_eq!(c[NodeId::new(0)], info(0, false));
765        assert_eq!(c[NodeId::new(2)], info(2, true));
766        // Out-of-range access must be a `None`, not a panic.
767        assert_eq!(c.get(NodeId::new(3)), None);
768        assert_eq!(c.get(NodeId::new(usize::MAX)), None);
769    }
770
771    #[test]
772    fn cascade_info_vec_as_container_on_empty_vec_does_not_panic() {
773        let v: CascadeInfoVec = Vec::new().into();
774        let c = v.as_container();
775        assert_eq!(c.len(), 0);
776        assert!(c.is_empty());
777        assert_eq!(c.get(NodeId::new(0)), None);
778    }
779
780    #[test]
781    fn cascade_info_vec_as_container_survives_extreme_field_values() {
782        let v: CascadeInfoVec = vec![info(u32::MAX, true)].into();
783        let c = v.as_container();
784        assert_eq!(c[NodeId::new(0)].index_in_parent, u32::MAX);
785        assert!(c[NodeId::new(0)].is_last_child);
786    }
787
788    // ---------------------------------------------------------------------
789    // CssGroupIterator
790    // ---------------------------------------------------------------------
791
792    #[test]
793    fn css_group_iterator_new_records_the_path_and_yields_nothing_when_empty() {
794        let empty: [CssPathSelector; 0] = [];
795        let it = CssGroupIterator::new(&empty);
796        assert!(it.css_path.is_empty());
797        assert_eq!(CssGroupIterator::new(&empty).count(), 0);
798    }
799
800    #[test]
801    fn css_group_iterator_new_keeps_the_slice_it_was_given() {
802        let path = vec![
803            CssPathSelector::Global,
804            CssPathSelector::Children,
805            CssPathSelector::Class("x".into()),
806        ];
807        let it = CssGroupIterator::new(&path);
808        assert_eq!(it.css_path.len(), 3);
809        assert_eq!(it.css_path, path.as_slice());
810    }
811
812    #[test]
813    fn css_group_iterator_splits_right_to_left_with_the_left_hand_combinator() {
814        // `body > .foo.main .baz`
815        let path = vec![
816            CssPathSelector::Type(NodeTypeTag::Body),
817            CssPathSelector::DirectChildren,
818            CssPathSelector::Class("foo".into()),
819            CssPathSelector::Class("main".into()),
820            CssPathSelector::Children,
821            CssPathSelector::Class("baz".into()),
822        ];
823
824        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
825        assert_eq!(groups.len(), 3);
826
827        // Group 0 is the RIGHTMOST group (the subject of the selector).
828        assert_eq!(groups[0].0, vec![&path[5]]);
829        assert_eq!(groups[0].1, CssGroupSplitReason::Children);
830
831        assert_eq!(groups[1].0, vec![&path[2], &path[3]]);
832        assert_eq!(groups[1].1, CssGroupSplitReason::DirectChildren);
833
834        assert_eq!(groups[2].0, vec![&path[0]]);
835        // NOTE: the leftmost group's `reason` is a carry-over from the previous
836        // split (there is no combinator to its left). `matches_html_element`
837        // never reads it — it uses `groups[i - 1].1` as the combinator for
838        // group `i` — so we deliberately do not assert on it here.
839    }
840
841    #[test]
842    fn css_group_iterator_yields_an_empty_group_for_a_trailing_combinator() {
843        // Malformed path `.foo >` (a combinator with nothing to its right).
844        let path = vec![
845            CssPathSelector::Class("foo".into()),
846            CssPathSelector::DirectChildren,
847        ];
848        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
849
850        assert_eq!(groups.len(), 2);
851        assert!(
852            groups[0].0.is_empty(),
853            "the group to the right of the dangling combinator is empty"
854        );
855        assert_eq!(groups[0].1, CssGroupSplitReason::DirectChildren);
856        assert_eq!(groups[1].0, vec![&path[0]]);
857    }
858
859    #[test]
860    fn css_group_iterator_terminates_on_consecutive_combinators() {
861        // `.a > ~ .b` — two combinators in a row (parser should never emit this,
862        // but the iterator must not loop forever or drop selectors).
863        let path = vec![
864            CssPathSelector::Class("a".into()),
865            CssPathSelector::Children,
866            CssPathSelector::DirectChildren,
867            CssPathSelector::Class("b".into()),
868        ];
869        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
870
871        assert_eq!(groups.len(), 3);
872        assert_eq!(groups[0].0, vec![&path[3]]);
873        assert!(groups[1].0.is_empty(), "the group between the two combinators is empty");
874        assert_eq!(groups[2].0, vec![&path[0]]);
875    }
876
877    #[test]
878    fn css_group_iterator_only_combinators_yields_only_empty_groups() {
879        let path = vec![
880            CssPathSelector::Children,
881            CssPathSelector::DirectChildren,
882            CssPathSelector::AdjacentSibling,
883            CssPathSelector::GeneralSibling,
884        ];
885        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
886
887        // Four combinators, four splits, and the final `new_idx == 0 &&
888        // current_path.is_empty()` branch ends the iteration.
889        assert_eq!(groups.len(), 4);
890        assert!(groups.iter().all(|(g, _)| g.is_empty()));
891    }
892
893    #[test]
894    fn css_group_iterator_conserves_every_non_combinator_selector_on_a_huge_path() {
895        // 10_000 selectors: `.c0 .c1 .c2 ...` — must terminate and must not
896        // lose or duplicate a single selector.
897        let mut path = Vec::new();
898        for i in 0..5_000u32 {
899            if i != 0 {
900                path.push(CssPathSelector::Children);
901            }
902            path.push(CssPathSelector::Class(alloc::format!(".c{i}").into()));
903        }
904
905        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
906        assert_eq!(groups.len(), 5_000);
907        let total: usize = groups.iter().map(|(g, _)| g.len()).sum();
908        assert_eq!(
909            total, 5_000,
910            "every non-combinator selector must appear in exactly one group"
911        );
912        assert!(groups.iter().all(|(_, r)| *r == CssGroupSplitReason::Children));
913    }
914
915    // ---------------------------------------------------------------------
916    // rule_ends_with
917    // ---------------------------------------------------------------------
918
919    #[test]
920    fn rule_ends_with_empty_path_is_always_false() {
921        let empty = CssPath::new(Vec::new());
922        assert!(!rule_ends_with(&empty, None));
923        assert!(!rule_ends_with(&empty, Some(CssPathPseudoSelector::Hover)));
924        assert!(!rule_ends_with(
925            &empty,
926            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Even))
927        ));
928    }
929
930    #[test]
931    fn rule_ends_with_none_rejects_interactive_pseudos_but_keeps_structural_ones() {
932        let ends_with = |s: CssPathSelector| {
933            rule_ends_with(&CssPath::new(vec![CssPathSelector::Class("a".into()), s]), None)
934        };
935
936        // Interactive => rejected for the "normal" (non-pseudo) pass.
937        for p in [
938            CssPathPseudoSelector::Hover,
939            CssPathPseudoSelector::Active,
940            CssPathPseudoSelector::Focus,
941            CssPathPseudoSelector::Backdrop,
942            CssPathPseudoSelector::Dragging,
943            CssPathPseudoSelector::DragOver,
944        ] {
945            assert!(
946                !ends_with(CssPathSelector::PseudoSelector(p.clone())),
947                "interactive pseudo {p:?} must not end a `None` rule"
948            );
949        }
950
951        // Structural => kept.
952        assert!(ends_with(CssPathSelector::PseudoSelector(
953            CssPathPseudoSelector::First
954        )));
955        assert!(ends_with(CssPathSelector::PseudoSelector(
956            CssPathPseudoSelector::Last
957        )));
958        assert!(ends_with(CssPathSelector::PseudoSelector(
959            CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd)
960        )));
961        // Non-pseudo endings are always kept.
962        assert!(ends_with(CssPathSelector::Global));
963        assert!(ends_with(CssPathSelector::Class("b".into())));
964        assert!(ends_with(CssPathSelector::Type(NodeTypeTag::Div)));
965        assert!(ends_with(CssPathSelector::DirectChildren));
966    }
967
968    #[test]
969    fn rule_ends_with_some_target_requires_an_exact_match_on_the_last_selector() {
970        let hover = CssPath::new(vec![
971            CssPathSelector::Class("a".into()),
972            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
973        ]);
974        assert!(rule_ends_with(&hover, Some(CssPathPseudoSelector::Hover)));
975        assert!(!rule_ends_with(&hover, Some(CssPathPseudoSelector::Active)));
976        assert!(!rule_ends_with(&hover, Some(CssPathPseudoSelector::Focus)));
977
978        // A non-pseudo ending never matches a pseudo target.
979        let plain = CssPath::new(vec![CssPathSelector::Class("a".into())]);
980        assert!(!rule_ends_with(&plain, Some(CssPathPseudoSelector::Hover)));
981
982        // Documented limitation: only the VERY LAST selector is inspected, so
983        // `.a:hover:first` does not count as ending with `:hover`.
984        let compound = CssPath::new(vec![
985            CssPathSelector::Class("a".into()),
986            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
987            CssPathSelector::PseudoSelector(CssPathPseudoSelector::First),
988        ]);
989        assert!(!rule_ends_with(&compound, Some(CssPathPseudoSelector::Hover)));
990        assert!(rule_ends_with(&compound, Some(CssPathPseudoSelector::First)));
991    }
992
993    #[test]
994    fn rule_ends_with_compares_nth_child_and_lang_payloads() {
995        let nth = |s| {
996            CssPath::new(vec![CssPathSelector::PseudoSelector(
997                CssPathPseudoSelector::NthChild(s),
998            )])
999        };
1000        assert!(rule_ends_with(
1001            &nth(CssNthChildSelector::Number(3)),
1002            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(3)))
1003        ));
1004        assert!(!rule_ends_with(
1005            &nth(CssNthChildSelector::Number(3)),
1006            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(4)))
1007        ));
1008        assert!(!rule_ends_with(
1009            &nth(CssNthChildSelector::Even),
1010            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd))
1011        ));
1012
1013        // Unicode / boundary language tags must compare by value, not by prefix.
1014        let lang = |s: &str| {
1015            CssPath::new(vec![CssPathSelector::PseudoSelector(
1016                CssPathPseudoSelector::Lang(s.into()),
1017            )])
1018        };
1019        assert!(rule_ends_with(
1020            &lang("zh-Hant-🎉"),
1021            Some(CssPathPseudoSelector::Lang("zh-Hant-🎉".into()))
1022        ));
1023        assert!(!rule_ends_with(
1024            &lang("zh-Hant-🎉"),
1025            Some(CssPathPseudoSelector::Lang("zh".into()))
1026        ));
1027        assert!(!rule_ends_with(&lang(""), Some(CssPathPseudoSelector::Lang("de".into()))));
1028    }
1029
1030    // ---------------------------------------------------------------------
1031    // match_first_child / match_last_child
1032    // ---------------------------------------------------------------------
1033
1034    #[test]
1035    fn match_first_and_last_child_read_only_their_own_field() {
1036        assert!(match_first_child(info(0, false)));
1037        assert!(!match_first_child(info(1, true)));
1038        assert!(!match_first_child(info(u32::MAX, true)));
1039
1040        assert!(match_last_child(info(0, true)));
1041        assert!(!match_last_child(info(0, false)));
1042        assert!(match_last_child(info(u32::MAX, true)));
1043    }
1044
1045    // ---------------------------------------------------------------------
1046    // match_nth_child  (numeric: 1-indexing, saturation, div-by-zero, overflow)
1047    // ---------------------------------------------------------------------
1048
1049    #[test]
1050    fn match_nth_child_is_one_indexed() {
1051        // index_in_parent 0 == :nth-child(1) == odd
1052        assert!(match_nth_child(info(0, false), &CssNthChildSelector::Number(1)));
1053        assert!(!match_nth_child(info(0, false), &CssNthChildSelector::Number(0)));
1054        assert!(match_nth_child(info(0, false), &CssNthChildSelector::Odd));
1055        assert!(!match_nth_child(info(0, false), &CssNthChildSelector::Even));
1056
1057        assert!(match_nth_child(info(1, false), &CssNthChildSelector::Number(2)));
1058        assert!(match_nth_child(info(1, false), &CssNthChildSelector::Even));
1059        assert!(!match_nth_child(info(1, false), &CssNthChildSelector::Odd));
1060    }
1061
1062    #[test]
1063    fn match_nth_child_number_matches_exactly_one_index() {
1064        for n in 1..64u32 {
1065            for idx in 0..64u32 {
1066                let matched = match_nth_child(info(idx, false), &CssNthChildSelector::Number(n));
1067                assert_eq!(matched, idx + 1 == n, "nth-child({n}) vs index {idx}");
1068            }
1069        }
1070        // `:nth-child(0)` can never match: the index is 1-based.
1071        for idx in 0..64u32 {
1072            assert!(!match_nth_child(info(idx, false), &CssNthChildSelector::Number(0)));
1073        }
1074    }
1075
1076    #[test]
1077    fn match_nth_child_even_and_odd_partition_every_index() {
1078        for idx in 0..1_000u32 {
1079            let even = match_nth_child(info(idx, false), &CssNthChildSelector::Even);
1080            let odd = match_nth_child(info(idx, false), &CssNthChildSelector::Odd);
1081            assert_ne!(even, odd, "index {idx} must be exactly one of even/odd");
1082        }
1083    }
1084
1085    #[test]
1086    fn match_nth_child_pattern_agrees_with_the_even_and_odd_shorthands() {
1087        // CSS: `even` == `2n`, `odd` == `2n+1`.
1088        for idx in 0..256u32 {
1089            let even_pat = CssNthChildSelector::Pattern(CssNthChildPattern {
1090                pattern_repeat: 2,
1091                offset: 0,
1092            });
1093            let odd_pat = CssNthChildSelector::Pattern(CssNthChildPattern {
1094                pattern_repeat: 2,
1095                offset: 1,
1096            });
1097            assert_eq!(
1098                match_nth_child(info(idx, false), &even_pat),
1099                match_nth_child(info(idx, false), &CssNthChildSelector::Even),
1100                "2n disagrees with `even` at index {idx}"
1101            );
1102            assert_eq!(
1103                match_nth_child(info(idx, false), &odd_pat),
1104                match_nth_child(info(idx, false), &CssNthChildSelector::Odd),
1105                "2n+1 disagrees with `odd` at index {idx}"
1106            );
1107        }
1108    }
1109
1110    #[test]
1111    fn match_nth_child_zero_repeat_never_divides_by_zero() {
1112        // `0n+3` matches only the 3rd child; `0n+0` matches nothing (1-based).
1113        let only_third = CssNthChildSelector::Pattern(CssNthChildPattern {
1114            pattern_repeat: 0,
1115            offset: 3,
1116        });
1117        let never = CssNthChildSelector::Pattern(CssNthChildPattern {
1118            pattern_repeat: 0,
1119            offset: 0,
1120        });
1121
1122        for idx in 0..32u32 {
1123            assert_eq!(match_nth_child(info(idx, false), &only_third), idx == 2);
1124            assert!(
1125                !match_nth_child(info(idx, false), &never),
1126                "0n+0 must never match (index is 1-based)"
1127            );
1128        }
1129    }
1130
1131    #[test]
1132    fn match_nth_child_pattern_below_the_offset_does_not_underflow() {
1133        // `2n+5`: nothing below the 5th child may match, and the `index - offset`
1134        // subtraction must never be reached for those.
1135        let pat = CssNthChildSelector::Pattern(CssNthChildPattern {
1136            pattern_repeat: 2,
1137            offset: 5,
1138        });
1139        for idx in 0..4u32 {
1140            assert!(!match_nth_child(info(idx, false), &pat), "index {idx} < offset");
1141        }
1142        assert!(match_nth_child(info(4, false), &pat)); // 5th child
1143        assert!(!match_nth_child(info(5, false), &pat)); // 6th
1144        assert!(match_nth_child(info(6, false), &pat)); // 7th
1145    }
1146
1147    #[test]
1148    fn match_nth_child_with_a_u32_max_offset_does_not_underflow() {
1149        let pat = CssNthChildSelector::Pattern(CssNthChildPattern {
1150            pattern_repeat: 1,
1151            offset: u32::MAX,
1152        });
1153        assert!(!match_nth_child(info(0, false), &pat));
1154        assert!(!match_nth_child(info(1_000, false), &pat));
1155        // index == u32::MAX (index_in_parent == u32::MAX - 1) is the largest
1156        // index that can be formed without overflowing the `+ 1`.
1157        assert!(match_nth_child(info(u32::MAX - 1, false), &pat));
1158    }
1159
1160    /// BOUNDARY: `match_nth_child` computes `index_in_parent + 1` unchecked.
1161    /// `index_in_parent == u32::MAX` is reachable — `construct_html_cascade_tree`
1162    /// itself saturates to `u32::MAX` (`unwrap_or(u32::MAX)`), and `CascadeInfo`
1163    /// is a `#[repr(C)]` struct with public fields. Debug builds therefore panic
1164    /// on overflow, release builds wrap the index to 0. Assert that the wrap can
1165    /// never silently produce the *wrong* answer for even/odd/number.
1166    #[cfg(feature = "std")]
1167    #[test]
1168    fn match_nth_child_at_u32_max_index_never_answers_wrongly() {
1169        // The true 1-based index here is 2^32, which is even.
1170        let even = std::panic::catch_unwind(|| {
1171            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Even)
1172        });
1173        let odd = std::panic::catch_unwind(|| {
1174            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Odd)
1175        });
1176        let one = std::panic::catch_unwind(|| {
1177            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Number(1))
1178        });
1179
1180        // debug: the overflow check fires — loud failure, acceptable.
1181        // release: the index wraps to 0, which must still not flip an answer.
1182        if let Ok(v) = even {
1183            assert!(v, "2^32 is even");
1184        }
1185        if let Ok(v) = odd {
1186            assert!(!v, "2^32 is not odd");
1187        }
1188        if let Ok(v) = one {
1189            assert!(!v, "the 2^32-th child is not the 1st child");
1190        }
1191    }
1192
1193    // ---------------------------------------------------------------------
1194    // match_interactive_pseudo / match_pseudo_selector
1195    // ---------------------------------------------------------------------
1196
1197    #[test]
1198    fn match_interactive_pseudo_needs_both_the_last_group_and_the_expected_ending() {
1199        let hover = CssPathPseudoSelector::Hover;
1200        let active = CssPathPseudoSelector::Active;
1201
1202        assert!(match_interactive_pseudo(&hover, Some(&hover), true));
1203        assert!(!match_interactive_pseudo(&hover, Some(&hover), false));
1204        assert!(!match_interactive_pseudo(&hover, Some(&active), true));
1205        assert!(!match_interactive_pseudo(&hover, None, true));
1206        assert!(!match_interactive_pseudo(&hover, None, false));
1207    }
1208
1209    #[test]
1210    fn match_pseudo_selector_routes_every_interactive_pseudo_through_the_gate() {
1211        for p in [
1212            CssPathPseudoSelector::Hover,
1213            CssPathPseudoSelector::Active,
1214            CssPathPseudoSelector::Focus,
1215            CssPathPseudoSelector::Backdrop,
1216            CssPathPseudoSelector::Dragging,
1217            CssPathPseudoSelector::DragOver,
1218        ] {
1219            assert!(
1220                match_pseudo_selector(&p, info(0, false), Some(&p), true),
1221                "{p:?} must match when it is the expected ending of the last group"
1222            );
1223            assert!(
1224                !match_pseudo_selector(&p, info(0, false), Some(&p), false),
1225                "{p:?} must not match outside the last content group"
1226            );
1227            assert!(
1228                !match_pseudo_selector(&p, info(0, false), None, true),
1229                "{p:?} must not match when no pseudo state is expected"
1230            );
1231        }
1232    }
1233
1234    #[test]
1235    fn match_pseudo_selector_structural_pseudos_ignore_the_expected_ending() {
1236        let hover = CssPathPseudoSelector::Hover;
1237
1238        // :first / :last / :nth-child depend only on the CascadeInfo.
1239        for (expected, is_last) in [(None, true), (Some(&hover), false), (Some(&hover), true)] {
1240            assert!(match_pseudo_selector(
1241                &CssPathPseudoSelector::First,
1242                info(0, false),
1243                expected,
1244                is_last
1245            ));
1246            assert!(!match_pseudo_selector(
1247                &CssPathPseudoSelector::First,
1248                info(1, false),
1249                expected,
1250                is_last
1251            ));
1252            assert!(match_pseudo_selector(
1253                &CssPathPseudoSelector::Last,
1254                info(9, true),
1255                expected,
1256                is_last
1257            ));
1258            assert!(match_pseudo_selector(
1259                &CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(10)),
1260                info(9, true),
1261                expected,
1262                is_last
1263            ));
1264        }
1265    }
1266
1267    #[test]
1268    fn match_pseudo_selector_lang_only_matches_the_expected_lang() {
1269        let de = CssPathPseudoSelector::Lang("de".into());
1270        let en = CssPathPseudoSelector::Lang("en".into());
1271
1272        assert!(match_pseudo_selector(&de, info(0, false), Some(&de), true));
1273        assert!(!match_pseudo_selector(&de, info(0, false), Some(&en), true));
1274        assert!(!match_pseudo_selector(&de, info(0, false), None, true));
1275        assert!(!match_pseudo_selector(
1276            &de,
1277            info(0, false),
1278            Some(&CssPathPseudoSelector::Hover),
1279            true
1280        ));
1281
1282        // Unicode + empty language tags must not panic and must compare by value.
1283        let emoji = CssPathPseudoSelector::Lang("de-🎉".into());
1284        assert!(match_pseudo_selector(&emoji, info(0, false), Some(&emoji), true));
1285        assert!(!match_pseudo_selector(&emoji, info(0, false), Some(&de), true));
1286        let empty = CssPathPseudoSelector::Lang("".into());
1287        assert!(match_pseudo_selector(&empty, info(0, false), Some(&empty), true));
1288    }
1289
1290    // ---------------------------------------------------------------------
1291    // match_attribute_selector
1292    // ---------------------------------------------------------------------
1293
1294    #[test]
1295    fn match_attribute_selector_on_a_node_without_attributes_is_always_false() {
1296        let nd = NodeData::create_div();
1297        for op in [
1298            AttributeMatchOp::Exists,
1299            AttributeMatchOp::Eq,
1300            AttributeMatchOp::Includes,
1301            AttributeMatchOp::DashMatch,
1302            AttributeMatchOp::Prefix,
1303            AttributeMatchOp::Suffix,
1304            AttributeMatchOp::Substring,
1305        ] {
1306            assert!(!match_attribute_selector(&attr_sel("data-x", op, Some("v")), &nd));
1307            assert!(!match_attribute_selector(&attr_sel("", op, None), &nd));
1308        }
1309    }
1310
1311    #[test]
1312    fn match_attribute_selector_exists_matches_any_value_including_the_empty_one() {
1313        let nd = node_with_attrs(vec![custom("data-x", "")]);
1314        assert!(match_attribute_selector(
1315            &attr_sel("data-x", AttributeMatchOp::Exists, None),
1316            &nd
1317        ));
1318        // Exists ignores the value entirely, even if one is (wrongly) supplied.
1319        assert!(match_attribute_selector(
1320            &attr_sel("data-x", AttributeMatchOp::Exists, Some("nonsense")),
1321            &nd
1322        ));
1323        assert!(!match_attribute_selector(
1324            &attr_sel("data-y", AttributeMatchOp::Exists, None),
1325            &nd
1326        ));
1327    }
1328
1329    #[test]
1330    fn match_attribute_selector_operator_without_a_value_never_matches() {
1331        let nd = node_with_attrs(vec![custom("data-x", "value")]);
1332        for op in [
1333            AttributeMatchOp::Eq,
1334            AttributeMatchOp::Includes,
1335            AttributeMatchOp::DashMatch,
1336            AttributeMatchOp::Prefix,
1337            AttributeMatchOp::Suffix,
1338            AttributeMatchOp::Substring,
1339        ] {
1340            assert!(
1341                !match_attribute_selector(&attr_sel("data-x", op, None), &nd),
1342                "{op:?} with a missing target value must be rejected, not matched"
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn match_attribute_selector_empty_target_never_matches_the_substring_family() {
1349        // `[x^=""]` / `[x$=""]` / `[x*=""]` / `[x~=""]` would otherwise match
1350        // every node (every string starts with / contains the empty string).
1351        let nd = node_with_attrs(vec![custom("data-x", "abc")]);
1352        for op in [
1353            AttributeMatchOp::Prefix,
1354            AttributeMatchOp::Suffix,
1355            AttributeMatchOp::Substring,
1356            AttributeMatchOp::Includes,
1357        ] {
1358            assert!(
1359                !match_attribute_selector(&attr_sel("data-x", op, Some("")), &nd),
1360                "{op:?} with an empty target must not match"
1361            );
1362        }
1363        // Eq is the exception: `[x=""]` legitimately means "the empty value".
1364        assert!(!match_attribute_selector(
1365            &attr_sel("data-x", AttributeMatchOp::Eq, Some("")),
1366            &nd
1367        ));
1368        let empty = node_with_attrs(vec![custom("data-x", "")]);
1369        assert!(match_attribute_selector(
1370            &attr_sel("data-x", AttributeMatchOp::Eq, Some("")),
1371            &empty
1372        ));
1373    }
1374
1375    #[test]
1376    fn match_attribute_selector_includes_matches_one_of_several_class_entries() {
1377        // Classes are stored as separate `AttributeType::Class` entries, so the
1378        // matcher has to be "any value satisfies the op" (see the fn doc).
1379        let mut nd = NodeData::create_div();
1380        nd.add_class("foo".into());
1381        nd.add_class("primary".into());
1382        nd.add_class("bar".into());
1383
1384        assert!(match_attribute_selector(
1385            &attr_sel("class", AttributeMatchOp::Includes, Some("primary")),
1386            &nd
1387        ));
1388        assert!(!match_attribute_selector(
1389            &attr_sel("class", AttributeMatchOp::Includes, Some("prim")),
1390            &nd
1391        ));
1392        // A target containing whitespace is invalid for `~=` and must not match.
1393        assert!(!match_attribute_selector(
1394            &attr_sel("class", AttributeMatchOp::Includes, Some("foo bar")),
1395            &nd
1396        ));
1397        assert!(!match_attribute_selector(
1398            &attr_sel("class", AttributeMatchOp::Includes, Some("\t")),
1399            &nd
1400        ));
1401    }
1402
1403    #[test]
1404    fn match_attribute_selector_dash_match_requires_a_dash_boundary() {
1405        let nd = node_with_attrs(vec![AttributeType::Lang("en-US".into())]);
1406
1407        assert!(match_attribute_selector(
1408            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en")),
1409            &nd
1410        ));
1411        assert!(match_attribute_selector(
1412            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en-US")),
1413            &nd
1414        ));
1415        // A prefix that does not end on the `-` boundary must not match.
1416        assert!(!match_attribute_selector(
1417            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en-U")),
1418            &nd
1419        ));
1420        assert!(!match_attribute_selector(
1421            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("e")),
1422            &nd
1423        ));
1424
1425        // `en` must not match the value `english` (no dash boundary).
1426        let english = node_with_attrs(vec![AttributeType::Lang("english".into())]);
1427        assert!(!match_attribute_selector(
1428            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en")),
1429            &english
1430        ));
1431    }
1432
1433    #[test]
1434    fn match_attribute_selector_name_matching_is_exact_and_case_sensitive() {
1435        let nd = node_with_attrs(vec![custom("data-foo", "v")]);
1436        assert!(match_attribute_selector(
1437            &attr_sel("data-foo", AttributeMatchOp::Eq, Some("v")),
1438            &nd
1439        ));
1440        assert!(!match_attribute_selector(
1441            &attr_sel("data-fo", AttributeMatchOp::Eq, Some("v")),
1442            &nd
1443        ));
1444        assert!(!match_attribute_selector(
1445            &attr_sel("data-foo2", AttributeMatchOp::Eq, Some("v")),
1446            &nd
1447        ));
1448        assert!(!match_attribute_selector(
1449            &attr_sel("DATA-FOO", AttributeMatchOp::Eq, Some("v")),
1450            &nd
1451        ));
1452        assert!(!match_attribute_selector(
1453            &attr_sel("", AttributeMatchOp::Exists, None),
1454            &nd
1455        ));
1456    }
1457
1458    #[test]
1459    fn match_attribute_selector_handles_unicode_values_on_char_boundaries() {
1460        // "héllo-🎉-世界" has no ASCII 'e' and no ASCII-splittable emoji.
1461        let nd = node_with_attrs(vec![custom("data-x", "héllo-🎉-世界")]);
1462
1463        assert!(match_attribute_selector(
1464            &attr_sel("data-x", AttributeMatchOp::Prefix, Some("hé")),
1465            &nd
1466        ));
1467        assert!(match_attribute_selector(
1468            &attr_sel("data-x", AttributeMatchOp::Suffix, Some("世界")),
1469            &nd
1470        ));
1471        assert!(match_attribute_selector(
1472            &attr_sel("data-x", AttributeMatchOp::Substring, Some("🎉")),
1473            &nd
1474        ));
1475        assert!(match_attribute_selector(
1476            &attr_sel("data-x", AttributeMatchOp::Eq, Some("héllo-🎉-世界")),
1477            &nd
1478        ));
1479        // No false positive from the ASCII byte inside a multi-byte codepoint.
1480        assert!(!match_attribute_selector(
1481            &attr_sel("data-x", AttributeMatchOp::Substring, Some("e")),
1482            &nd
1483        ));
1484        // DashMatch on a unicode segment.
1485        assert!(match_attribute_selector(
1486            &attr_sel("data-x", AttributeMatchOp::DashMatch, Some("héllo")),
1487            &nd
1488        ));
1489    }
1490
1491    #[test]
1492    fn match_attribute_selector_handles_huge_values() {
1493        let huge = "ä".repeat(100_000);
1494        let value = alloc::format!("{huge}-tail");
1495        let nd = node_with_attrs(vec![custom("data-big", &value)]);
1496
1497        assert!(match_attribute_selector(
1498            &attr_sel("data-big", AttributeMatchOp::Suffix, Some("-tail")),
1499            &nd
1500        ));
1501        assert!(match_attribute_selector(
1502            &attr_sel("data-big", AttributeMatchOp::Prefix, Some("ää")),
1503            &nd
1504        ));
1505        assert!(match_attribute_selector(
1506            &attr_sel("data-big", AttributeMatchOp::DashMatch, Some(huge.as_str())),
1507            &nd
1508        ));
1509        assert!(!match_attribute_selector(
1510            &attr_sel("data-big", AttributeMatchOp::Eq, Some(huge.as_str())),
1511            &nd
1512        ));
1513    }
1514
1515    // ---------------------------------------------------------------------
1516    // match_single_selector / selector_group_matches
1517    // ---------------------------------------------------------------------
1518
1519    #[test]
1520    fn match_single_selector_global_matches_every_node_type() {
1521        let div = NodeData::create_div();
1522        let text = NodeData::create_text("hello");
1523        assert!(match_single_selector(
1524            &CssPathSelector::Global,
1525            info(0, false),
1526            &div,
1527            NodeId::new(0),
1528            None,
1529            true
1530        ));
1531        assert!(match_single_selector(
1532            &CssPathSelector::Global,
1533            info(u32::MAX, true),
1534            &text,
1535            NodeId::new(usize::MAX),
1536            None,
1537            false
1538        ));
1539    }
1540
1541    #[test]
1542    fn match_single_selector_never_matches_a_combinator() {
1543        // Combinators must be split out by the group iterator; if one ever
1544        // reaches the matcher it must fail closed, not match everything.
1545        let div = NodeData::create_div();
1546        for c in [
1547            CssPathSelector::DirectChildren,
1548            CssPathSelector::Children,
1549            CssPathSelector::AdjacentSibling,
1550            CssPathSelector::GeneralSibling,
1551        ] {
1552            assert!(!match_single_selector(
1553                &c,
1554                info(0, true),
1555                &div,
1556                NodeId::new(0),
1557                None,
1558                true
1559            ));
1560        }
1561    }
1562
1563    #[test]
1564    fn match_single_selector_root_scope_range_is_inclusive_on_both_ends() {
1565        let div = NodeData::create_div();
1566        let sel = CssPathSelector::Root(CssScopeRange { start: 2, end: 4 });
1567
1568        for id in [2usize, 3, 4] {
1569            assert!(match_single_selector(
1570                &sel,
1571                info(0, false),
1572                &div,
1573                NodeId::new(id),
1574                None,
1575                true
1576            ));
1577        }
1578        for id in [0usize, 1, 5, usize::MAX] {
1579            assert!(!match_single_selector(
1580                &sel,
1581                info(0, false),
1582                &div,
1583                NodeId::new(id),
1584                None,
1585                true
1586            ));
1587        }
1588    }
1589
1590    #[test]
1591    fn match_single_selector_root_scope_with_an_inverted_or_full_range() {
1592        let div = NodeData::create_div();
1593
1594        // Inverted range (start > end) matches nothing, and must not panic.
1595        let inverted = CssPathSelector::Root(CssScopeRange { start: 9, end: 2 });
1596        for id in [0usize, 2, 9, usize::MAX] {
1597            assert!(!match_single_selector(
1598                &inverted,
1599                info(0, false),
1600                &div,
1601                NodeId::new(id),
1602                None,
1603                true
1604            ));
1605        }
1606
1607        // Full range matches every node id, including usize::MAX.
1608        let full = CssPathSelector::Root(CssScopeRange {
1609            start: 0,
1610            end: usize::MAX,
1611        });
1612        for id in [0usize, 1, usize::MAX] {
1613            assert!(match_single_selector(
1614                &full,
1615                info(0, false),
1616                &div,
1617                NodeId::new(id),
1618                None,
1619                true
1620            ));
1621        }
1622    }
1623
1624    #[test]
1625    fn match_single_selector_type_class_and_id() {
1626        let mut nd = div_with(Some("first"), Some("a"));
1627        nd.add_class("日本語-🎉".into());
1628
1629        let hit = |s: &CssPathSelector| {
1630            match_single_selector(s, info(0, false), &nd, NodeId::new(0), None, true)
1631        };
1632
1633        assert!(hit(&CssPathSelector::Type(NodeTypeTag::Div)));
1634        assert!(!hit(&CssPathSelector::Type(NodeTypeTag::P)));
1635        assert!(hit(&CssPathSelector::Class("a".into())));
1636        assert!(hit(&CssPathSelector::Class("日本語-🎉".into())));
1637        assert!(!hit(&CssPathSelector::Class("日本語".into())), "no prefix matching");
1638        assert!(!hit(&CssPathSelector::Class("".into())));
1639        assert!(hit(&CssPathSelector::Id("first".into())));
1640        assert!(!hit(&CssPathSelector::Id("firs".into())));
1641        // Ids and classes must not cross over.
1642        assert!(!hit(&CssPathSelector::Class("first".into())));
1643        assert!(!hit(&CssPathSelector::Id("a".into())));
1644    }
1645
1646    #[test]
1647    fn selector_group_matches_requires_every_selector_in_the_group() {
1648        let nd = div_with(Some("first"), Some("a"));
1649        let div = CssPathSelector::Type(NodeTypeTag::Div);
1650        let class_a = CssPathSelector::Class("a".into());
1651        let class_z = CssPathSelector::Class("zzz".into());
1652
1653        let group: Vec<&CssPathSelector> = vec![&div, &class_a];
1654        assert!(selector_group_matches(
1655            &group,
1656            info(0, false),
1657            &nd,
1658            NodeId::new(0),
1659            None,
1660            true
1661        ));
1662
1663        let group: Vec<&CssPathSelector> = vec![&div, &class_a, &class_z];
1664        assert!(!selector_group_matches(
1665            &group,
1666            info(0, false),
1667            &nd,
1668            NodeId::new(0),
1669            None,
1670            true
1671        ));
1672    }
1673
1674    #[test]
1675    fn selector_group_matches_empty_group_matches_vacuously() {
1676        // This is what a dangling combinator (`.foo >`) produces — see
1677        // `css_group_iterator_yields_an_empty_group_for_a_trailing_combinator`.
1678        // `all()` over an empty group is `true`, so such a group matches ANY node.
1679        let empty: Vec<&CssPathSelector> = Vec::new();
1680        assert!(selector_group_matches(
1681            &empty,
1682            info(0, false),
1683            &NodeData::create_div(),
1684            NodeId::new(0),
1685            None,
1686            true
1687        ));
1688    }
1689
1690    // ---------------------------------------------------------------------
1691    // find_non_anonymous_parent / find_non_anonymous_prev_sibling
1692    // ---------------------------------------------------------------------
1693
1694    /// ```text
1695    /// 0 body
1696    /// ├── 1 <anonymous>
1697    /// │   └── 2 <anonymous>
1698    /// │       └── 3 div        <- parent chain must skip 1 and 2
1699    /// ├── 4 <anonymous>
1700    /// └── 5 div                <- prev-sibling chain must skip 4 and 1
1701    /// ```
1702    fn anonymous_fixture() -> (Vec<Node>, Vec<NodeData>) {
1703        let hierarchy = vec![
1704            node(None, None, None, Some(5)),
1705            node(Some(0), None, Some(4), Some(2)),
1706            node(Some(1), None, None, Some(3)),
1707            node(Some(2), None, None, None),
1708            node(Some(0), Some(1), Some(5), None),
1709            node(Some(0), Some(4), None, None),
1710        ];
1711        let mut anon1 = NodeData::create_div();
1712        anon1.set_anonymous(true);
1713        let mut anon2 = NodeData::create_div();
1714        anon2.set_anonymous(true);
1715        let mut anon4 = NodeData::create_div();
1716        anon4.set_anonymous(true);
1717        let data = vec![
1718            NodeData::create_body(),
1719            anon1,
1720            anon2,
1721            div_with(None, Some("deep")),
1722            anon4,
1723            div_with(None, Some("c")),
1724        ];
1725        (hierarchy, data)
1726    }
1727
1728    #[test]
1729    fn find_non_anonymous_parent_skips_a_chain_of_anonymous_boxes() {
1730        let (hierarchy, data) = anonymous_fixture();
1731        let hier_items = items(&hierarchy);
1732        let h = NodeDataContainerRef::from_slice(&hier_items);
1733        let d = NodeDataContainerRef::from_slice(&data);
1734
1735        // node 3's real parent is the body (0), not the anonymous 2 / 1.
1736        assert_eq!(
1737            find_non_anonymous_parent(NodeId::new(3), &h, &d),
1738            Some(NodeId::new(0))
1739        );
1740        // node 5's parent is the body directly.
1741        assert_eq!(
1742            find_non_anonymous_parent(NodeId::new(5), &h, &d),
1743            Some(NodeId::new(0))
1744        );
1745        // the root has no parent at all.
1746        assert_eq!(find_non_anonymous_parent(NodeId::new(0), &h, &d), None);
1747    }
1748
1749    #[test]
1750    fn find_non_anonymous_parent_returns_none_when_every_ancestor_is_anonymous() {
1751        // 0 <anonymous root> -> 1 div
1752        let hierarchy = vec![node(None, None, None, Some(1)), node(Some(0), None, None, None)];
1753        let mut anon_root = NodeData::create_div();
1754        anon_root.set_anonymous(true);
1755        let data = vec![anon_root, NodeData::create_div()];
1756        let hier_items = items(&hierarchy);
1757        let h = NodeDataContainerRef::from_slice(&hier_items);
1758        let d = NodeDataContainerRef::from_slice(&data);
1759
1760        assert_eq!(find_non_anonymous_parent(NodeId::new(1), &h, &d), None);
1761    }
1762
1763    #[test]
1764    fn find_non_anonymous_prev_sibling_skips_anonymous_siblings() {
1765        let (hierarchy, data) = anonymous_fixture();
1766        let hier_items = items(&hierarchy);
1767        let h = NodeDataContainerRef::from_slice(&hier_items);
1768        let d = NodeDataContainerRef::from_slice(&data);
1769
1770        // node 5's previous siblings are 4 (anonymous) and 1 (anonymous), so
1771        // there is no non-anonymous previous sibling.
1772        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(5), &h, &d), None);
1773        // a first child has no previous sibling.
1774        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(1), &h, &d), None);
1775        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(0), &h, &d), None);
1776    }
1777
1778    #[test]
1779    fn find_non_anonymous_prev_sibling_returns_the_nearest_real_sibling() {
1780        let hierarchy = sample_hierarchy();
1781        let data = sample_node_data();
1782        let hier_items = items(&hierarchy);
1783        let h = NodeDataContainerRef::from_slice(&hier_items);
1784        let d = NodeDataContainerRef::from_slice(&data);
1785
1786        // NOTE: text nodes are NOT anonymous, so the text node (2) IS returned
1787        // as the previous sibling of the div (3) — see
1788        // `matches_html_element_adjacent_sibling_skips_text_nodes`.
1789        assert_eq!(
1790            find_non_anonymous_prev_sibling(NodeId::new(3), &h, &d),
1791            Some(NodeId::new(2))
1792        );
1793        assert_eq!(
1794            find_non_anonymous_prev_sibling(NodeId::new(5), &h, &d),
1795            Some(NodeId::new(3))
1796        );
1797    }
1798
1799    // ---------------------------------------------------------------------
1800    // construct_html_cascade_tree
1801    // ---------------------------------------------------------------------
1802
1803    #[test]
1804    fn construct_html_cascade_tree_on_an_empty_hierarchy_is_empty() {
1805        let hierarchy: Vec<Node> = Vec::new();
1806        let data: Vec<NodeData> = Vec::new();
1807        let out = construct_html_cascade_tree(
1808            &NodeHierarchyRef::from_slice(&hierarchy),
1809            &[],
1810            &NodeDataContainerRef::from_slice(&data),
1811        );
1812        assert_eq!(out.len(), 0);
1813        assert!(out.is_empty());
1814    }
1815
1816    #[test]
1817    fn construct_html_cascade_tree_with_no_parents_defaults_every_node() {
1818        // A single childless root is a LEAF, so `get_parents_sorted_by_depth`
1819        // returns nothing and every entry keeps the zeroed default.
1820        let hierarchy = vec![node(None, None, None, None)];
1821        let data = vec![NodeData::create_body()];
1822        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1823        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1824        assert!(depths.is_empty());
1825
1826        let out = construct_html_cascade_tree(
1827            &hierarchy_ref,
1828            &depths,
1829            &NodeDataContainerRef::from_slice(&data),
1830        );
1831        assert_eq!(out.len(), 1);
1832        assert_eq!(out.internal[0], CascadeInfo::default());
1833    }
1834
1835    #[test]
1836    fn construct_html_cascade_tree_does_not_count_text_nodes_as_element_siblings() {
1837        let hierarchy = sample_hierarchy();
1838        let data = sample_node_data();
1839        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1840        let data_ref = NodeDataContainerRef::from_slice(&data);
1841        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1842
1843        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
1844
1845        assert_eq!(out.len(), hierarchy.len(), "one CascadeInfo per node");
1846        assert_eq!(out.internal[0], info(0, true), "root");
1847        assert_eq!(out.internal[1], info(0, false), "div#first.a — 1st element child");
1848        assert_eq!(out.internal[3], info(1, false), "div.b — 2nd element child (text skipped)");
1849        assert_eq!(out.internal[4], info(0, true), "p.inner — only child of div.b");
1850        assert_eq!(
1851            out.internal[5],
1852            info(2, true),
1853            "div.c — 3rd element child and the last one"
1854        );
1855        // The text node itself is never the last child and is not an element.
1856        assert!(!out.internal[2].is_last_child);
1857    }
1858
1859    #[test]
1860    fn construct_html_cascade_tree_ignores_trailing_text_nodes_for_is_last_child() {
1861        // 0 body -> [1 div, 2 "text", 3 "text"]  => div is still the LAST element child.
1862        let hierarchy = vec![
1863            node(None, None, None, Some(3)),
1864            node(Some(0), None, Some(2), None),
1865            node(Some(0), Some(1), Some(3), None),
1866            node(Some(0), Some(2), None, None),
1867        ];
1868        let data = vec![
1869            NodeData::create_body(),
1870            NodeData::create_div(),
1871            NodeData::create_text("a"),
1872            NodeData::create_text("b"),
1873        ];
1874        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1875        let data_ref = NodeDataContainerRef::from_slice(&data);
1876        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1877
1878        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
1879        assert_eq!(out.internal[1], info(0, true), "trailing text must not un-last the div");
1880    }
1881
1882    #[test]
1883    fn construct_html_cascade_tree_handles_a_wide_tree() {
1884        const CHILDREN: usize = 2_000;
1885
1886        let mut hierarchy = vec![node(None, None, None, Some(CHILDREN))];
1887        let mut data = vec![NodeData::create_body()];
1888        for i in 1..=CHILDREN {
1889            hierarchy.push(node(
1890                Some(0),
1891                if i == 1 { None } else { Some(i - 1) },
1892                if i == CHILDREN { None } else { Some(i + 1) },
1893                None,
1894            ));
1895            data.push(NodeData::create_div());
1896        }
1897
1898        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1899        let data_ref = NodeDataContainerRef::from_slice(&data);
1900        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1901        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
1902
1903        assert_eq!(out.len(), CHILDREN + 1);
1904        for i in 1..=CHILDREN {
1905            let expected = info(u32::try_from(i - 1).unwrap(), i == CHILDREN);
1906            assert_eq!(out.internal[i], expected, "child {i}");
1907        }
1908    }
1909
1910    #[test]
1911    fn construct_html_cascade_tree_handles_a_deep_chain() {
1912        const DEPTH: usize = 1_000;
1913
1914        let mut hierarchy = Vec::with_capacity(DEPTH);
1915        let mut data = Vec::with_capacity(DEPTH);
1916        for i in 0..DEPTH {
1917            hierarchy.push(node(
1918                if i == 0 { None } else { Some(i - 1) },
1919                None,
1920                None,
1921                if i + 1 == DEPTH { None } else { Some(i + 1) },
1922            ));
1923            data.push(NodeData::create_div());
1924        }
1925
1926        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1927        let data_ref = NodeDataContainerRef::from_slice(&data);
1928        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1929        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
1930
1931        assert_eq!(out.len(), DEPTH);
1932        for i in 0..DEPTH {
1933            assert_eq!(
1934                out.internal[i],
1935                info(0, true),
1936                "every node in a chain is an only child"
1937            );
1938        }
1939    }
1940
1941    // ---------------------------------------------------------------------
1942    // matches_html_element
1943    // ---------------------------------------------------------------------
1944
1945    #[test]
1946    fn matches_html_element_empty_path_never_matches() {
1947        assert!(!matches(Vec::new(), 1, None));
1948        assert!(!matches(Vec::new(), 0, Some(CssPathPseudoSelector::Hover)));
1949    }
1950
1951    #[test]
1952    fn matches_html_element_matches_type_class_and_id_on_the_subject() {
1953        assert!(matches(vec![CssPathSelector::Global], 1, None));
1954        assert!(matches(vec![CssPathSelector::Class("a".into())], 1, None));
1955        assert!(!matches(vec![CssPathSelector::Class("a".into())], 3, None));
1956        assert!(matches(vec![CssPathSelector::Id("first".into())], 1, None));
1957        assert!(matches(vec![CssPathSelector::Type(NodeTypeTag::Div)], 1, None));
1958        assert!(!matches(vec![CssPathSelector::Type(NodeTypeTag::Div)], 0, None));
1959        assert!(matches(vec![CssPathSelector::Type(NodeTypeTag::Body)], 0, None));
1960
1961        // Compound group: `div.a` matches node 1 but not node 3 (`div.b`).
1962        let div_a = vec![
1963            CssPathSelector::Type(NodeTypeTag::Div),
1964            CssPathSelector::Class("a".into()),
1965        ];
1966        assert!(matches(div_a.clone(), 1, None));
1967        assert!(!matches(div_a, 3, None));
1968    }
1969
1970    #[test]
1971    fn matches_html_element_never_matches_an_anonymous_node() {
1972        let (hierarchy, data) = anonymous_fixture();
1973        let hier_items = items(&hierarchy);
1974        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
1975        let data_ref = NodeDataContainerRef::from_slice(&data);
1976        let depths = hierarchy_ref.get_parents_sorted_by_depth();
1977        let cascade = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
1978
1979        // `*` matches everything EXCEPT the anonymous boxes (1, 2, 4).
1980        for id in [1usize, 2, 4] {
1981            assert!(
1982                !matches_html_element(
1983                    &CssPath::new(vec![CssPathSelector::Global]),
1984                    NodeId::new(id),
1985                    &NodeDataContainerRef::from_slice(&hier_items),
1986                    &data_ref,
1987                    &cascade.as_ref(),
1988                    None,
1989                ),
1990                "anonymous node {id} must not be styled"
1991            );
1992        }
1993        for id in [0usize, 3, 5] {
1994            assert!(matches_html_element(
1995                &CssPath::new(vec![CssPathSelector::Global]),
1996                NodeId::new(id),
1997                &NodeDataContainerRef::from_slice(&hier_items),
1998                &data_ref,
1999                &cascade.as_ref(),
2000                None,
2001            ));
2002        }
2003    }
2004
2005    #[test]
2006    fn matches_html_element_child_combinator_is_stricter_than_the_descendant_one() {
2007        // `body > p` must NOT match p.inner (its parent is div.b) ...
2008        let child = vec![
2009            CssPathSelector::Type(NodeTypeTag::Body),
2010            CssPathSelector::DirectChildren,
2011            CssPathSelector::Type(NodeTypeTag::P),
2012        ];
2013        assert!(!matches(child, 4, None));
2014
2015        // ... but `body p` must.
2016        let descendant = vec![
2017            CssPathSelector::Type(NodeTypeTag::Body),
2018            CssPathSelector::Children,
2019            CssPathSelector::Type(NodeTypeTag::P),
2020        ];
2021        assert!(matches(descendant, 4, None));
2022
2023        // `body > div.b` is a direct child.
2024        let direct = vec![
2025            CssPathSelector::Type(NodeTypeTag::Body),
2026            CssPathSelector::DirectChildren,
2027            CssPathSelector::Type(NodeTypeTag::Div),
2028            CssPathSelector::Class("b".into()),
2029        ];
2030        assert!(matches(direct, 3, None));
2031    }
2032
2033    #[test]
2034    fn matches_html_element_descendant_combinator_walks_the_whole_ancestor_chain() {
2035        // `div.b p.inner` (direct) and `body p.inner` (two levels up).
2036        let close = vec![
2037            CssPathSelector::Class("b".into()),
2038            CssPathSelector::Children,
2039            CssPathSelector::Class("inner".into()),
2040        ];
2041        assert!(matches(close, 4, None));
2042
2043        // A non-ancestor class must not match, even though it exists in the DOM.
2044        let unrelated = vec![
2045            CssPathSelector::Class("c".into()),
2046            CssPathSelector::Children,
2047            CssPathSelector::Class("inner".into()),
2048        ];
2049        assert!(!matches(unrelated, 4, None));
2050    }
2051
2052    #[test]
2053    fn matches_html_element_general_sibling_scans_all_previous_siblings() {
2054        // `div.a ~ div.c`: div.c (5) is preceded by div.b (3) and a text node (2),
2055        // and the scan must keep walking until it reaches div.a (1).
2056        let general = vec![
2057            CssPathSelector::Class("a".into()),
2058            CssPathSelector::GeneralSibling,
2059            CssPathSelector::Class("c".into()),
2060        ];
2061        assert!(matches(general, 5, None));
2062
2063        // The subject must come AFTER the sibling: `div.c ~ div.a` must not match.
2064        let backwards = vec![
2065            CssPathSelector::Class("c".into()),
2066            CssPathSelector::GeneralSibling,
2067            CssPathSelector::Class("a".into()),
2068        ];
2069        assert!(!matches(backwards, 1, None));
2070    }
2071
2072    #[test]
2073    fn matches_html_element_adjacent_sibling_matches_the_immediate_element_sibling() {
2074        // `div.b + div.c`: node 5's immediately preceding sibling IS div.b.
2075        let adjacent = vec![
2076            CssPathSelector::Class("b".into()),
2077            CssPathSelector::AdjacentSibling,
2078            CssPathSelector::Class("c".into()),
2079        ];
2080        assert!(matches(adjacent, 5, None));
2081
2082        // `div.a + div.c` must not match (div.b sits between them).
2083        let not_adjacent = vec![
2084            CssPathSelector::Class("a".into()),
2085            CssPathSelector::AdjacentSibling,
2086            CssPathSelector::Class("c".into()),
2087        ];
2088        assert!(!matches(not_adjacent, 5, None));
2089    }
2090
2091    /// EXPECTED-RED (genuine bug, see report): `find_non_anonymous_prev_sibling`
2092    /// only skips *anonymous* nodes, not *non-element* (text) nodes. CSS
2093    /// Selectors L4 §15.2 defines `E + F` over element siblings only — and
2094    /// `construct_html_cascade_tree` already excludes text nodes from sibling
2095    /// indexing (L4 §13) — so `div.a + div.b` must still match across the
2096    /// intervening text node. Today it returns `false`.
2097    #[test]
2098    fn matches_html_element_adjacent_sibling_skips_text_nodes() {
2099        // `div.a + div.b`, with the text node 2 sitting between them.
2100        let adjacent = vec![
2101            CssPathSelector::Class("a".into()),
2102            CssPathSelector::AdjacentSibling,
2103            CssPathSelector::Class("b".into()),
2104        ];
2105        assert!(
2106            matches(adjacent, 3, None),
2107            "the `+` combinator must ignore non-element (text) siblings"
2108        );
2109    }
2110
2111    #[test]
2112    fn matches_html_element_hover_on_a_single_group_path_needs_the_expected_ending() {
2113        let hover = vec![
2114            CssPathSelector::Class("a".into()),
2115            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
2116        ];
2117        assert!(matches(hover.clone(), 1, Some(CssPathPseudoSelector::Hover)));
2118        // Without the expected pseudo state the rule must not apply...
2119        assert!(!matches(hover.clone(), 1, None));
2120        // ... and neither must a different state.
2121        assert!(!matches(hover.clone(), 1, Some(CssPathPseudoSelector::Focus)));
2122        // ... and it still has to match the rest of the selector.
2123        assert!(!matches(hover, 3, Some(CssPathPseudoSelector::Hover)));
2124    }
2125
2126    /// EXPECTED-RED (genuine bug, see report): `matches_html_element` passes
2127    /// `is_last_content_group = groups.len() == 1` for the SUBJECT group (the
2128    /// rightmost one, which the iterator yields first), so an interactive pseudo
2129    /// on the subject of any multi-group selector — `.container .btn:hover`,
2130    /// `body > .btn:hover`, … — can never match. `prop_cache` reaches
2131    /// `matches_html_element` with exactly this shape (`rule_ends_with(path,
2132    /// Some(Hover))` → `matches_html_element(..., Some(Hover))`), so every
2133    /// descendant/child `:hover` / `:active` / `:focus` rule is silently dropped.
2134    #[test]
2135    fn matches_html_element_hover_on_a_descendant_path_still_matches() {
2136        // `body .a:hover` — the hover applies to the SUBJECT (node 1).
2137        let hover_descendant = vec![
2138            CssPathSelector::Type(NodeTypeTag::Body),
2139            CssPathSelector::Children,
2140            CssPathSelector::Class("a".into()),
2141            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
2142        ];
2143        assert!(
2144            matches(hover_descendant, 1, Some(CssPathPseudoSelector::Hover)),
2145            ":hover on the subject of a multi-group selector must still match"
2146        );
2147    }
2148
2149    #[test]
2150    fn matches_html_element_structural_pseudos_work_on_the_subject() {
2151        // div#first.a is the first element child; div.c is the last one.
2152        let first = vec![
2153            CssPathSelector::Class("a".into()),
2154            CssPathSelector::PseudoSelector(CssPathPseudoSelector::First),
2155        ];
2156        assert!(matches(first, 1, None));
2157
2158        let last = vec![
2159            CssPathSelector::Class("c".into()),
2160            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Last),
2161        ];
2162        assert!(matches(last, 5, None));
2163
2164        // div.b is the 2nd element child — the text node must not shift the index.
2165        let nth = vec![
2166            CssPathSelector::Class("b".into()),
2167            CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
2168                CssNthChildSelector::Number(2),
2169            )),
2170        ];
2171        assert!(matches(nth, 3, None));
2172
2173        let wrong_nth = vec![
2174            CssPathSelector::Class("b".into()),
2175            CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
2176                CssNthChildSelector::Number(3),
2177            )),
2178        ];
2179        assert!(!matches(wrong_nth, 3, None));
2180    }
2181
2182    #[test]
2183    fn matches_html_element_root_scope_confines_a_rule_to_its_subtree() {
2184        // `[Root(3..=4), *]` — the #47 scope marker: only div.b and its child.
2185        let scoped = vec![
2186            CssPathSelector::Root(CssScopeRange { start: 3, end: 4 }),
2187            CssPathSelector::Global,
2188        ];
2189        assert!(matches(scoped.clone(), 3, None));
2190        assert!(matches(scoped.clone(), 4, None));
2191        assert!(!matches(scoped.clone(), 1, None), "must not leak to a sibling");
2192        assert!(!matches(scoped.clone(), 5, None), "must not leak to a sibling");
2193        assert!(!matches(scoped, 0, None), "must not leak to the parent");
2194
2195        // Node-only scope (`[start, start]`) = inline-style semantics.
2196        let node_only = vec![
2197            CssPathSelector::Root(CssScopeRange { start: 3, end: 3 }),
2198            CssPathSelector::Global,
2199        ];
2200        assert!(matches(node_only.clone(), 3, None));
2201        assert!(!matches(node_only, 4, None), "a node-only scope must not reach children");
2202    }
2203
2204    #[test]
2205    fn matches_html_element_with_a_dangling_combinator_does_not_panic() {
2206        // `.a >` — the iterator yields an empty subject group, which matches
2207        // vacuously, and then requires an `.a` parent. Node 4's parent is div.b,
2208        // so this must be false; no panic either way.
2209        let dangling = vec![
2210            CssPathSelector::Class("a".into()),
2211            CssPathSelector::DirectChildren,
2212        ];
2213        assert!(!matches(dangling.clone(), 4, None));
2214        // ... but div.a IS the parent of nothing, so no node matches it.
2215        for id in 0..6 {
2216            let _ = matches(dangling.clone(), id, None);
2217        }
2218    }
2219
2220    #[test]
2221    fn matches_html_element_on_a_very_long_selector_chain_terminates() {
2222        // 500 `body ...` descendant groups: the ancestor scan must fail fast
2223        // (there are only 3 levels in the DOM) instead of looping.
2224        let mut path = Vec::new();
2225        for _ in 0..500 {
2226            path.push(CssPathSelector::Type(NodeTypeTag::Body));
2227            path.push(CssPathSelector::Children);
2228        }
2229        path.push(CssPathSelector::Class("inner".into()));
2230        assert!(!matches(path, 4, None));
2231    }
2232}