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

            
7
use alloc::vec::Vec;
8

            
9
use azul_css::css::{
10
    AttributeMatchOp, CssAttributeSelector, CssContentGroup, CssNthChildSelector,
11
    CssNthChildSelector::{Number, Even, Odd, Pattern}, CssPath, CssPathPseudoSelector, CssPathSelector,
12
};
13

            
14
use 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)]
23
pub struct CascadeInfo {
24
    pub index_in_parent: u32,
25
    pub is_last_child: bool,
26
}
27

            
28
impl_option!(
29
    CascadeInfo,
30
    OptionCascadeInfo,
31
    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
32
);
33

            
34
impl_vec!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor, CascadeInfoVecDestructorType, CascadeInfoVecSlice, OptionCascadeInfo);
35
impl_vec_mut!(CascadeInfo, CascadeInfoVec);
36
impl_vec_debug!(CascadeInfo, CascadeInfoVec);
37
impl_vec_partialord!(CascadeInfo, CascadeInfoVec);
38
impl_vec_clone!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor);
39
impl_vec_partialeq!(CascadeInfo, CascadeInfoVec);
40

            
41
impl CascadeInfoVec {
42
290
    #[must_use] pub fn as_container(&self) -> NodeDataContainerRef<'_, CascadeInfo> {
43
290
        NodeDataContainerRef {
44
290
            internal: self.as_ref(),
45
290
        }
46
290
    }
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
937182
#[must_use] pub fn matches_html_element(
53
937182
    css_path: &CssPath,
54
937182
    node_id: NodeId,
55
937182
    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
56
937182
    node_data: &NodeDataContainerRef<'_, NodeData>,
57
937182
    html_node_tree: &NodeDataContainerRef<'_, CascadeInfo>,
58
937182
    expected_path_ending: Option<CssPathPseudoSelector>,
59
937182
) -> bool {
60
    use self::CssGroupSplitReason::{DirectChildren, Children, AdjacentSibling, GeneralSibling};
61

            
62
937182
    if css_path.selectors.is_empty() {
63
90
        return false;
64
937092
    }
65

            
66
    // Skip anonymous nodes - they are not part of the original DOM tree
67
    // and should not participate in CSS selector matching
68
937092
    if node_data[node_id].is_anonymous() {
69
3
        return false;
70
937089
    }
71

            
72
    // Collect all selector groups (processed right-to-left from the CSS path).
73
937089
    let groups: Vec<(CssContentGroup<'_>, CssGroupSplitReason)> =
74
937089
        CssGroupIterator::new(css_path.selectors.as_ref()).collect();
75

            
76
937089
    if groups.is_empty() {
77
        return false;
78
937089
    }
79

            
80
    // The rightmost group must match the target node directly.
81
937089
    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
937089
    let is_last_content_group = true;
87
937089
    if !selector_group_matches(
88
937089
        first_group,
89
937089
        html_node_tree[node_id],
90
937089
        &node_data[node_id],
91
937089
        node_id,
92
937089
        expected_path_ending.as_ref(),
93
937089
        is_last_content_group,
94
937089
    ) {
95
809193
        return false;
96
127896
    }
97

            
98
    // Navigate from the target node upward/sideways through the DOM,
99
    // matching each remaining selector group with its combinator.
100
127896
    let mut current_node = node_id;
101

            
102
127896
    for (group_idx, (content_group, _reason)) in groups.iter().enumerate().skip(1) {
103
        // The combinator comes from the PREVIOUS group's reason
104
111
        let combinator = groups[group_idx - 1].1;
105
111
        let is_last = group_idx == groups.len() - 1;
106

            
107
111
        match combinator {
108
            DirectChildren => {
109
                // Parent must match directly (child combinator `>`)
110
34
                let parent = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
111
33
                match parent {
112
33
                    Some(p) if selector_group_matches(
113
33
                        content_group, html_node_tree[p], &node_data[p], p,
114
33
                        expected_path_ending.as_ref(), is_last,
115
15
                    ) => { current_node = p; }
116
19
                    _ => return false,
117
                }
118
            }
119
            Children => {
120
                // Search up ancestor chain for a match (descendant combinator ` `)
121
72
                let mut ancestor = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
122
72
                let mut found = false;
123
131
                while let Some(anc) = ancestor {
124
107
                    if selector_group_matches(
125
107
                        content_group, html_node_tree[anc], &node_data[anc], anc,
126
107
                        expected_path_ending.as_ref(), is_last,
127
                    ) {
128
48
                        current_node = anc;
129
48
                        found = true;
130
48
                        break;
131
59
                    }
132
59
                    ancestor = find_non_anonymous_parent(anc, node_hierarchy, node_data);
133
                }
134
72
                if !found {
135
24
                    return false;
136
48
                }
137
            }
138
            AdjacentSibling => {
139
                // Immediate previous sibling must match (adjacent sibling `+`)
140
3
                let sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
141
3
                match sibling {
142
3
                    Some(s) if selector_group_matches(
143
3
                        content_group, html_node_tree[s], &node_data[s], s,
144
3
                        expected_path_ending.as_ref(), is_last,
145
2
                    ) => { current_node = s; }
146
1
                    _ => return false,
147
                }
148
            }
149
            GeneralSibling => {
150
                // Search previous siblings for a match (general sibling `~`)
151
2
                let mut sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
152
2
                let mut found = false;
153
3
                while let Some(sib) = sibling {
154
2
                    if selector_group_matches(
155
2
                        content_group, html_node_tree[sib], &node_data[sib], sib,
156
2
                        expected_path_ending.as_ref(), is_last,
157
                    ) {
158
1
                        current_node = sib;
159
1
                        found = true;
160
1
                        break;
161
1
                    }
162
1
                    sibling = find_non_anonymous_prev_sibling(sib, node_hierarchy, node_data);
163
                }
164
2
                if !found {
165
1
                    return false;
166
1
                }
167
            }
168
        }
169
    }
170

            
171
127851
    true
172
937182
}
173

            
174
/// Find the first non-anonymous parent of a node.
175
169
fn find_non_anonymous_parent(
176
169
    node_id: NodeId,
177
169
    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
178
169
    node_data: &NodeDataContainerRef<'_, NodeData>,
179
169
) -> Option<NodeId> {
180
169
    let mut next = node_hierarchy[node_id].parent_id();
181
172
    while let Some(n) = next {
182
145
        if !node_data[n].is_anonymous() {
183
142
            return Some(n);
184
3
        }
185
3
        next = node_hierarchy[n].parent_id();
186
    }
187
27
    None
188
169
}
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.
196
11
fn find_non_anonymous_prev_sibling(
197
11
    node_id: NodeId,
198
11
    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
199
11
    node_data: &NodeDataContainerRef<'_, NodeData>,
200
11
) -> Option<NodeId> {
201
11
    let mut next = node_hierarchy[node_id].previous_sibling_id();
202
16
    while let Some(n) = next {
203
12
        if !node_data[n].is_anonymous() && !node_data[n].is_text_node() {
204
7
            return Some(n);
205
5
        }
206
5
        next = node_hierarchy[n].previous_sibling_id();
207
    }
208
4
    None
209
11
}
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)]
224
pub 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)]
231
pub 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

            
242
impl<'a> CssGroupIterator<'a> {
243
937099
    #[must_use] pub const fn new(css_path: &'a [CssPathSelector]) -> Self {
244
937099
        let initial_len = css_path.len();
245
937099
        Self {
246
937099
            css_path,
247
937099
            current_idx: initial_len,
248
937099
            last_reason: CssGroupSplitReason::Children,
249
937099
        }
250
937099
    }
251
}
252

            
253
impl<'a> Iterator for CssGroupIterator<'a> {
254
    type Item = (CssContentGroup<'a>, CssGroupSplitReason);
255

            
256
1879964
    fn next(&mut self) -> Option<(CssContentGroup<'a>, CssGroupSplitReason)> {
257
        use self::CssPathSelector::{Children, DirectChildren, AdjacentSibling, GeneralSibling};
258

            
259
1879964
        let mut new_idx = self.current_idx;
260

            
261
1879964
        if new_idx == 0 {
262
937097
            return None;
263
942867
        }
264

            
265
942867
        let mut current_path = Vec::new();
266

            
267
2483838
        while new_idx != 0 {
268
1546743
            match self.css_path.get(new_idx - 1)? {
269
                Children => {
270
5704
                    self.last_reason = CssGroupSplitReason::Children;
271
5704
                    break;
272
                }
273
                DirectChildren => {
274
61
                    self.last_reason = CssGroupSplitReason::DirectChildren;
275
61
                    break;
276
                }
277
                AdjacentSibling => {
278
4
                    self.last_reason = CssGroupSplitReason::AdjacentSibling;
279
4
                    break;
280
                }
281
                GeneralSibling => {
282
3
                    self.last_reason = CssGroupSplitReason::GeneralSibling;
283
3
                    break;
284
                }
285
1540971
                other => current_path.push(other),
286
            }
287
1540971
            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
7693
        current_path.reverse();
294

            
295
942867
        if new_idx == 0 {
296
937095
            if current_path.is_empty() {
297
                None
298
            } else {
299
                // Last element of path
300
937095
                self.current_idx = 0;
301
937095
                Some((current_path, self.last_reason))
302
            }
303
        } else {
304
            // skip the "Children | DirectChildren" element itself
305
5772
            self.current_idx = new_idx - 1;
306
5772
            Some((current_path, self.last_reason))
307
        }
308
1879964
    }
309
}
310

            
311
35800
#[must_use] pub fn construct_html_cascade_tree(
312
35800
    node_hierarchy: &NodeHierarchyRef<'_>,
313
35800
    node_depths_sorted: &[(usize, NodeId)],
314
35800
    node_data: &NodeDataContainerRef<'_, NodeData>,
315
35800
) -> NodeDataContainer<CascadeInfo> {
316
35800
    let mut nodes = (0..node_hierarchy.len())
317
35800
        .map(|_| CascadeInfo {
318
            index_in_parent: 0,
319
            is_last_child: false,
320
690202
        })
321
35800
        .collect::<Vec<_>>();
322

            
323
394237
    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
358437
        let element_index_in_parent = parent_id
330
358437
            .preceding_siblings(node_hierarchy)
331
7367538
            .filter(|sib_id| !node_data[*sib_id].is_text_node())
332
358437
            .count();
333

            
334
358437
        let parent_html_matcher = CascadeInfo {
335
358437
            index_in_parent: u32::try_from(element_index_in_parent.saturating_sub(1))
336
358437
                .unwrap_or(u32::MAX),
337
            // Necessary for :last selectors — find last element sibling
338
            is_last_child: {
339
358437
                let mut is_last_element = true;
340
358437
                let mut next = node_hierarchy[*parent_id].next_sibling;
341
380395
                while let Some(sib_id) = next {
342
210239
                    if !node_data[sib_id].is_text_node() {
343
188281
                        is_last_element = false;
344
188281
                        break;
345
21958
                    }
346
21958
                    next = node_hierarchy[sib_id].next_sibling;
347
                }
348
358437
                is_last_element
349
            },
350
        };
351

            
352
358437
        nodes[parent_id.index()] = parent_html_matcher;
353

            
354
        // Count only element children for index_in_parent
355
358437
        let mut element_idx: u32 = 0;
356
654469
        for child_id in parent_id.children(node_hierarchy) {
357
654469
            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
654469
            let is_last_element_child = if is_text {
361
219625
                false
362
            } else {
363
434844
                let mut is_last = true;
364
434844
                let mut next = node_hierarchy[child_id].next_sibling;
365
458670
                while let Some(sib_id) = next {
366
279669
                    if !node_data[sib_id].is_text_node() {
367
255843
                        is_last = false;
368
255843
                        break;
369
23826
                    }
370
23826
                    next = node_hierarchy[sib_id].next_sibling;
371
                }
372
434844
                is_last
373
            };
374

            
375
654469
            let child_html_matcher = CascadeInfo {
376
654469
                index_in_parent: element_idx,
377
654469
                is_last_child: is_last_element_child,
378
654469
            };
379

            
380
654469
            nodes[child_id.index()] = child_html_matcher;
381

            
382
654469
            if !is_text {
383
434844
                element_idx += 1;
384
435164
            }
385
        }
386
    }
387

            
388
35800
    NodeDataContainer { internal: nodes }
389
35800
}
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
1144252
#[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
6943
    const fn is_interactive_pseudo(p: &CssPathPseudoSelector) -> bool {
401
3
        matches!(
402
6943
            p,
403
            CssPathPseudoSelector::Hover
404
                | CssPathPseudoSelector::Active
405
                | CssPathPseudoSelector::Focus
406
                | CssPathPseudoSelector::Backdrop
407
                | CssPathPseudoSelector::Dragging
408
                | CssPathPseudoSelector::DragOver
409
        )
410
6943
    }
