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