dom-cat 0.1.0

Persistent DOM model: arena-backed Node tree with mutation API and CSS-selector matching. Consumes html-cat trees; selectors via css-cat. No mut, no Rc/Arc, no interior mutability, no panics, exhaustive matches. Third sub-crate of a Servo-replacement webview runtime targeting Tauri.
//! Selector matching against arena nodes.

use css_cat::{AttrOperator, Combinator, ComplexSelector, CompoundSelector, SimpleSelector};

use crate::document::Document;
use crate::node::{ElementData, Node, NodeId};

/// Whether `node_id` matches `selector` in `doc`.
#[must_use]
pub fn matches(doc: &Document, node_id: NodeId, selector: &ComplexSelector) -> bool {
    doc.get(node_id)
        .and_then(Node::as_element)
        .is_some_and(|_| matches_complex(doc, node_id, selector))
}

fn matches_complex(doc: &Document, node_id: NodeId, selector: &ComplexSelector) -> bool {
    // Match the last compound against `node_id`, then walk leftward
    // through the tail in reverse, satisfying each combinator.
    let tail = selector.tail();
    if tail.is_empty() {
        matches_compound(doc, node_id, selector.head())
    } else {
        let last_compound = tail.last().map_or(selector.head(), |(_, c)| c);
        if matches_compound(doc, node_id, last_compound) {
            walk_combinators_left(doc, node_id, selector, tail.len())
        } else {
            false
        }
    }
}

fn walk_combinators_left(
    doc: &Document,
    current: NodeId,
    selector: &ComplexSelector,
    tail_idx: usize,
) -> bool {
    if tail_idx == 0 {
        // No more combinators to satisfy.
        true
    } else {
        let idx = tail_idx - 1;
        let combinator = selector
            .tail()
            .get(idx)
            .map_or(Combinator::Descendant, |(c, _)| *c);
        let prior_compound = if idx == 0 {
            selector.head()
        } else {
            selector
                .tail()
                .get(idx - 1)
                .map_or(selector.head(), |(_, c)| c)
        };
        find_combinator_match(doc, current, combinator, prior_compound)
            .is_some_and(|next_node| walk_combinators_left(doc, next_node, selector, idx))
    }
}

fn find_combinator_match(
    doc: &Document,
    current: NodeId,
    combinator: Combinator,
    prior: &CompoundSelector,
) -> Option<NodeId> {
    match combinator {
        Combinator::Descendant => find_ancestor(doc, current, prior),
        Combinator::Child => doc
            .get(current)
            .and_then(Node::parent)
            .filter(|p| matches_compound(doc, *p, prior)),
        Combinator::AdjacentSibling => {
            previous_sibling(doc, current).filter(|p| matches_compound(doc, *p, prior))
        }
        Combinator::GeneralSibling => find_previous_sibling_matching(doc, current, prior),
    }
}

fn find_ancestor(doc: &Document, current: NodeId, prior: &CompoundSelector) -> Option<NodeId> {
    doc.get(current).and_then(Node::parent).and_then(|p| {
        if matches_compound(doc, p, prior) {
            Some(p)
        } else {
            find_ancestor(doc, p, prior)
        }
    })
}

fn previous_sibling(doc: &Document, current: NodeId) -> Option<NodeId> {
    let parent = doc.get(current).and_then(Node::parent)?;
    let siblings = doc.get(parent)?.children();
    siblings
        .iter()
        .position(|&id| id == current)
        .and_then(|idx| {
            if idx == 0 {
                None
            } else {
                siblings.get(idx - 1).copied()
            }
        })
}

fn find_previous_sibling_matching(
    doc: &Document,
    current: NodeId,
    prior: &CompoundSelector,
) -> Option<NodeId> {
    previous_sibling(doc, current).and_then(|prev| {
        if matches_compound(doc, prev, prior) {
            Some(prev)
        } else {
            find_previous_sibling_matching(doc, prev, prior)
        }
    })
}

fn matches_compound(doc: &Document, node_id: NodeId, compound: &CompoundSelector) -> bool {
    doc.get(node_id)
        .and_then(Node::as_element)
        .is_some_and(|el| {
            compound
                .parts()
                .iter()
                .all(|simple| matches_simple(doc, node_id, el, simple))
        })
}

fn matches_simple(
    doc: &Document,
    node_id: NodeId,
    el: &ElementData,
    simple: &SimpleSelector,
) -> bool {
    match simple {
        SimpleSelector::Universal => true,
        SimpleSelector::Type(name) => el.name().eq_ignore_ascii_case(name),
        SimpleSelector::Class(name) => el.has_class(name),
        SimpleSelector::Id(name) => el.id().is_some_and(|v| v == name),
        SimpleSelector::Attribute { name, op, value } => {
            match_attribute(el, name, *op, value.as_deref())
        }
        SimpleSelector::PseudoClass { name, argument } => {
            match_pseudo_class(doc, node_id, name, argument.as_deref())
        }
    }
}

fn match_attribute(
    el: &ElementData,
    name: &str,
    op: Option<AttrOperator>,
    value: Option<&str>,
) -> bool {
    el.attribute(name).is_some_and(|present| match (op, value) {
        (None, _) => true,
        (Some(AttrOperator::Equals), Some(v)) => present == v,
        (Some(AttrOperator::Includes), Some(v)) => present.split_ascii_whitespace().any(|w| w == v),
        (Some(AttrOperator::DashMatch), Some(v)) => {
            present == v || present.starts_with(&format!("{v}-"))
        }
        (Some(AttrOperator::Prefix), Some(v)) => present.starts_with(v),
        (Some(AttrOperator::Suffix), Some(v)) => present.ends_with(v),
        (Some(AttrOperator::Substring), Some(v)) => present.contains(v),
        (Some(_), None) => false,
    })
}

fn match_pseudo_class(
    doc: &Document,
    node_id: NodeId,
    name: &str,
    _argument: Option<&str>,
) -> bool {
    match name.to_ascii_lowercase().as_str() {
        // Static pseudo-classes that any non-dynamic engine can answer
        // affirmatively without a real interaction model.  Dynamic ones
        // (hover/focus/active/visited) match nothing in our static DOM.
        "first-child" => is_first_element_child(doc, node_id),
        "last-child" => is_last_element_child(doc, node_id),
        "only-child" => is_first_element_child(doc, node_id) && is_last_element_child(doc, node_id),
        "root" => doc
            .get(node_id)
            .and_then(Node::parent)
            .and_then(|p| doc.get(p))
            .is_some_and(|n| matches!(n, Node::Document(_))),
        _other => false,
    }
}

fn is_first_element_child(doc: &Document, node_id: NodeId) -> bool {
    sibling_position(doc, node_id, true)
}

fn is_last_element_child(doc: &Document, node_id: NodeId) -> bool {
    sibling_position(doc, node_id, false)
}

fn sibling_position(doc: &Document, node_id: NodeId, first: bool) -> bool {
    doc.get(node_id)
        .and_then(Node::parent)
        .and_then(|p| doc.get(p))
        .is_some_and(|parent_node| {
            let element_siblings: Vec<NodeId> = parent_node
                .children()
                .iter()
                .copied()
                .filter(|id| doc.get(*id).is_some_and(|n| matches!(n, Node::Element(_))))
                .collect();
            if first {
                element_siblings.first().copied() == Some(node_id)
            } else {
                element_siblings.last().copied() == Some(node_id)
            }
        })
}