411

            
412
1144252
    let Some(last) = path.selectors.as_ref().last() else {
413
3
        return false;
414
    };
415
1144249
    target.map_or_else(
416
947541
        || match last {
417
            // Only reject interactive pseudo-selectors (hover, active, focus).
418
            // Structural pseudo-selectors (nth-child, first, last) should be allowed.
419
6943
            CssPathSelector::PseudoSelector(p) => !is_interactive_pseudo(p),
420
940598
            _ => true,
421
947541
        },
422
8897
        |s| matches!(last, CssPathSelector::PseudoSelector(q) if *q == s),
423
    )
424
1144252
}
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.
430
937241
fn selector_group_matches(
431
937241
    selectors: &[&CssPathSelector],
432
937241
    html_node: CascadeInfo,
433
937241
    node_data: &NodeData,
434
937241
    node_id: NodeId,
435
937241
    expected_path_ending: Option<&CssPathPseudoSelector>,
436
937241
    is_last_content_group: bool,
437
937241
) -> bool {
438
    // Inline-style detection for the `Global` arm: a bare-declaration
439
    // `with_css` rule is scoped to EXACTLY its owner node when the scope is
440
    // pushed (`push_front_scope_for` collapses the range to `[owner, owner]`
441
    // for INLINE-priority bare `*` wrappers). Such a rule is the author
442
    // addressing THIS node directly — it must style a text node too.
443
1535276
    let node_scoped_to_self = selectors.iter().any(|s| {
444
590868
        matches!(s, CssPathSelector::Root(r)
445
590868
            if r.start == r.end && r.start == node_id.index())
446
1535276
    });
447
1147363
    selectors.iter().all(|selector| {
448
1147363
        match_single_selector(
449
1147363
            selector,
450
1147363
            html_node,
451
1147363
            node_data,
452
1147363
            node_id,
453
1147363
            expected_path_ending,
454
1147363
            is_last_content_group,
455
1147363
            node_scoped_to_self,
456
        )
457
1147363
    })
458
937241
}
459

            
460
/// Matches a single CSS selector against a DOM node.
461
1147393
fn match_single_selector(
462
1147393
    selector: &CssPathSelector,
463
1147393
    html_node: CascadeInfo,
464
1147393
    node_data: &NodeData,
465
1147393
    node_id: NodeId,
466
1147393
    expected_path_ending: Option<&CssPathPseudoSelector>,
467
1147393
    is_last_content_group: bool,
468
1147393
    node_scoped_to_self: bool,
469
1147393
) -> bool {
470
    use self::CssPathSelector::{Global, Root, Type, Class, Id, PseudoSelector, Attribute, DirectChildren, Children, AdjacentSibling, GeneralSibling};
471

            
472
6941
    match selector {
473
        // Per CSS, `*` matches ELEMENTS - never text nodes: letting a
474
        // stylesheet's universal selector hit text nodes made
475
        // `* { color: #666 }` overwrite the color a text child had just
476
        // inherited from its `p { color: red }` parent. The one exception is
477
        // a rule scoped to EXACTLY this node (`node_scoped_to_self`) — that
478
        // is a bare-declaration `with_css` ON the text node itself, i.e.
479
        // inline-style semantics: `create_text_do_not_use_without_block_level_wrapper("x").with_css("color: white")`
480
        // must apply. Subtree-scoped and unscoped `*` rules keep refusing
481
        // text nodes.
482
256642
        Global => !node_data.is_text_node() || node_scoped_to_self,
483
        // `Root(range)` (scope marker, #47): matches any node WITHIN the subtree
484
        // range `[start, end]`. The range is chosen when the scope is pushed
485
        // (`CssPath::push_front_scope`):
486
        //  - a bare-decl `with_css` rule (`* { … }`) is scoped node-only (`[start,
487
        //    start]`) → inline-style semantics: it applies to the OWNER only, so a
488
        //    non-root `background` can't leak to descendants/siblings (#47 leak fix).
489
        //  - a component rule with a real selector (`.menu-item`, from
490
        //    `add_component_css`) is scoped to the whole subtree (`[start, end]`) so
491
        //    its selector matches descendants of the owner (a menu container styling
492
        //    its `.menu-item` children). Compounded with the rest of the path,
493
        //    `[Root(range), Class(x)]` means "a node in range that also matches `.x`".
494
203108
        Root(range) => range.contains(node_id.index()),
495
326663
        Type(t) => node_data.get_node_type().get_path() == *t,
496
344985
        Class(c) => node_data.has_class(c.as_str()),
497
8727
        Id(id) => node_data.has_id(id.as_str()),
498
        // `:root` matches the document root element (NodeId::ZERO, the topmost
499
        // element). Handled here rather than in `match_pseudo_selector` because it
500
        // needs `node_id`. Equivalent to `html` but with pseudo-class specificity.
501
        PseudoSelector(CssPathPseudoSelector::Root) => node_id.index() == 0,
502
6941
        PseudoSelector(p) => {
503
6941
            match_pseudo_selector(p, html_node, expected_path_ending, is_last_content_group)
504
        }
505
323
        Attribute(a) => match_attribute_selector(a, node_data),
506
4
        DirectChildren | Children | AdjacentSibling | GeneralSibling => false,
507
    }
508
1147393
}
509

            
510
/// Matches an attribute selector (`[name]`, `[name="v"]`, `[name~="v"]`, ...) against a node.
511
///
512
/// Some attributes (notably `class`) are stored as multiple separate entries in
513
/// `node_data.attributes()` rather than a single space-joined string. We collect
514
/// every matching value and treat the matcher as "any value satisfies the op",
515
/// so that `[class~="primary"]` matches a node with classes `foo primary bar`.
516
376
fn match_attribute_selector(sel: &CssAttributeSelector, node_data: &NodeData) -> bool {
517
376
    let name = sel.name.as_str();
518
376
    let target = sel.value.as_ref().map(azul_css::AzString::as_str);
519

            
520
376
    let check = |actual: &str| -> bool {
521
364
        match (&sel.op, target) {
522
21
            (AttributeMatchOp::Exists, _) => true,
523
62
            (AttributeMatchOp::Eq, Some(t)) => actual == t,
524
69
            (AttributeMatchOp::Includes, Some(t)) => {
525
69
                if t.is_empty() || t.contains(char::is_whitespace) {
526
7
                    return false;
527
62
                }
528
62
                actual.split_whitespace().any(|word| word == t)
529
            }
530
64
            (AttributeMatchOp::DashMatch, Some(t)) => {
531
64
                actual == t || actual.starts_with(&alloc::format!("{t}-"))
532
            }
533
60
            (AttributeMatchOp::Prefix, Some(t)) => !t.is_empty() && actual.starts_with(t),
534
41
            (AttributeMatchOp::Suffix, Some(t)) => !t.is_empty() && actual.ends_with(t),
535
41
            (AttributeMatchOp::Substring, Some(t)) => !t.is_empty() && actual.contains(t),
536
            // Operator with a missing value (parser should reject these — be defensive).
537
6
            (_, None) => false,
538
        }
539
364
    };
540

            
541
376
    for attr in node_data.attributes() {
542
369
        if attr.name() != name {
543
5
            continue;
544
364
        }
545
364
        if check(attr.value().as_str()) {
546
186
            return true;
547
178
        }
548
    }
549

            
550
190
    false
551
376
}
552

            
553
/// Matches a pseudo-selector (:first, :last, :nth-child, :hover, etc.) against a node.
554
6978
fn match_pseudo_selector(
555
6978
    pseudo: &CssPathPseudoSelector,
556
6978
    html_node: CascadeInfo,
557
6978
    expected_path_ending: Option<&CssPathPseudoSelector>,
558
6978
    is_last_content_group: bool,
559
6978
) -> bool {
560
6978
    match pseudo {
561
7
        CssPathPseudoSelector::First => match_first_child(html_node),
562
4
        CssPathPseudoSelector::Last => match_last_child(html_node),
563
5
        CssPathPseudoSelector::NthChild(pattern) => match_nth_child(html_node, pattern),
564
6940
        CssPathPseudoSelector::Hover => match_interactive_pseudo(
565
6940
            &CssPathPseudoSelector::Hover,
566
6940
            expected_path_ending,
567
6940
            is_last_content_group,
568
        ),
569
3
        CssPathPseudoSelector::Active => match_interactive_pseudo(
570
3
            &CssPathPseudoSelector::Active,
571
3
            expected_path_ending,
572
3
            is_last_content_group,
573
        ),
574
3
        CssPathPseudoSelector::Focus => match_interactive_pseudo(
575
3
            &CssPathPseudoSelector::Focus,
576
3
            expected_path_ending,
577
3
            is_last_content_group,
578
        ),
579
3
        CssPathPseudoSelector::Backdrop => match_interactive_pseudo(
580
3
            &CssPathPseudoSelector::Backdrop,
581
3
            expected_path_ending,
582
3
            is_last_content_group,
583
        ),
584
3
        CssPathPseudoSelector::Dragging => match_interactive_pseudo(
585
3
            &CssPathPseudoSelector::Dragging,
586
3
            expected_path_ending,
587
3
            is_last_content_group,
588
        ),
589
3
        CssPathPseudoSelector::DragOver => match_interactive_pseudo(
590
3
            &CssPathPseudoSelector::DragOver,
591
3
            expected_path_ending,
592
3
            is_last_content_group,
593
        ),
594
7
        CssPathPseudoSelector::Lang(lang) => {
595
            // :lang() is matched via DynamicSelector at runtime, not during CSS cascade
596
            // During cascade, we just check if this is the expected ending
597
6
            if let Some(CssPathPseudoSelector::Lang(expected_lang)) = expected_path_ending {
598
5
                return lang == expected_lang;
599
2
            }
600
            // If not specifically looking for :lang, it doesn't match structurally
601
2
            false
602
        }
603
        // `:root` is matched in `match_single_selector` (it needs `node_id`), so it
604
        // never reaches here — return false defensively.
605
        CssPathPseudoSelector::Root => false,
606
    }
607
6978
}
608

            
609
/// Returns true if the node is the first child of its parent.
610
10
const fn match_first_child(html_node: CascadeInfo) -> bool {
611
10
    html_node.index_in_parent == 0
612
10
}
613

            
614
/// Returns true if the node is the last child of its parent.
615
7
const fn match_last_child(html_node: CascadeInfo) -> bool {
616
7
    html_node.is_last_child
617
7
}
618

            
619
/// Matches :nth-child(n), :nth-child(even), :nth-child(odd), or :nth-child(An+B) patterns.
620
7209
fn match_nth_child(html_node: CascadeInfo, pattern: &CssNthChildSelector) -> bool {
621
    use azul_css::css::CssNthChildPattern;
622

            
623
    // nth-child is 1-indexed, index_in_parent is 0-indexed
624
7209
    let index = html_node.index_in_parent + 1;
625

            
626
7209
    match pattern {
627
4105
        Number(n) => index == *n,
628
1259
        Even => index.is_multiple_of(2),
629
1259
        Odd => index % 2 == 1,
630
        Pattern(CssNthChildPattern {
631
586
            pattern_repeat,
632
586
            offset,
633
        }) => {
634
586
            if *pattern_repeat == 0 {
635
64
                index == *offset
636
            } else {
637
522
                index >= *offset && (index - offset).is_multiple_of(*pattern_repeat)
638
            }
639
        }
640
    }
641
7209
}
642

            
643
/// Matches interactive pseudo-selectors (:hover, :active, :focus).
644
/// These only apply if they appear in the last content group of the CSS path.
645
6960
fn match_interactive_pseudo(
646
6960
    pseudo: &CssPathPseudoSelector,
647
6960
    expected_path_ending: Option<&CssPathPseudoSelector>,
648
6960
    is_last_content_group: bool,
649
6960
) -> bool {
650
6960
    is_last_content_group && expected_path_ending == Some(pseudo)
651
6960
}
652

            
653
#[cfg(test)]
654
#[allow(clippy::pedantic, clippy::nursery, clippy::too_many_lines)]
655
mod autotest_generated {
656
    use azul_css::{
657
        css::{CssNthChildPattern, CssScopeRange, NodeTypeTag},
658
        OptionString,
659
    };
660

            
661
    use super::*;
662
    use crate::{
663
        dom::{AttributeNameValue, AttributeType, NodeType},
664
        id::Node,
665
    };
666

            
667
    // ---------------------------------------------------------------------
668
    // helpers
669
    // ---------------------------------------------------------------------
670

            
671
    fn node(
672
        parent: Option<usize>,
673
        prev: Option<usize>,
674
        next: Option<usize>,
675
        last_child: Option<usize>,
676
    ) -> Node {
677
        Node {
678
            parent: parent.map(NodeId::new),
679
            previous_sibling: prev.map(NodeId::new),
680
            next_sibling: next.map(NodeId::new),
681
            last_child: last_child.map(NodeId::new),
682
        }
683
    }
684

            
685
    fn items(nodes: &[Node]) -> Vec<NodeHierarchyItem> {
686
        nodes.iter().map(|n| NodeHierarchyItem::from(*n)).collect()
687
    }
688

            
689
    fn div_with(id: Option<&str>, class: Option<&str>) -> NodeData {
690
        let mut nd = NodeData::create_div();
691
        if let Some(i) = id {
692
            nd.add_id(i.into());
693
        }
694
        if let Some(c) = class {
695
            nd.add_class(c.into());
696
        }
697
        nd
698
    }
699

            
700
    fn node_with_attrs(attrs: Vec<AttributeType>) -> NodeData {
701
        let mut nd = NodeData::create_div();
702
        nd.set_attributes(attrs.into());
703
        nd
704
    }
705

            
706
    fn custom(name: &str, value: &str) -> AttributeType {
707
        AttributeType::Custom(AttributeNameValue {
708
            attr_name: name.into(),
709
            value: value.into(),
710
        })
711
    }
712

            
713
    fn attr_sel(name: &str, op: AttributeMatchOp, value: Option<&str>) -> CssAttributeSelector {
714
        CssAttributeSelector {
715
            name: name.into(),
716
            op,
717
            value: value.map_or(OptionString::None, |v| OptionString::Some(v.into())),
718
        }
719
    }
720

            
721
    fn info(index_in_parent: u32, is_last_child: bool) -> CascadeInfo {
722
        CascadeInfo {
723
            index_in_parent,
724
            is_last_child,
725
        }
726
    }
727

            
728
    /// The shared fixture DOM. Note node 2 is a **text node** sitting between
729
    /// two element siblings — the whole point is to exercise the "text nodes
730
    /// are not counted as element siblings" rule (CSS Selectors L4 §13).
731
    ///
732
    /// ```text
733
    /// 0 body
734
    /// ├── 1 div#first.a
735
    /// ├── 2 "hello"          (text)
736
    /// ├── 3 div.b
737
    /// │   └── 4 p.inner
738
    /// └── 5 div.c
739
    /// ```
740
    fn sample_hierarchy() -> Vec<Node> {
741
        vec![
742
            node(None, None, None, Some(5)),
743
            node(Some(0), None, Some(2), None),
744
            node(Some(0), Some(1), Some(3), None),
745
            node(Some(0), Some(2), Some(5), Some(4)),
746
            node(Some(3), None, None, None),
747
            node(Some(0), Some(3), None, None),
748
        ]
749
    }
750

            
751
    fn sample_node_data() -> Vec<NodeData> {
752
        let mut p = NodeData::create_node(NodeType::P);
753
        p.add_class("inner".into());
754
        vec![
755
            NodeData::create_body(),
756
            div_with(Some("first"), Some("a")),
757
            NodeData::create_text_do_not_use_without_block_level_wrapper("hello"),
758
            div_with(None, Some("b")),
759
            p,
760
            div_with(None, Some("c")),
761
        ]
762
    }
763

            
764
    /// Runs `matches_html_element` against the sample fixture.
765
    fn matches(
766
        selectors: Vec<CssPathSelector>,
767
        node_index: usize,
768
        expected_path_ending: Option<CssPathPseudoSelector>,
769
    ) -> bool {
770
        let hierarchy = sample_hierarchy();
771
        let hier_items = items(&hierarchy);
772
        let data = sample_node_data();
773

            
774
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
775
        let data_ref = NodeDataContainerRef::from_slice(&data);
776
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
777
        let cascade = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
778

            
779
        matches_html_element(
780
            &CssPath::new(selectors),
781
            NodeId::new(node_index),
782
            &NodeDataContainerRef::from_slice(&hier_items),
783
            &data_ref,
784
            &cascade.as_ref(),
785
            expected_path_ending,
786
        )
787
    }
788

            
789
    // ---------------------------------------------------------------------
790
    // CascadeInfoVec::as_container  (getter)
791
    // ---------------------------------------------------------------------
792

            
793
    #[test]
794
    fn cascade_info_vec_as_container_preserves_every_entry() {
795
        let v: CascadeInfoVec = vec![info(0, false), info(1, false), info(2, true)].into();
796
        let c = v.as_container();
797

            
798
        assert_eq!(c.len(), 3);
799
        assert!(!c.is_empty());
800
        assert_eq!(c[NodeId::new(0)], info(0, false));
801
        assert_eq!(c[NodeId::new(2)], info(2, true));
802
        // Out-of-range access must be a `None`, not a panic.
803
        assert_eq!(c.get(NodeId::new(3)), None);
804
        assert_eq!(c.get(NodeId::new(usize::MAX)), None);
805
    }
806

            
807
    #[test]
808
    fn cascade_info_vec_as_container_on_empty_vec_does_not_panic() {
809
        let v: CascadeInfoVec = Vec::new().into();
810
        let c = v.as_container();
811
        assert_eq!(c.len(), 0);
812
        assert!(c.is_empty());
813
        assert_eq!(c.get(NodeId::new(0)), None);
814
    }
815

            
816
    #[test]
817
    fn cascade_info_vec_as_container_survives_extreme_field_values() {
818
        let v: CascadeInfoVec = vec![info(u32::MAX, true)].into();
819
        let c = v.as_container();
820
        assert_eq!(c[NodeId::new(0)].index_in_parent, u32::MAX);
821
        assert!(c[NodeId::new(0)].is_last_child);
822
    }
823

            
824
    // ---------------------------------------------------------------------
825
    // CssGroupIterator
826
    // ---------------------------------------------------------------------
827

            
828
    #[test]
829
    fn css_group_iterator_new_records_the_path_and_yields_nothing_when_empty() {
830
        let empty: [CssPathSelector; 0] = [];
831
        let it = CssGroupIterator::new(&empty);
832
        assert!(it.css_path.is_empty());
833
        assert_eq!(CssGroupIterator::new(&empty).count(), 0);
834
    }
835

            
836
    #[test]
837
    fn css_group_iterator_new_keeps_the_slice_it_was_given() {
838
        let path = vec![
839
            CssPathSelector::Global,
840
            CssPathSelector::Children,
841
            CssPathSelector::Class("x".into()),
842
        ];
843
        let it = CssGroupIterator::new(&path);
844
        assert_eq!(it.css_path.len(), 3);
845
        assert_eq!(it.css_path, path.as_slice());
846
    }
847

            
848
    #[test]
849
    fn css_group_iterator_splits_right_to_left_with_the_left_hand_combinator() {
850
        // `body > .foo.main .baz`
851
        let path = vec![
852
            CssPathSelector::Type(NodeTypeTag::Body),
853
            CssPathSelector::DirectChildren,
854
            CssPathSelector::Class("foo".into()),
855
            CssPathSelector::Class("main".into()),
856
            CssPathSelector::Children,
857
            CssPathSelector::Class("baz".into()),
858
        ];
859

            
860
        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
861
        assert_eq!(groups.len(), 3);
862

            
863
        // Group 0 is the RIGHTMOST group (the subject of the selector).
864
        assert_eq!(groups[0].0, vec![&path[5]]);
865
        assert_eq!(groups[0].1, CssGroupSplitReason::Children);
866

            
867
        assert_eq!(groups[1].0, vec![&path[2], &path[3]]);
868
        assert_eq!(groups[1].1, CssGroupSplitReason::DirectChildren);
869

            
870
        assert_eq!(groups[2].0, vec![&path[0]]);
871
        // NOTE: the leftmost group's `reason` is a carry-over from the previous
872
        // split (there is no combinator to its left). `matches_html_element`
873
        // never reads it — it uses `groups[i - 1].1` as the combinator for
874
        // group `i` — so we deliberately do not assert on it here.
875
    }
876

            
877
    #[test]
878
    fn css_group_iterator_yields_an_empty_group_for_a_trailing_combinator() {
879
        // Malformed path `.foo >` (a combinator with nothing to its right).
880
        let path = vec![
881
            CssPathSelector::Class("foo".into()),
882
            CssPathSelector::DirectChildren,
883
        ];
884
        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
885

            
886
        assert_eq!(groups.len(), 2);
887
        assert!(
888
            groups[0].0.is_empty(),
889
            "the group to the right of the dangling combinator is empty"
890
        );
891
        assert_eq!(groups[0].1, CssGroupSplitReason::DirectChildren);
892
        assert_eq!(groups[1].0, vec![&path[0]]);
893
    }
894

            
895
    #[test]
896
    fn css_group_iterator_terminates_on_consecutive_combinators() {
897
        // `.a > ~ .b` — two combinators in a row (parser should never emit this,
898
        // but the iterator must not loop forever or drop selectors).
899
        let path = vec![
900
            CssPathSelector::Class("a".into()),
901
            CssPathSelector::Children,
902
            CssPathSelector::DirectChildren,
903
            CssPathSelector::Class("b".into()),
904
        ];
905
        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
906

            
907
        assert_eq!(groups.len(), 3);
908
        assert_eq!(groups[0].0, vec![&path[3]]);
909
        assert!(groups[1].0.is_empty(), "the group between the two combinators is empty");
910
        assert_eq!(groups[2].0, vec![&path[0]]);
911
    }
912

            
913
    #[test]
914
    fn css_group_iterator_only_combinators_yields_only_empty_groups() {
915
        let path = vec![
916
            CssPathSelector::Children,
917
            CssPathSelector::DirectChildren,
918
            CssPathSelector::AdjacentSibling,
919
            CssPathSelector::GeneralSibling,
920
        ];
921
        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
922

            
923
        // Four combinators, four splits, and the final `new_idx == 0 &&
924
        // current_path.is_empty()` branch ends the iteration.
925
        assert_eq!(groups.len(), 4);
926
        assert!(groups.iter().all(|(g, _)| g.is_empty()));
927
    }
928

            
929
    #[test]
930
    fn css_group_iterator_conserves_every_non_combinator_selector_on_a_huge_path() {
931
        // 10_000 selectors: `.c0 .c1 .c2 ...` — must terminate and must not
932
        // lose or duplicate a single selector.
933
        let mut path = Vec::new();
934
        for i in 0..5_000u32 {
935
            if i != 0 {
936
                path.push(CssPathSelector::Children);
937
            }
938
            path.push(CssPathSelector::Class(alloc::format!(".c{i}").into()));
939
        }
940

            
941
        let groups: Vec<_> = CssGroupIterator::new(&path).collect();
942
        assert_eq!(groups.len(), 5_000);
943
        let total: usize = groups.iter().map(|(g, _)| g.len()).sum();
944
        assert_eq!(
945
            total, 5_000,
946
            "every non-combinator selector must appear in exactly one group"
947
        );
948
        assert!(groups.iter().all(|(_, r)| *r == CssGroupSplitReason::Children));
949
    }
950

            
951
    // ---------------------------------------------------------------------
952
    // rule_ends_with
953
    // ---------------------------------------------------------------------
954

            
955
    #[test]
956
    fn rule_ends_with_empty_path_is_always_false() {
957
        let empty = CssPath::new(Vec::new());
958
        assert!(!rule_ends_with(&empty, None));
959
        assert!(!rule_ends_with(&empty, Some(CssPathPseudoSelector::Hover)));
960
        assert!(!rule_ends_with(
961
            &empty,
962
            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Even))
963
        ));
