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::