From 9c256d7629903ec1c3575bc5a163a41868ce75c5 Mon Sep 17 00:00:00 2001 From: Mohammad Vatankhah Date: Fri, 30 Oct 2026 09:54:19 +0330 Subject: [PATCH] fix: correct selector and DOM namespace behavior --- matching/src/selectable.rs | 22 +++++++--- python/tests/test_rustlib.py | 84 ++++++++++++++++++++++++++++++++++++ src/tree.rs | 75 +++++++++++++++++++++++--------- treedom/src/interface.rs | 4 +- 4 files changed, 157 insertions(+), 28 deletions(-) diff --git a/matching/src/selectable.rs b/matching/src/selectable.rs index 5ef0960..31795bb 100644 --- a/matching/src/selectable.rs +++ b/matching/src/selectable.rs @@ -140,7 +140,14 @@ impl selectors::Element for CssNodeRef<'_> { } fn is_link(&self) -> bool { - &self.0.value().element().unwrap().name.local == "link" + let element = self.0.value().element().unwrap(); + + element.name.ns == ns!(html) + && matches!(&*element.name.local, "a" | "area" | "link") + && element + .attrs + .iter() + .any(|(name, _)| name.ns.is_empty() && &*name.local == "href") } fn is_html_slot_element(&self) -> bool { @@ -181,14 +188,19 @@ impl selectors::Element for CssNodeRef<'_> { } fn is_empty(&self) -> bool { - !self.0.children().any(|x| { - let v = x.value(); - v.is_element() || v.is_text() + !self.0.children().any(|child| { + let value = child.value(); + value.is_element() + || value + .text() + .is_some_and(|text| !text.contents.is_empty()) }) } fn is_root(&self) -> bool { - self.0.value().is_document() + self.0 + .parent() + .is_some_and(|parent| parent.value().is_document()) } fn apply_selector_flags(&self, _flags: selectors::matching::ElementSelectorFlags) {} diff --git a/python/tests/test_rustlib.py b/python/tests/test_rustlib.py index 5a2cf63..235200c 100644 --- a/python/tests/test_rustlib.py +++ b/python/tests/test_rustlib.py @@ -638,3 +638,87 @@ def test_iter_submodule(): for name in rl.iter.__all__: assert getattr(rl.iter, name).__module__ == "markupever._rustlib.iter", name + + + +def test_select_pseudo_classes(): + parser = rl.Parser(rl.HtmlOptions()) + parser.process( + '' + '' + '' + ) + parser.finish() + dom = parser.into_dom() + + def selected_ids(expression): + return [ + _get_attr(node.attrs, "id")[1] + for node in rl.Select(dom.root(), expression) + ] + + assert selected_ids(":root") == ["document-root"] + assert selected_ids(":link") == ["stylesheet", "anchor", "area"] + assert selected_ids("#plain-anchor:link") == [] + + body = next(iter(rl.Select(dom.root(), "body"))) + + empty_text = rl.Element( + dom, rl.QualName("div", "html"), [("id", "empty-text")], False, False + ) + comment_only = rl.Element( + dom, rl.QualName("div", "html"), [("id", "comment-only")], False, False + ) + whitespace = rl.Element( + dom, rl.QualName("div", "html"), [("id", "whitespace")], False, False + ) + + for element in (empty_text, comment_only, whitespace): + dom.append(body, element) + + dom.append(empty_text, rl.Text(dom, "")) + dom.append(comment_only, rl.Comment(dom, "ignored")) + dom.append(whitespace, rl.Text(dom, " ")) + + assert selected_ids("#empty-text:empty") == ["empty-text"] + assert selected_ids("#comment-only:empty") == ["comment-only"] + assert selected_ids("#whitespace:empty") == [] + + +def test_unqualified_id_and_class_selectors_ignore_namespaced_attributes(): + dom = rl.TreeDom(namespaces={"ns": "urn:example"}) + element = rl.Element( + dom, + "item", + [ + (rl.QualName("id", "urn:example", "ns"), "namespaced-id"), + ("id", "plain-id"), + (rl.QualName("class", "urn:example", "ns"), "namespaced-class"), + ("class", "plain-class"), + ], + False, + False, + ) + dom.append(dom.root(), element) + + assert len(list(rl.Select(dom.root(), "#plain-id"))) == 1 + assert len(list(rl.Select(dom.root(), "#namespaced-id"))) == 0 + assert len(list(rl.Select(dom.root(), ".plain-class"))) == 1 + assert len(list(rl.Select(dom.root(), ".namespaced-class"))) == 0 + assert len(list(rl.Select(dom.root(), "[ns|id]"))) == 1 + + +def test_detaching_a_node_preserves_namespaces_still_in_use(): + dom = rl.TreeDom() + first = rl.Element(dom, rl.QualName("first", "urn:example", "ns"), [], False, False) + second = rl.Element(dom, rl.QualName("second", "urn:example", "ns"), [], False, False) + + dom.append(dom.root(), first) + dom.append(dom.root(), second) + assert dom.namespaces() == {"ns": "urn:example"} + + dom.detach(first) + assert dom.namespaces() == {"ns": "urn:example"} + + dom.detach(second) + assert dom.namespaces() == {} diff --git a/src/tree.rs b/src/tree.rs index 4e5501f..a9f07f5 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -18,6 +18,24 @@ impl PyTreeDom { Self { dom } } + #[inline] + fn same_contents(&self, other: &Self) -> bool { + if Arc::ptr_eq(&self.dom, &other.dom) { + return true; + } + + // Lock the two DOMs in a stable order so opposite comparisons cannot deadlock. + let self_address = Arc::as_ptr(&self.dom) as usize; + let other_address = Arc::as_ptr(&other.dom) as usize; + let (first, second) = if self_address < other_address { + (self.dom.lock(), other.dom.lock()) + } else { + (other.dom.lock(), self.dom.lock()) + }; + + *first == *second + } + #[inline] fn add_new_namespace( &self, @@ -46,12 +64,41 @@ impl PyTreeDom { mut lock: ::parking_lot::MutexGuard<'_, ::treedom::IDTreeDOM>, id: ::treedom::NodeId, ) { - let child = lock.get(id).unwrap(); - - if let Some(elem) = child.value().element() - && let Some(prefix) = elem.name.prefix.clone() + let mut detached_prefixes = Vec::new(); { - lock.namespaces_mut().remove(&prefix); + let detached = lock.get(id).unwrap(); + if let Some(element) = detached.value().element() + && let Some(prefix) = element.name.prefix.clone() + { + detached_prefixes.push(prefix); + } + + for descendant in detached.descendants() { + if let Some(element) = descendant.value().element() + && let Some(prefix) = element.name.prefix.clone() + && !detached_prefixes.contains(&prefix) + { + detached_prefixes.push(prefix); + } + } + } + + let active_namespaces = { + let root = lock.root(); + root.descendants() + .filter_map(|node| { + let element = node.value().element()?; + Some((element.name.prefix.clone()?, element.name.ns.clone())) + }) + .collect::>() + }; + + for prefix in detached_prefixes { + if let Some(namespace) = active_namespaces.get(&prefix) { + lock.namespaces_mut().insert(prefix, namespace.clone()); + } else { + lock.namespaces_mut().remove(&prefix); + } } } } @@ -420,14 +467,7 @@ impl PyTreeDom { Err(_) => return Ok(false), }; - if Arc::ptr_eq(&self_.get().dom, &other.dom) { - Ok(true) - } else { - let t1 = self_.get().dom.lock(); - let t2 = other.dom.lock(); - - Ok(*t1 == *t2) - } + Ok(self_.get().same_contents(&other)) } pyo3::basic::CompareOp::Ne => { let other = match other.extract::>(self_.py()) { @@ -435,14 +475,7 @@ impl PyTreeDom { Err(_) => return Ok(false), }; - if Arc::ptr_eq(&self_.get().dom, &other.dom) { - Ok(false) - } else { - let t1 = self_.get().dom.lock(); - let t2 = other.dom.lock(); - - Ok(*t1 != *t2) - } + Ok(!self_.get().same_contents(&other)) } pyo3::basic::CompareOp::Gt => { crate::nodes::create_richcmp_notimplemented!('>', self_) diff --git a/treedom/src/interface.rs b/treedom/src/interface.rs index 83d99c8..fcda6cd 100644 --- a/treedom/src/interface.rs +++ b/treedom/src/interface.rs @@ -159,7 +159,7 @@ impl ElementAttributes { .get_or_init(|| { self.list .iter() - .find(|(name, _)| name == "id") + .find(|(name, _)| name.ns.is_empty() && &*name.local == "id") .map(|(_, value)| value.clone()) }) .as_deref() @@ -171,7 +171,7 @@ impl ElementAttributes { let mut classes = self .list .iter() - .filter(|(name, _)| &*name.local == "class") + .filter(|(name, _)| name.ns.is_empty() && &*name.local == "class") .flat_map(|(_, value)| { value .split_ascii_whitespace()