Initial Release
This commit is contained in:
1 parent
2e5d63cc35
commit
8749725b45
52 files changed
+19804
No files matched your search
@@ -0,0 +1,641 @@
|
||||
use cssparser::{CowRcStr, ToCss};
|
||||
use html5ever::{LocalName, Namespace};
|
||||
use precomputed_hash::PrecomputedHash;
|
||||
use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
|
||||
use selectors::bloom::BloomFilter;
|
||||
use selectors::context::QuirksMode;
|
||||
use selectors::matching::{
|
||||
ElementSelectorFlags, MatchingContext, MatchingForInvalidation, MatchingMode,
|
||||
NeedsSelectorFlags,
|
||||
};
|
||||
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
||||
use selectors::{Element, OpaqueElement, SelectorList};
|
||||
use selectors::visitor::SelectorVisitor;
|
||||
|
||||
use crate::tree::{DomTree, NodeData, NodeId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObscuraSelector;
|
||||
|
||||
impl parser::SelectorImpl for ObscuraSelector {
|
||||
type ExtraMatchingData<'a> = ();
|
||||
type AttrValue = CssString;
|
||||
type Identifier = CssString;
|
||||
type LocalName = CssLocalName;
|
||||
type NamespaceUrl = CssNamespace;
|
||||
type NamespacePrefix = CssString;
|
||||
type BorrowedLocalName = CssLocalName;
|
||||
type BorrowedNamespaceUrl = CssNamespace;
|
||||
type NonTSPseudoClass = PseudoClass;
|
||||
type PseudoElement = PseudoElement;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CssString(pub String);
|
||||
|
||||
impl<'a> From<&'a str> for CssString {
|
||||
fn from(s: &'a str) -> Self {
|
||||
CssString(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for CssString {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToCss for CssString {
|
||||
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
||||
cssparser::serialize_string(&self.0, dest)
|
||||
}
|
||||
}
|
||||
|
||||
impl PrecomputedHash for CssString {
|
||||
fn precomputed_hash(&self) -> u32 {
|
||||
let mut h: u32 = 5381;
|
||||
for b in self.0.as_bytes() {
|
||||
h = h.wrapping_mul(33).wrapping_add(*b as u32);
|
||||
}
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CssString {
|
||||
fn default() -> Self {
|
||||
CssString(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CssLocalName(pub LocalName);
|
||||
|
||||
impl<'a> From<&'a str> for CssLocalName {
|
||||
fn from(s: &'a str) -> Self {
|
||||
CssLocalName(LocalName::from(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToCss for CssLocalName {
|
||||
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
||||
dest.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PrecomputedHash for CssLocalName {
|
||||
fn precomputed_hash(&self) -> u32 {
|
||||
self.0.precomputed_hash()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
||||
pub struct CssNamespace(pub Namespace);
|
||||
|
||||
impl PrecomputedHash for CssNamespace {
|
||||
fn precomputed_hash(&self) -> u32 {
|
||||
self.0.precomputed_hash()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PseudoClass {
|
||||
Hover,
|
||||
Active,
|
||||
Focus,
|
||||
Enabled,
|
||||
Disabled,
|
||||
Checked,
|
||||
}
|
||||
|
||||
impl parser::NonTSPseudoClass for PseudoClass {
|
||||
type Impl = ObscuraSelector;
|
||||
|
||||
fn is_active_or_hover(&self) -> bool {
|
||||
matches!(self, PseudoClass::Hover | PseudoClass::Active)
|
||||
}
|
||||
|
||||
fn is_user_action_state(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PseudoClass::Hover | PseudoClass::Active | PseudoClass::Focus
|
||||
)
|
||||
}
|
||||
|
||||
fn visit<V>(&self, _visitor: &mut V) -> bool
|
||||
where
|
||||
V: SelectorVisitor<Impl = Self::Impl>,
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl ToCss for PseudoClass {
|
||||
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
||||
match self {
|
||||
PseudoClass::Hover => dest.write_str(":hover"),
|
||||
PseudoClass::Active => dest.write_str(":active"),
|
||||
PseudoClass::Focus => dest.write_str(":focus"),
|
||||
PseudoClass::Enabled => dest.write_str(":enabled"),
|
||||
PseudoClass::Disabled => dest.write_str(":disabled"),
|
||||
PseudoClass::Checked => dest.write_str(":checked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PseudoElement {
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
|
||||
impl parser::PseudoElement for PseudoElement {
|
||||
type Impl = ObscuraSelector;
|
||||
}
|
||||
|
||||
impl ToCss for PseudoElement {
|
||||
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
||||
match self {
|
||||
PseudoElement::Before => dest.write_str("::before"),
|
||||
PseudoElement::After => dest.write_str("::after"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ObscuraSelectorParser;
|
||||
|
||||
impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
|
||||
type Impl = ObscuraSelector;
|
||||
type Error = SelectorParseErrorKind<'i>;
|
||||
|
||||
fn parse_non_ts_pseudo_class(
|
||||
&self,
|
||||
_location: cssparser::SourceLocation,
|
||||
name: CowRcStr<'i>,
|
||||
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
|
||||
match name.as_ref() {
|
||||
"hover" => Ok(PseudoClass::Hover),
|
||||
"active" => Ok(PseudoClass::Active),
|
||||
"focus" => Ok(PseudoClass::Focus),
|
||||
"enabled" => Ok(PseudoClass::Enabled),
|
||||
"disabled" => Ok(PseudoClass::Disabled),
|
||||
"checked" => Ok(PseudoClass::Checked),
|
||||
_ => Err(cssparser::ParseError {
|
||||
kind: cssparser::ParseErrorKind::Custom(
|
||||
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
|
||||
),
|
||||
location: _location,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DomElement<'a> {
|
||||
pub tree: &'a DomTree,
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
impl<'a> DomElement<'a> {
|
||||
pub fn new(tree: &'a DomTree, node_id: NodeId) -> Self {
|
||||
DomElement { tree, node_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::fmt::Debug for DomElement<'a> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "DomElement({:?})", self.node_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> PartialEq for DomElement<'a> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.node_id == other.node_id
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Eq for DomElement<'a> {}
|
||||
|
||||
impl<'a> Element for DomElement<'a> {
|
||||
type Impl = ObscuraSelector;
|
||||
|
||||
fn opaque(&self) -> OpaqueElement {
|
||||
OpaqueElement::new(self)
|
||||
}
|
||||
|
||||
fn parent_element(&self) -> Option<Self> {
|
||||
let node = self.tree.get_node(self.node_id)?;
|
||||
let parent_id = node.parent?;
|
||||
let parent = self.tree.get_node(parent_id)?;
|
||||
if parent.is_element() {
|
||||
Some(DomElement::new(self.tree, parent_id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_node_is_shadow_root(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn containing_shadow_host(&self) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
fn pseudo_element_originating_element(&self) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_pseudo_element(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn prev_sibling_element(&self) -> Option<Self> {
|
||||
let node = self.tree.get_node(self.node_id)?;
|
||||
let mut current = node.prev_sibling;
|
||||
while let Some(sibling_id) = current {
|
||||
let sibling = self.tree.get_node(sibling_id)?;
|
||||
if sibling.is_element() {
|
||||
return Some(DomElement::new(self.tree, sibling_id));
|
||||
}
|
||||
current = sibling.prev_sibling;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_sibling_element(&self) -> Option<Self> {
|
||||
let node = self.tree.get_node(self.node_id)?;
|
||||
let mut current = node.next_sibling;
|
||||
while let Some(sibling_id) = current {
|
||||
let sibling = self.tree.get_node(sibling_id)?;
|
||||
if sibling.is_element() {
|
||||
return Some(DomElement::new(self.tree, sibling_id));
|
||||
}
|
||||
current = sibling.next_sibling;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn first_element_child(&self) -> Option<Self> {
|
||||
let node = self.tree.get_node(self.node_id)?;
|
||||
let mut current = node.first_child;
|
||||
while let Some(child_id) = current {
|
||||
let child = self.tree.get_node(child_id)?;
|
||||
if child.is_element() {
|
||||
return Some(DomElement::new(self.tree, child_id));
|
||||
}
|
||||
current = child.next_sibling;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_html_element_in_html_document(&self) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| name.ns == ns!(html))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_local_name(&self, local_name: &CssLocalName) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| name.local == local_name.0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_namespace(&self, ns: &CssNamespace) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| name.ns == ns.0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_same_type(&self, other: &Self) -> bool {
|
||||
let self_name = self.tree.with_node(self.node_id, |n| {
|
||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
||||
}).flatten();
|
||||
let other_name = self.tree.with_node(other.node_id, |n| {
|
||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
||||
}).flatten();
|
||||
match (self_name, other_name) {
|
||||
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn attr_matches(
|
||||
&self,
|
||||
ns: &NamespaceConstraint<&CssNamespace>,
|
||||
local_name: &CssLocalName,
|
||||
operation: &AttrSelectorOperation<&CssString>,
|
||||
) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |node| {
|
||||
let attrs = match node.attrs() {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
};
|
||||
attrs.iter().any(|attr| {
|
||||
let ns_match = match ns {
|
||||
NamespaceConstraint::Any => true,
|
||||
NamespaceConstraint::Specific(expected_ns) => attr.name.ns == expected_ns.0,
|
||||
};
|
||||
if !ns_match || attr.name.local != local_name.0 {
|
||||
return false;
|
||||
}
|
||||
operation.eval_str(&attr.value)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_attr_in_no_namespace(&self, local_name: &CssLocalName) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |node| {
|
||||
node.attrs()
|
||||
.map(|attrs| {
|
||||
attrs.iter().any(|a| {
|
||||
a.name.ns == html5ever::ns!()
|
||||
&& a.name.local == local_name.0
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn match_non_ts_pseudo_class(
|
||||
&self,
|
||||
_pc: &PseudoClass,
|
||||
_context: &mut MatchingContext<'_, Self::Impl>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn match_pseudo_element(
|
||||
&self,
|
||||
_pe: &PseudoElement,
|
||||
_context: &mut MatchingContext<'_, Self::Impl>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {}
|
||||
|
||||
fn is_link(&self) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| {
|
||||
matches!(name.local.as_ref(), "a" | "area" | "link")
|
||||
&& n.get_attribute("href").is_some()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_html_slot_element(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn assigned_slot(&self) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
fn has_id(&self, id: &CssString, case_sensitivity: CaseSensitivity) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.get_attribute("id")
|
||||
.map(|value| case_sensitivity.eq(value.as_bytes(), id.0.as_bytes()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_class(&self, name: &CssString, case_sensitivity: CaseSensitivity) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.get_attribute("class")
|
||||
.map(|class_attr| {
|
||||
class_attr
|
||||
.split_whitespace()
|
||||
.any(|c| case_sensitivity.eq(c.as_bytes(), name.0.as_bytes()))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_custom_state(&self, _name: &CssString) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn imported_part(&self, _name: &CssString) -> Option<CssString> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_part(&self, _name: &CssString) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |node| {
|
||||
let mut child = node.first_child;
|
||||
while let Some(child_id) = child {
|
||||
if let Some(child_node) = self.tree.get_node(child_id) {
|
||||
match &child_node.data {
|
||||
NodeData::Element { .. } => return false,
|
||||
NodeData::Text { contents } if !contents.is_empty() => return false,
|
||||
_ => {}
|
||||
}
|
||||
child = child_node.next_sibling;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.parent
|
||||
.map(|parent_id| {
|
||||
self.tree
|
||||
.with_node(parent_id, |p| p.is_document())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ignores_nth_child_selectors(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
|
||||
let mut parser_input = cssparser::ParserInput::new(selector);
|
||||
let mut parser = cssparser::Parser::new(&mut parser_input);
|
||||
SelectorList::parse(&ObscuraSelectorParser, &mut parser, ParseRelative::No)
|
||||
.map_err(|e| format!("Failed to parse selector '{}': {:?}", selector, e))
|
||||
}
|
||||
|
||||
impl DomTree {
|
||||
pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> {
|
||||
let selector_list = parse_selector(selector)?;
|
||||
let mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
MatchingMode::Normal,
|
||||
None,
|
||||
&mut caches,
|
||||
QuirksMode::NoQuirks,
|
||||
NeedsSelectorFlags::No,
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
|
||||
let doc = self.document();
|
||||
for desc_id in self.descendants(doc) {
|
||||
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
||||
if is_element {
|
||||
let element = DomElement::new(self, desc_id);
|
||||
if selectors::matching::matches_selector_list(
|
||||
&selector_list,
|
||||
&element,
|
||||
&mut context,
|
||||
) {
|
||||
return Ok(Some(desc_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> {
|
||||
let selector_list = parse_selector(selector)?;
|
||||
let mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
MatchingMode::Normal,
|
||||
None,
|
||||
&mut caches,
|
||||
QuirksMode::NoQuirks,
|
||||
NeedsSelectorFlags::No,
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
let mut results = Vec::new();
|
||||
|
||||
let doc = self.document();
|
||||
for desc_id in self.descendants(doc) {
|
||||
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
||||
if is_element {
|
||||
let element = DomElement::new(self, desc_id);
|
||||
if selectors::matching::matches_selector_list(
|
||||
&selector_list,
|
||||
&element,
|
||||
&mut context,
|
||||
) {
|
||||
results.push(desc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tree_sink::parse_html;
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_tag() {
|
||||
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
||||
let result = tree.query_selector("h1").unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
assert_eq!(node.as_element().unwrap().local.as_ref(), "h1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_class() {
|
||||
let tree =
|
||||
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
||||
let result = tree.query_selector(".foo").unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
assert_eq!(node.get_attribute("class"), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_id() {
|
||||
let tree = parse_html(r#"<div id="main">Content</div>"#);
|
||||
let result = tree.query_selector("#main").unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_all() {
|
||||
let tree = parse_html("<ul><li>1</li><li>2</li><li>3</li></ul>");
|
||||
let results = tree.query_selector_all("li").unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_descendant() {
|
||||
let tree =
|
||||
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
||||
let result = tree.query_selector("#outer span").unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
assert_eq!(node.as_element().unwrap().local.as_ref(), "span");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_attribute() {
|
||||
let tree = parse_html(
|
||||
r#"<input type="text" name="user"><input type="password" name="pass">"#,
|
||||
);
|
||||
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
assert_eq!(node.get_attribute("name"), Some("pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_no_match() {
|
||||
let tree = parse_html("<div>Hello</div>");
|
||||
let result = tree.query_selector("span").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_complex() {
|
||||
let tree = parse_html(
|
||||
r#"<div class="container">
|
||||
<ul class="list">
|
||||
<li class="item active">First</li>
|
||||
<li class="item">Second</li>
|
||||
<li class="item active">Third</li>
|
||||
</ul>
|
||||
</div>"#,
|
||||
);
|
||||
let results = tree.query_selector_all(".list .item.active").unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user