964
    }
965

            
966
    #[test]
967
    fn rule_ends_with_none_rejects_interactive_pseudos_but_keeps_structural_ones() {
968
        let ends_with = |s: CssPathSelector| {
969
            rule_ends_with(&CssPath::new(vec![CssPathSelector::Class("a".into()), s]), None)
970
        };
971

            
972
        // Interactive => rejected for the "normal" (non-pseudo) pass.
973
        for p in [
974
            CssPathPseudoSelector::Hover,
975
            CssPathPseudoSelector::Active,
976
            CssPathPseudoSelector::Focus,
977
            CssPathPseudoSelector::Backdrop,
978
            CssPathPseudoSelector::Dragging,
979
            CssPathPseudoSelector::DragOver,
980
        ] {
981
            assert!(
982
                !ends_with(CssPathSelector::PseudoSelector(p.clone())),
983
                "interactive pseudo {p:?} must not end a `None` rule"
984
            );
985
        }
986

            
987
        // Structural => kept.
988
        assert!(ends_with(CssPathSelector::PseudoSelector(
989
            CssPathPseudoSelector::First
990
        )));
991
        assert!(ends_with(CssPathSelector::PseudoSelector(
992
            CssPathPseudoSelector::Last
993
        )));
994
        assert!(ends_with(CssPathSelector::PseudoSelector(
995
            CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd)
996
        )));
997
        // Non-pseudo endings are always kept.
998
        assert!(ends_with(CssPathSelector::Global));
999
        assert!(ends_with(CssPathSelector::Class("b".into())));
        assert!(ends_with(CssPathSelector::Type(NodeTypeTag::Div)));
        assert!(ends_with(CssPathSelector::DirectChildren));
    }
    #[test]
    fn rule_ends_with_some_target_requires_an_exact_match_on_the_last_selector() {
        let hover = CssPath::new(vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
        ]);
        assert!(rule_ends_with(&hover, Some(CssPathPseudoSelector::Hover)));
        assert!(!rule_ends_with(&hover, Some(CssPathPseudoSelector::Active)));
        assert!(!rule_ends_with(&hover, Some(CssPathPseudoSelector::Focus)));
        // A non-pseudo ending never matches a pseudo target.
        let plain = CssPath::new(vec![CssPathSelector::Class("a".into())]);
        assert!(!rule_ends_with(&plain, Some(CssPathPseudoSelector::Hover)));
        // Documented limitation: only the VERY LAST selector is inspected, so
        // `.a:hover:first` does not count as ending with `:hover`.
        let compound = CssPath::new(vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::First),
        ]);
        assert!(!rule_ends_with(&compound, Some(CssPathPseudoSelector::Hover)));
        assert!(rule_ends_with(&compound, Some(CssPathPseudoSelector::First)));
    }
    #[test]
    fn rule_ends_with_compares_nth_child_and_lang_payloads() {
        let nth = |s| {
            CssPath::new(vec![CssPathSelector::PseudoSelector(
                CssPathPseudoSelector::NthChild(s),
            )])
        };
        assert!(rule_ends_with(
            &nth(CssNthChildSelector::Number(3)),
            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(3)))
        ));
        assert!(!rule_ends_with(
            &nth(CssNthChildSelector::Number(3)),
            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(4)))
        ));
        assert!(!rule_ends_with(
            &nth(CssNthChildSelector::Even),
            Some(CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd))
        ));
        // Unicode / boundary language tags must compare by value, not by prefix.
        let lang = |s: &str| {
            CssPath::new(vec![CssPathSelector::PseudoSelector(
                CssPathPseudoSelector::Lang(s.into()),
            )])
        };
        assert!(rule_ends_with(
            &lang("zh-Hant-🎉"),
            Some(CssPathPseudoSelector::Lang("zh-Hant-🎉".into()))
        ));
        assert!(!rule_ends_with(
            &lang("zh-Hant-🎉"),
            Some(CssPathPseudoSelector::Lang("zh".into()))
        ));
        assert!(!rule_ends_with(&lang(""), Some(CssPathPseudoSelector::Lang("de".into()))));
    }
    // ---------------------------------------------------------------------
    // match_first_child / match_last_child
    // ---------------------------------------------------------------------
    #[test]
    fn match_first_and_last_child_read_only_their_own_field() {
        assert!(match_first_child(info(0, false)));
        assert!(!match_first_child(info(1, true)));
        assert!(!match_first_child(info(u32::MAX, true)));
        assert!(match_last_child(info(0, true)));
        assert!(!match_last_child(info(0, false)));
        assert!(match_last_child(info(u32::MAX, true)));
    }
    // ---------------------------------------------------------------------
    // match_nth_child  (numeric: 1-indexing, saturation, div-by-zero, overflow)
    // ---------------------------------------------------------------------
    #[test]
    fn match_nth_child_is_one_indexed() {
        // index_in_parent 0 == :nth-child(1) == odd
        assert!(match_nth_child(info(0, false), &CssNthChildSelector::Number(1)));
        assert!(!match_nth_child(info(0, false), &CssNthChildSelector::Number(0)));
        assert!(match_nth_child(info(0, false), &CssNthChildSelector::Odd));
        assert!(!match_nth_child(info(0, false), &CssNthChildSelector::Even));
        assert!(match_nth_child(info(1, false), &CssNthChildSelector::Number(2)));
        assert!(match_nth_child(info(1, false), &CssNthChildSelector::Even));
        assert!(!match_nth_child(info(1, false), &CssNthChildSelector::Odd));
    }
    #[test]
    fn match_nth_child_number_matches_exactly_one_index() {
        for n in 1..64u32 {
            for idx in 0..64u32 {
                let matched = match_nth_child(info(idx, false), &CssNthChildSelector::Number(n));
                assert_eq!(matched, idx + 1 == n, "nth-child({n}) vs index {idx}");
            }
        }
        // `:nth-child(0)` can never match: the index is 1-based.
        for idx in 0..64u32 {
            assert!(!match_nth_child(info(idx, false), &CssNthChildSelector::Number(0)));
        }
    }
    #[test]
    fn match_nth_child_even_and_odd_partition_every_index() {
        for idx in 0..1_000u32 {
            let even = match_nth_child(info(idx, false), &CssNthChildSelector::Even);
            let odd = match_nth_child(info(idx, false), &CssNthChildSelector::Odd);
            assert_ne!(even, odd, "index {idx} must be exactly one of even/odd");
        }
    }
    #[test]
    fn match_nth_child_pattern_agrees_with_the_even_and_odd_shorthands() {
        // CSS: `even` == `2n`, `odd` == `2n+1`.
        for idx in 0..256u32 {
            let even_pat = CssNthChildSelector::Pattern(CssNthChildPattern {
                pattern_repeat: 2,
                offset: 0,
            });
            let odd_pat = CssNthChildSelector::Pattern(CssNthChildPattern {
                pattern_repeat: 2,
                offset: 1,
            });
            assert_eq!(
                match_nth_child(info(idx, false), &even_pat),
                match_nth_child(info(idx, false), &CssNthChildSelector::Even),
                "2n disagrees with `even` at index {idx}"
            );
            assert_eq!(
                match_nth_child(info(idx, false), &odd_pat),
                match_nth_child(info(idx, false), &CssNthChildSelector::Odd),
                "2n+1 disagrees with `odd` at index {idx}"
            );
        }
    }
    #[test]
    fn match_nth_child_zero_repeat_never_divides_by_zero() {
        // `0n+3` matches only the 3rd child; `0n+0` matches nothing (1-based).
        let only_third = CssNthChildSelector::Pattern(CssNthChildPattern {
            pattern_repeat: 0,
            offset: 3,
        });
        let never = CssNthChildSelector::Pattern(CssNthChildPattern {
            pattern_repeat: 0,
            offset: 0,
        });
        for idx in 0..32u32 {
            assert_eq!(match_nth_child(info(idx, false), &only_third), idx == 2);
            assert!(
                !match_nth_child(info(idx, false), &never),
                "0n+0 must never match (index is 1-based)"
            );
        }
    }
    #[test]
    fn match_nth_child_pattern_below_the_offset_does_not_underflow() {
        // `2n+5`: nothing below the 5th child may match, and the `index - offset`
        // subtraction must never be reached for those.
        let pat = CssNthChildSelector::Pattern(CssNthChildPattern {
            pattern_repeat: 2,
            offset: 5,
        });
        for idx in 0..4u32 {
            assert!(!match_nth_child(info(idx, false), &pat), "index {idx} < offset");
        }
        assert!(match_nth_child(info(4, false), &pat)); // 5th child
        assert!(!match_nth_child(info(5, false), &pat)); // 6th
        assert!(match_nth_child(info(6, false), &pat)); // 7th
    }
    #[test]
    fn match_nth_child_with_a_u32_max_offset_does_not_underflow() {
        let pat = CssNthChildSelector::Pattern(CssNthChildPattern {
            pattern_repeat: 1,
            offset: u32::MAX,
        });
        assert!(!match_nth_child(info(0, false), &pat));
        assert!(!match_nth_child(info(1_000, false), &pat));
        // index == u32::MAX (index_in_parent == u32::MAX - 1) is the largest
        // index that can be formed without overflowing the `+ 1`.
        assert!(match_nth_child(info(u32::MAX - 1, false), &pat));
    }
    /// BOUNDARY: `match_nth_child` computes `index_in_parent + 1` unchecked.
    /// `index_in_parent == u32::MAX` is reachable — `construct_html_cascade_tree`
    /// itself saturates to `u32::MAX` (`unwrap_or(u32::MAX)`), and `CascadeInfo`
    /// is a `#[repr(C)]` struct with public fields. Debug builds therefore panic
    /// on overflow, release builds wrap the index to 0. Assert that the wrap can
    /// never silently produce the *wrong* answer for even/odd/number.
    #[cfg(feature = "std")]
    #[test]
    fn match_nth_child_at_u32_max_index_never_answers_wrongly() {
        // The true 1-based index here is 2^32, which is even.
        let even = std::panic::catch_unwind(|| {
            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Even)
        });
        let odd = std::panic::catch_unwind(|| {
            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Odd)
        });
        let one = std::panic::catch_unwind(|| {
            match_nth_child(info(u32::MAX, false), &CssNthChildSelector::Number(1))
        });
        // debug: the overflow check fires — loud failure, acceptable.
        // release: the index wraps to 0, which must still not flip an answer.
        if let Ok(v) = even {
            assert!(v, "2^32 is even");
        }
        if let Ok(v) = odd {
            assert!(!v, "2^32 is not odd");
        }
        if let Ok(v) = one {
            assert!(!v, "the 2^32-th child is not the 1st child");
        }
    }
    // ---------------------------------------------------------------------
    // match_interactive_pseudo / match_pseudo_selector
    // ---------------------------------------------------------------------
    #[test]
    fn match_interactive_pseudo_needs_both_the_last_group_and_the_expected_ending() {
        let hover = CssPathPseudoSelector::Hover;
        let active = CssPathPseudoSelector::Active;
        assert!(match_interactive_pseudo(&hover, Some(&hover), true));
        assert!(!match_interactive_pseudo(&hover, Some(&hover), false));
        assert!(!match_interactive_pseudo(&hover, Some(&active), true));
        assert!(!match_interactive_pseudo(&hover, None, true));
        assert!(!match_interactive_pseudo(&hover, None, false));
    }
    #[test]
    fn match_pseudo_selector_routes_every_interactive_pseudo_through_the_gate() {
        for p in [
            CssPathPseudoSelector::Hover,
            CssPathPseudoSelector::Active,
            CssPathPseudoSelector::Focus,
            CssPathPseudoSelector::Backdrop,
            CssPathPseudoSelector::Dragging,
            CssPathPseudoSelector::DragOver,
        ] {
            assert!(
                match_pseudo_selector(&p, info(0, false), Some(&p), true),
                "{p:?} must match when it is the expected ending of the last group"
            );
            assert!(
                !match_pseudo_selector(&p, info(0, false), Some(&p), false),
                "{p:?} must not match outside the last content group"
            );
            assert!(
                !match_pseudo_selector(&p, info(0, false), None, true),
                "{p:?} must not match when no pseudo state is expected"
            );
        }
    }
    #[test]
    fn match_pseudo_selector_structural_pseudos_ignore_the_expected_ending() {
        let hover = CssPathPseudoSelector::Hover;
        // :first / :last / :nth-child depend only on the CascadeInfo.
        for (expected, is_last) in [(None, true), (Some(&hover), false), (Some(&hover), true)] {
            assert!(match_pseudo_selector(
                &CssPathPseudoSelector::First,
                info(0, false),
                expected,
                is_last
            ));
            assert!(!match_pseudo_selector(
                &CssPathPseudoSelector::First,
                info(1, false),
                expected,
                is_last
            ));
            assert!(match_pseudo_selector(
                &CssPathPseudoSelector::Last,
                info(9, true),
                expected,
                is_last
            ));
            assert!(match_pseudo_selector(
                &CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(10)),
                info(9, true),
                expected,
                is_last
            ));
        }
    }
    #[test]
    fn match_pseudo_selector_lang_only_matches_the_expected_lang() {
        let de = CssPathPseudoSelector::Lang("de".into());
        let en = CssPathPseudoSelector::Lang("en".into());
        assert!(match_pseudo_selector(&de, info(0, false), Some(&de), true));
        assert!(!match_pseudo_selector(&de, info(0, false), Some(&en), true));
        assert!(!match_pseudo_selector(&de, info(0, false), None, true));
        assert!(!match_pseudo_selector(
            &de,
            info(0, false),
            Some(&CssPathPseudoSelector::Hover),
            true
        ));
        // Unicode + empty language tags must not panic and must compare by value.
        let emoji = CssPathPseudoSelector::Lang("de-🎉".into());
        assert!(match_pseudo_selector(&emoji, info(0, false), Some(&emoji), true));
        assert!(!match_pseudo_selector(&emoji, info(0, false), Some(&de), true));
        let empty = CssPathPseudoSelector::Lang("".into());
        assert!(match_pseudo_selector(&empty, info(0, false), Some(&empty), true));
    }
    // ---------------------------------------------------------------------
    // match_attribute_selector
    // ---------------------------------------------------------------------
    #[test]
    fn match_attribute_selector_on_a_node_without_attributes_is_always_false() {
        let nd = NodeData::create_div();
        for op in [
            AttributeMatchOp::Exists,
            AttributeMatchOp::Eq,
            AttributeMatchOp::Includes,
            AttributeMatchOp::DashMatch,
            AttributeMatchOp::Prefix,
            AttributeMatchOp::Suffix,
            AttributeMatchOp::Substring,
        ] {
            assert!(!match_attribute_selector(&attr_sel("data-x", op, Some("v")), &nd));
            assert!(!match_attribute_selector(&attr_sel("", op, None), &nd));
        }
    }
    #[test]
    fn match_attribute_selector_exists_matches_any_value_including_the_empty_one() {
        let nd = node_with_attrs(vec![custom("data-x", "")]);
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Exists, None),
            &nd
        ));
        // Exists ignores the value entirely, even if one is (wrongly) supplied.
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Exists, Some("nonsense")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("data-y", AttributeMatchOp::Exists, None),
            &nd
        ));
    }
    #[test]
    fn match_attribute_selector_operator_without_a_value_never_matches() {
        let nd = node_with_attrs(vec![custom("data-x", "value")]);
        for op in [
            AttributeMatchOp::Eq,
            AttributeMatchOp::Includes,
            AttributeMatchOp::DashMatch,
            AttributeMatchOp::Prefix,
            AttributeMatchOp::Suffix,
            AttributeMatchOp::Substring,
        ] {
            assert!(
                !match_attribute_selector(&attr_sel("data-x", op, None), &nd),
                "{op:?} with a missing target value must be rejected, not matched"
            );
        }
    }
    #[test]
    fn match_attribute_selector_empty_target_never_matches_the_substring_family() {
        // `[x^=""]` / `[x$=""]` / `[x*=""]` / `[x~=""]` would otherwise match
        // every node (every string starts with / contains the empty string).
        let nd = node_with_attrs(vec![custom("data-x", "abc")]);
        for op in [
            AttributeMatchOp::Prefix,
            AttributeMatchOp::Suffix,
            AttributeMatchOp::Substring,
            AttributeMatchOp::Includes,
        ] {
            assert!(
                !match_attribute_selector(&attr_sel("data-x", op, Some("")), &nd),
                "{op:?} with an empty target must not match"
            );
        }
        // Eq is the exception: `[x=""]` legitimately means "the empty value".
        assert!(!match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Eq, Some("")),
            &nd
        ));
        let empty = node_with_attrs(vec![custom("data-x", "")]);
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Eq, Some("")),
            &empty
        ));
    }
    #[test]
    fn match_attribute_selector_includes_matches_one_of_several_class_entries() {
        // Classes are stored as separate `AttributeType::Class` entries, so the
        // matcher has to be "any value satisfies the op" (see the fn doc).
        let mut nd = NodeData::create_div();
        nd.add_class("foo".into());
        nd.add_class("primary".into());
        nd.add_class("bar".into());
        assert!(match_attribute_selector(
            &attr_sel("class", AttributeMatchOp::Includes, Some("primary")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("class", AttributeMatchOp::Includes, Some("prim")),
            &nd
        ));
        // A target containing whitespace is invalid for `~=` and must not match.
        assert!(!match_attribute_selector(
            &attr_sel("class", AttributeMatchOp::Includes, Some("foo bar")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("class", AttributeMatchOp::Includes, Some("\t")),
            &nd
        ));
    }
    #[test]
    fn match_attribute_selector_dash_match_requires_a_dash_boundary() {
        let nd = node_with_attrs(vec![AttributeType::Lang("en-US".into())]);
        assert!(match_attribute_selector(
            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en-US")),
            &nd
        ));
        // A prefix that does not end on the `-` boundary must not match.
        assert!(!match_attribute_selector(
            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en-U")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("e")),
            &nd
        ));
        // `en` must not match the value `english` (no dash boundary).
        let english = node_with_attrs(vec![AttributeType::Lang("english".into())]);
        assert!(!match_attribute_selector(
            &attr_sel("lang", AttributeMatchOp::DashMatch, Some("en")),
            &english
        ));
    }
    #[test]
    fn match_attribute_selector_name_matching_is_exact_and_case_sensitive() {
        let nd = node_with_attrs(vec![custom("data-foo", "v")]);
        assert!(match_attribute_selector(
            &attr_sel("data-foo", AttributeMatchOp::Eq, Some("v")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("data-fo", AttributeMatchOp::Eq, Some("v")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("data-foo2", AttributeMatchOp::Eq, Some("v")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("DATA-FOO", AttributeMatchOp::Eq, Some("v")),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("", AttributeMatchOp::Exists, None),
            &nd
        ));
    }
    #[test]
    fn match_attribute_selector_handles_unicode_values_on_char_boundaries() {
        // "héllo-🎉-世界" has no ASCII 'e' and no ASCII-splittable emoji.
        let nd = node_with_attrs(vec![custom("data-x", "héllo-🎉-世界")]);
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Prefix, Some("hé")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Suffix, Some("世界")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Substring, Some("🎉")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Eq, Some("héllo-🎉-世界")),
            &nd
        ));
        // No false positive from the ASCII byte inside a multi-byte codepoint.
        assert!(!match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::Substring, Some("e")),
            &nd
        ));
        // DashMatch on a unicode segment.
        assert!(match_attribute_selector(
            &attr_sel("data-x", AttributeMatchOp::DashMatch, Some("héllo")),
            &nd
        ));
    }
    #[test]
    fn match_attribute_selector_handles_huge_values() {
        let huge = "ä".repeat(100_000);
        let value = alloc::format!("{huge}-tail");
        let nd = node_with_attrs(vec![custom("data-big", &value)]);
        assert!(match_attribute_selector(
            &attr_sel("data-big", AttributeMatchOp::Suffix, Some("-tail")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("data-big", AttributeMatchOp::Prefix, Some("ää")),
            &nd
        ));
        assert!(match_attribute_selector(
            &attr_sel("data-big", AttributeMatchOp::DashMatch, Some(huge.as_str())),
            &nd
        ));
        assert!(!match_attribute_selector(
            &attr_sel("data-big", AttributeMatchOp::Eq, Some(huge.as_str())),
            &nd
        ));
    }
    // ---------------------------------------------------------------------
    // match_single_selector / selector_group_matches
    // ---------------------------------------------------------------------
    #[test]
    fn match_single_selector_global_matches_elements_but_never_text_nodes() {
        let div = NodeData::create_div();
        let text = NodeData::create_text_do_not_use_without_block_level_wrapper("hello");
        assert!(match_single_selector(
            &CssPathSelector::Global,
            info(0, false),
            &div,
            NodeId::new(0),
            None,
            true
        , false));
        // Per CSS, `*` matches ELEMENTS only. Text nodes take styling by
        // inheritance; a universal rule hitting them directly overwrote
        // freshly inherited values (`* { color }` vs `p { color }`).
        assert!(!match_single_selector(
            &CssPathSelector::Global,
            info(u32::MAX, true),
            &text,
            NodeId::new(usize::MAX),
            None,
            false
        , false));
    }
    #[test]
    fn global_matches_a_text_node_only_when_the_rule_is_scoped_to_exactly_it() {
        // Inline-style semantics (miniword ENGINE-ISSUE 4):
        // `create_text_do_not_use_without_block_level_wrapper("x").with_css("color: white")` produces
        // `[Root(n..=n), Global]` — the author addressed THIS node, so the
        // universal selector must match despite it being a text node.
        let text = NodeData::create_text_do_not_use_without_block_level_wrapper("hello");
        let nid = NodeId::new(7);
        let self_scope = CssPathSelector::Root(CssScopeRange { start: 7, end: 7 });
        let global = CssPathSelector::Global;
        let group: Vec<&CssPathSelector> = vec![&self_scope, &global];
        assert!(
            selector_group_matches(&group, info(0, true), &text, nid, None, true),
            "a node-only-scoped bare-decl rule must style its own text node"
        );
        // Negative control 1: the SAME group on a DIFFERENT text node (the
        // scope range excludes it) must not match.
        assert!(
            !selector_group_matches(&group, info(0, true), &text, NodeId::new(8), None, true),
            "node-only scope must not leak to other text nodes"
        );
        // Negative control 2: a SUBTREE-scoped `* { }` (stylesheet semantics,
        // start != end) keeps refusing text nodes — the historical
        // `* { color: #666 }` inheritance-overwrite bug must stay fixed.
        let subtree_scope = CssPathSelector::Root(CssScopeRange { start: 0, end: 20 });
        let group2: Vec<&CssPathSelector> = vec![&subtree_scope, &global];
        assert!(
            !selector_group_matches(&group2, info(0, true), &text, nid, None, true),
            "subtree-scoped universal selectors must never match text nodes"
        );
        // Negative control 3: an UNSCOPED bare `*` refuses text nodes too.
        let group3: Vec<&CssPathSelector> = vec![&global];
        assert!(
            !selector_group_matches(&group3, info(0, true), &text, nid, None, true)
        );
    }
    #[test]
    fn match_single_selector_never_matches_a_combinator() {
        // Combinators must be split out by the group iterator; if one ever
        // reaches the matcher it must fail closed, not match everything.
        let div = NodeData::create_div();
        for c in [
            CssPathSelector::DirectChildren,
            CssPathSelector::Children,
            CssPathSelector::AdjacentSibling,
            CssPathSelector::GeneralSibling,
        ] {
            assert!(!match_single_selector(
                &c,
                info(0, true),
                &div,
                NodeId::new(0),
                None,
                true
            , false));
        }
    }
    #[test]
    fn match_single_selector_root_scope_range_is_inclusive_on_both_ends() {
        let div = NodeData::create_div();
        let sel = CssPathSelector::Root(CssScopeRange { start: 2, end: 4 });
        for id in [2usize, 3, 4] {
            assert!(match_single_selector(
                &sel,
                info(0, false),
                &div,
                NodeId::new(id),
                None,
                true
            , false));
        }
        for id in [0usize, 1, 5, usize::MAX] {
            assert!(!match_single_selector(
                &sel,
                info(0, false),
                &div,
                NodeId::new(id),
                None,
                true
            , false));
        }
    }
    #[test]
    fn match_single_selector_root_scope_with_an_inverted_or_full_range() {
        let div = NodeData::create_div();
        // Inverted range (start > end) matches nothing, and must not panic.
        let inverted = CssPathSelector::Root(CssScopeRange { start: 9, end: 2 });
        for id in [0usize, 2, 9, usize::MAX] {
            assert!(!match_single_selector(
                &inverted,
                info(0, false),
                &div,
                NodeId::new(id),
                None,
                true
            , false));
        }
        // Full range matches every node id, including usize::MAX.
        let full = CssPathSelector::Root(CssScopeRange {
            start: 0,
            end: usize::MAX,
        });
        for id in [0usize, 1, usize::MAX] {
            assert!(match_single_selector(
                &full,
                info(0, false),
                &div,
                NodeId::new(id),
                None,
                true
            , false));
        }
    }
    #[test]
    fn match_single_selector_type_class_and_id() {
        let mut nd = div_with(Some("first"), Some("a"));
        nd.add_class("日本語-🎉".into());
        let hit = |s: &CssPathSelector| {
            match_single_selector(s, info(0, false), &nd, NodeId::new(0), None, true, false)
        };
        assert!(hit(&CssPathSelector::Type(NodeTypeTag::Div)));
        assert!(!hit(&CssPathSelector::Type(NodeTypeTag::P)));
        assert!(hit(&CssPathSelector::Class("a".into())));
        assert!(hit(&CssPathSelector::Class("日本語-🎉".into())));
        assert!(!hit(&CssPathSelector::Class("日本語".into())), "no prefix matching");
        assert!(!hit(&CssPathSelector::Class("".into())));
        assert!(hit(&CssPathSelector::Id("first".into())));
        assert!(!hit(&CssPathSelector::Id("firs".into())));
        // Ids and classes must not cross over.
        assert!(!hit(&CssPathSelector::Class("first".into())));
        assert!(!hit(&CssPathSelector::Id("a".into())));
    }
    #[test]
    fn selector_group_matches_requires_every_selector_in_the_group() {
        let nd = div_with(Some("first"), Some("a"));
        let div = CssPathSelector::Type(NodeTypeTag::Div);
        let class_a = CssPathSelector::Class("a".into());
        let class_z = CssPathSelector::Class("zzz".into());
        let group: Vec<&CssPathSelector> = vec![&div, &class_a];
        assert!(selector_group_matches(
            &group,
            info(0, false),
            &nd,
            NodeId::new(0),
            None,
            true
        ));
        let group: Vec<&CssPathSelector> = vec![&div, &class_a, &class_z];
        assert!(!selector_group_matches(
            &group,
            info(0, false),
            &nd,
            NodeId::new(0),
            None,
            true
        ));
    }
    #[test]
    fn selector_group_matches_empty_group_matches_vacuously() {
        // This is what a dangling combinator (`.foo >`) produces — see
        // `css_group_iterator_yields_an_empty_group_for_a_trailing_combinator`.
        // `all()` over an empty group is `true`, so such a group matches ANY node.
        let empty: Vec<&CssPathSelector> = Vec::new();
        assert!(selector_group_matches(
            &empty,
            info(0, false),
            &NodeData::create_div(),
            NodeId::new(0),
            None,
            true
        ));
    }
    // ---------------------------------------------------------------------
    // find_non_anonymous_parent / find_non_anonymous_prev_sibling
    // ---------------------------------------------------------------------
    /// ```text
    /// 0 body
    /// ├── 1 <anonymous>
    /// │   └── 2 <anonymous>
    /// │       └── 3 div        <- parent chain must skip 1 and 2
    /// ├── 4 <anonymous>
    /// └── 5 div                <- prev-sibling chain must skip 4 and 1
    /// ```
    fn anonymous_fixture() -> (Vec<Node>, Vec<NodeData>) {
        let hierarchy = vec![
            node(None, None, None, Some(5)),
            node(Some(0), None, Some(4), Some(2)),
            node(Some(1), None, None, Some(3)),
            node(Some(2), None, None, None),
            node(Some(0), Some(1), Some(5), None),
            node(Some(0), Some(4), None, None),
        ];
        let mut anon1 = NodeData::create_div();
        anon1.set_anonymous(true);
        let mut anon2 = NodeData::create_div();
        anon2.set_anonymous(true);
        let mut anon4 = NodeData::create_div();
        anon4.set_anonymous(true);
        let data = vec![
            NodeData::create_body(),
            anon1,
            anon2,
            div_with(None, Some("deep")),
            anon4,
            div_with(None, Some("c")),
        ];
        (hierarchy, data)
    }
    #[test]
    fn find_non_anonymous_parent_skips_a_chain_of_anonymous_boxes() {
        let (hierarchy, data) = anonymous_fixture();
        let hier_items = items(&hierarchy);
        let h = NodeDataContainerRef::from_slice(&hier_items);
        let d = NodeDataContainerRef::from_slice(&data);
        // node 3's real parent is the body (0), not the anonymous 2 / 1.
        assert_eq!(
            find_non_anonymous_parent(NodeId::new(3), &h, &d),
            Some(NodeId::new(0))
        );
        // node 5's parent is the body directly.
        assert_eq!(
            find_non_anonymous_parent(NodeId::new(5), &h, &d),
            Some(NodeId::new(0))
        );
        // the root has no parent at all.
        assert_eq!(find_non_anonymous_parent(NodeId::new(0), &h, &d), None);
    }
    #[test]
    fn find_non_anonymous_parent_returns_none_when_every_ancestor_is_anonymous() {
        // 0 <anonymous root> -> 1 div
        let hierarchy = vec![node(None, None, None, Some(1)), node(Some(0), None, None, None)];
        let mut anon_root = NodeData::create_div();
        anon_root.set_anonymous(true);
        let data = vec![anon_root, NodeData::create_div()];
        let hier_items = items(&hierarchy);
        let h = NodeDataContainerRef::from_slice(&hier_items);
        let d = NodeDataContainerRef::from_slice(&data);
        assert_eq!(find_non_anonymous_parent(NodeId::new(1), &h, &d), None);
    }
    #[test]
    fn find_non_anonymous_prev_sibling_skips_anonymous_siblings() {
        let (hierarchy, data) = anonymous_fixture();
        let hier_items = items(&hierarchy);
        let h = NodeDataContainerRef::from_slice(&hier_items);
        let d = NodeDataContainerRef::from_slice(&data);
        // node 5's previous siblings are 4 (anonymous) and 1 (anonymous), so
        // there is no non-anonymous previous sibling.
        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(5), &h, &d), None);
        // a first child has no previous sibling.
        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(1), &h, &d), None);
        assert_eq!(find_non_anonymous_prev_sibling(NodeId::new(0), &h, &d), None);
    }
    #[test]
    fn find_non_anonymous_prev_sibling_returns_the_nearest_real_sibling() {
        let hierarchy = sample_hierarchy();
        let data = sample_node_data();
        let hier_items = items(&hierarchy);
        let h = NodeDataContainerRef::from_slice(&hier_items);
        let d = NodeDataContainerRef::from_slice(&data);
        // The text node (2) between div.a (1) and div.b (3) is SKIPPED — sibling
        // combinators target elements only — so the previous element sibling of node 3
        // is div.a (1), not the text node. See
        // `matches_html_element_adjacent_sibling_skips_text_nodes`.
        assert_eq!(
            find_non_anonymous_prev_sibling(NodeId::new(3), &h, &d),
            Some(NodeId::new(1))
        );
        assert_eq!(
            find_non_anonymous_prev_sibling(NodeId::new(5), &h, &d),
            Some(NodeId::new(3))
        );
    }
    // ---------------------------------------------------------------------
    // construct_html_cascade_tree
    // ---------------------------------------------------------------------
    #[test]
    fn construct_html_cascade_tree_on_an_empty_hierarchy_is_empty() {
        let hierarchy: Vec<Node> = Vec::new();
        let data: Vec<NodeData> = Vec::new();
        let out = construct_html_cascade_tree(
            &NodeHierarchyRef::from_slice(&hierarchy),
            &[],
            &NodeDataContainerRef::from_slice(&data),
        );
        assert_eq!(out.len(), 0);
        assert!(out.is_empty());
    }
    #[test]
    fn construct_html_cascade_tree_with_no_parents_defaults_every_node() {
        // A single childless root is a LEAF, so `get_parents_sorted_by_depth`
        // returns nothing and every entry keeps the zeroed default.
        let hierarchy = vec![node(None, None, None, None)];
        let data = vec![NodeData::create_body()];
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        assert!(depths.is_empty());
        let out = construct_html_cascade_tree(
            &hierarchy_ref,
            &depths,
            &NodeDataContainerRef::from_slice(&data),
        );
        assert_eq!(out.len(), 1);
        assert_eq!(out.internal[0], CascadeInfo::default());
    }
    #[test]
    fn construct_html_cascade_tree_does_not_count_text_nodes_as_element_siblings() {
        let hierarchy = sample_hierarchy();
        let data = sample_node_data();
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let data_ref = NodeDataContainerRef::from_slice(&data);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
        assert_eq!(out.len(), hierarchy.len(), "one CascadeInfo per node");
        assert_eq!(out.internal[0], info(0, true), "root");
        assert_eq!(out.internal[1], info(0, false), "div#first.a — 1st element child");
        assert_eq!(out.internal[3], info(1, false), "div.b — 2nd element child (text skipped)");
        assert_eq!(out.internal[4], info(0, true), "p.inner — only child of div.b");
        assert_eq!(
            out.internal[5],
            info(2, true),
            "div.c — 3rd element child and the last one"
        );
        // The text node itself is never the last child and is not an element.
        assert!(!out.internal[2].is_last_child);
    }
    #[test]
    fn construct_html_cascade_tree_ignores_trailing_text_nodes_for_is_last_child() {
        // 0 body -> [1 div, 2 "text", 3 "text"]  => div is still the LAST element child.
        let hierarchy = vec![
            node(None, None, None, Some(3)),
            node(Some(0), None, Some(2), None),
            node(Some(0), Some(1), Some(3), None),
            node(Some(0), Some(2), None, None),
        ];
        let data = vec![
            NodeData::create_body(),
            NodeData::create_div(),
            NodeData::create_text_do_not_use_without_block_level_wrapper("a"),
            NodeData::create_text_do_not_use_without_block_level_wrapper("b"),
        ];
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let data_ref = NodeDataContainerRef::from_slice(&data);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
        assert_eq!(out.internal[1], info(0, true), "trailing text must not un-last the div");
    }
    #[test]
    fn construct_html_cascade_tree_handles_a_wide_tree() {
        const CHILDREN: usize = 2_000;
        let mut hierarchy = vec![node(None, None, None, Some(CHILDREN))];
        let mut data = vec![NodeData::create_body()];
        for i in 1..=CHILDREN {
            hierarchy.push(node(
                Some(0),
                if i == 1 { None } else { Some(i - 1) },
                if i == CHILDREN { None } else { Some(i + 1) },
                None,
            ));
            data.push(NodeData::create_div());
        }
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let data_ref = NodeDataContainerRef::from_slice(&data);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
        assert_eq!(out.len(), CHILDREN + 1);
        for i in 1..=CHILDREN {
            let expected = info(u32::try_from(i - 1).unwrap(), i == CHILDREN);
            assert_eq!(out.internal[i], expected, "child {i}");
        }
    }
    #[test]
    fn construct_html_cascade_tree_handles_a_deep_chain() {
        const DEPTH: usize = 1_000;
        let mut hierarchy = Vec::with_capacity(DEPTH);
        let mut data = Vec::with_capacity(DEPTH);
        for i in 0..DEPTH {
            hierarchy.push(node(
                if i == 0 { None } else { Some(i - 1) },
                None,
                None,
                if i + 1 == DEPTH { None } else { Some(i + 1) },
            ));
            data.push(NodeData::create_div());
        }
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let data_ref = NodeDataContainerRef::from_slice(&data);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        let out = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
        assert_eq!(out.len(), DEPTH);
        for i in 0..DEPTH {
            assert_eq!(
                out.internal[i],
                info(0, true),
                "every node in a chain is an only child"
            );
        }
    }
    // ---------------------------------------------------------------------
    // matches_html_element
    // ---------------------------------------------------------------------
    #[test]
    fn matches_html_element_empty_path_never_matches() {
        assert!(!matches(Vec::new(), 1, None));
        assert!(!matches(Vec::new(), 0, Some(CssPathPseudoSelector::Hover)));
    }
    #[test]
    fn matches_html_element_matches_type_class_and_id_on_the_subject() {
        assert!(matches(vec![CssPathSelector::Global], 1, None));
        assert!(matches(vec![CssPathSelector::Class("a".into())], 1, None));
        assert!(!matches(vec![CssPathSelector::Class("a".into())], 3, None));
        assert!(matches(vec![CssPathSelector::Id("first".into())], 1, None));
        assert!(matches(vec![CssPathSelector::Type(NodeTypeTag::Div)], 1, None));
        assert!(!matches(vec![CssPathSelector::Type(NodeTypeTag::Div)], 0, None));
        assert!(matches(vec![CssPathSelector::Type(NodeTypeTag::Body)], 0, None));
        // Compound group: `div.a` matches node 1 but not node 3 (`div.b`).
        let div_a = vec![
            CssPathSelector::Type(NodeTypeTag::Div),
            CssPathSelector::Class("a".into()),
        ];
        assert!(matches(div_a.clone(), 1, None));
        assert!(!matches(div_a, 3, None));
    }
    #[test]
    fn matches_html_element_never_matches_an_anonymous_node() {
        let (hierarchy, data) = anonymous_fixture();
        let hier_items = items(&hierarchy);
        let hierarchy_ref = NodeHierarchyRef::from_slice(&hierarchy);
        let data_ref = NodeDataContainerRef::from_slice(&data);
        let depths = hierarchy_ref.get_parents_sorted_by_depth();
        let cascade = construct_html_cascade_tree(&hierarchy_ref, &depths, &data_ref);
        // `*` matches everything EXCEPT the anonymous boxes (1, 2, 4).
        for id in [1usize, 2, 4] {
            assert!(
                !matches_html_element(
                    &CssPath::new(vec![CssPathSelector::Global]),
                    NodeId::new(id),
                    &NodeDataContainerRef::from_slice(&hier_items),
                    &data_ref,
                    &cascade.as_ref(),
                    None,
                ),
                "anonymous node {id} must not be styled"
            );
        }
        for id in [0usize, 3, 5] {
            assert!(matches_html_element(
                &CssPath::new(vec![CssPathSelector::Global]),
                NodeId::new(id),
                &NodeDataContainerRef::from_slice(&hier_items),
                &data_ref,
                &cascade.as_ref(),
                None,
            ));
        }
    }
    #[test]
    fn matches_html_element_child_combinator_is_stricter_than_the_descendant_one() {
        // `body > p` must NOT match p.inner (its parent is div.b) ...
        let child = vec![
            CssPathSelector::Type(NodeTypeTag::Body),
            CssPathSelector::DirectChildren,
            CssPathSelector::Type(NodeTypeTag::P),
        ];
        assert!(!matches(child, 4, None));
        // ... but `body p` must.
        let descendant = vec![
            CssPathSelector::Type(NodeTypeTag::Body),
            CssPathSelector::Children,
            CssPathSelector::Type(NodeTypeTag::P),
        ];
        assert!(matches(descendant, 4, None));
        // `body > div.b` is a direct child.
        let direct = vec![
            CssPathSelector::Type(NodeTypeTag::Body),
            CssPathSelector::DirectChildren,
            CssPathSelector::Type(NodeTypeTag::Div),
            CssPathSelector::Class("b".into()),
        ];
        assert!(matches(direct, 3, None));
    }
    #[test]
    fn matches_html_element_descendant_combinator_walks_the_whole_ancestor_chain() {
        // `div.b p.inner` (direct) and `body p.inner` (two levels up).
        let close = vec![
            CssPathSelector::Class("b".into()),
            CssPathSelector::Children,
            CssPathSelector::Class("inner".into()),
        ];
        assert!(matches(close, 4, None));
        // A non-ancestor class must not match, even though it exists in the DOM.
        let unrelated = vec![
            CssPathSelector::Class("c".into()),
            CssPathSelector::Children,
            CssPathSelector::Class("inner".into()),
        ];
        assert!(!matches(unrelated, 4, None));
    }
    #[test]
    fn matches_html_element_general_sibling_scans_all_previous_siblings() {
        // `div.a ~ div.c`: div.c (5) is preceded by div.b (3) and a text node (2),
        // and the scan must keep walking until it reaches div.a (1).
        let general = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::GeneralSibling,
            CssPathSelector::Class("c".into()),
        ];
        assert!(matches(general, 5, None));
        // The subject must come AFTER the sibling: `div.c ~ div.a` must not match.
        let backwards = vec![
            CssPathSelector::Class("c".into()),
            CssPathSelector::GeneralSibling,
            CssPathSelector::Class("a".into()),
        ];
        assert!(!matches(backwards, 1, None));
    }
    #[test]
    fn matches_html_element_adjacent_sibling_matches_the_immediate_element_sibling() {
        // `div.b + div.c`: node 5's immediately preceding sibling IS div.b.
        let adjacent = vec![
            CssPathSelector::Class("b".into()),
            CssPathSelector::AdjacentSibling,
            CssPathSelector::Class("c".into()),
        ];
        assert!(matches(adjacent, 5, None));
        // `div.a + div.c` must not match (div.b sits between them).
        let not_adjacent = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::AdjacentSibling,
            CssPathSelector::Class("c".into()),
        ];
        assert!(!matches(not_adjacent, 5, None));
    }
    /// EXPECTED-RED (genuine bug, see report): `find_non_anonymous_prev_sibling`
    /// only skips *anonymous* nodes, not *non-element* (text) nodes. CSS
    /// Selectors L4 §15.2 defines `E + F` over element siblings only — and
    /// `construct_html_cascade_tree` already excludes text nodes from sibling
    /// indexing (L4 §13) — so `div.a + div.b` must still match across the
    /// intervening text node. Today it returns `false`.
    #[test]
    fn matches_html_element_adjacent_sibling_skips_text_nodes() {
        // `div.a + div.b`, with the text node 2 sitting between them.
        let adjacent = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::AdjacentSibling,
            CssPathSelector::Class("b".into()),
        ];
        assert!(
            matches(adjacent, 3, None),
            "the `+` combinator must ignore non-element (text) siblings"
        );
    }
    #[test]
    fn matches_html_element_hover_on_a_single_group_path_needs_the_expected_ending() {
        let hover = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
        ];
        assert!(matches(hover.clone(), 1, Some(CssPathPseudoSelector::Hover)));
        // Without the expected pseudo state the rule must not apply...
        assert!(!matches(hover.clone(), 1, None));
        // ... and neither must a different state.
        assert!(!matches(hover.clone(), 1, Some(CssPathPseudoSelector::Focus)));
        // ... and it still has to match the rest of the selector.
        assert!(!matches(hover, 3, Some(CssPathPseudoSelector::Hover)));
    }
    /// EXPECTED-RED (genuine bug, see report): `matches_html_element` passes
    /// `is_last_content_group = groups.len() == 1` for the SUBJECT group (the
    /// rightmost one, which the iterator yields first), so an interactive pseudo
    /// on the subject of any multi-group selector — `.container .btn:hover`,
    /// `body > .btn:hover`, … — can never match. `prop_cache` reaches
    /// `matches_html_element` with exactly this shape (`rule_ends_with(path,
    /// Some(Hover))` → `matches_html_element(..., Some(Hover))`), so every
    /// descendant/child `:hover` / `:active` / `:focus` rule is silently dropped.
    #[test]
    fn matches_html_element_hover_on_a_descendant_path_still_matches() {
        // `body .a:hover` — the hover applies to the SUBJECT (node 1).
        let hover_descendant = vec![
            CssPathSelector::Type(NodeTypeTag::Body),
            CssPathSelector::Children,
            CssPathSelector::Class("a".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Hover),
        ];
        assert!(
            matches(hover_descendant, 1, Some(CssPathPseudoSelector::Hover)),
            ":hover on the subject of a multi-group selector must still match"
        );
    }
    #[test]
    fn matches_html_element_structural_pseudos_work_on_the_subject() {
        // div#first.a is the first element child; div.c is the last one.
        let first = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::First),
        ];
        assert!(matches(first, 1, None));
        let last = vec![
            CssPathSelector::Class("c".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::Last),
        ];
        assert!(matches(last, 5, None));
        // div.b is the 2nd element child — the text node must not shift the index.
        let nth = vec![
            CssPathSelector::Class("b".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
                CssNthChildSelector::Number(2),
            )),
        ];
        assert!(matches(nth, 3, None));
        let wrong_nth = vec![
            CssPathSelector::Class("b".into()),
            CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
                CssNthChildSelector::Number(3),
            )),
        ];
        assert!(!matches(wrong_nth, 3, None));
    }
    #[test]
    fn matches_html_element_root_scope_confines_a_rule_to_its_subtree() {
        // `[Root(3..=4), *]` — the #47 scope marker: only div.b and its child.
        let scoped = vec![
            CssPathSelector::Root(CssScopeRange { start: 3, end: 4 }),
            CssPathSelector::Global,
        ];
        assert!(matches(scoped.clone(), 3, None));
        assert!(matches(scoped.clone(), 4, None));
        assert!(!matches(scoped.clone(), 1, None), "must not leak to a sibling");
        assert!(!matches(scoped.clone(), 5, None), "must not leak to a sibling");
        assert!(!matches(scoped, 0, None), "must not leak to the parent");
        // Node-only scope (`[start, start]`) = inline-style semantics.
        let node_only = vec![
            CssPathSelector::Root(CssScopeRange { start: 3, end: 3 }),
            CssPathSelector::Global,
        ];
        assert!(matches(node_only.clone(), 3, None));
        assert!(!matches(node_only, 4, None), "a node-only scope must not reach children");
    }
    #[test]
    fn matches_html_element_with_a_dangling_combinator_does_not_panic() {
        // `.a >` — the iterator yields an empty subject group, which matches
        // vacuously, and then requires an `.a` parent. Node 4's parent is div.b,
        // so this must be false; no panic either way.
        let dangling = vec![
            CssPathSelector::Class("a".into()),
            CssPathSelector::DirectChildren,
        ];
        assert!(!matches(dangling.clone(), 4, None));
        // ... but div.a IS the parent of nothing, so no node matches it.
        for id in 0..6 {
            let _ = matches(dangling.clone(), id, None);
        }
    }
    #[test]
    fn matches_html_element_on_a_very_long_selector_chain_terminates() {
        // 500 `body ...` descendant groups: the ancestor scan must fail fast
        // (there are only 3 levels in the DOM) instead of looping.
        let mut path = Vec::new();
        for _ in 0..500 {
            path.push(CssPathSelector::Type(NodeTypeTag::Body));
            path.push(CssPathSelector::Children);
        }
        path.push(CssPathSelector::Class("inner".into()));
        assert!(!matches(path, 4, None));
    }
}