set_quirks_mode was a no-op and the selector MatchingContext hardcoded QuirksMode::NoQuirks, so class/id selectors were always case-sensitive even on quirks-mode (no-doctype) pages where browsers match .foo / #bar ASCII-case-insensitively. Store the parser-reported quirks mode on DomTree, pass it into the selector context, and skip the exact-case #id index fast path in quirks mode so it falls through to case-insensitive matching. Fixes #499
833 lines
28 KiB
Rust
833 lines
28 KiB
Rust
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>;
|
|
|
|
// Allow `:has()`. The selectors crate gates relative-selector parsing on
|
|
// this (default false), so without it `a:has(p)` failed to parse and the
|
|
// error was swallowed into an empty match set. Matching already passes a
|
|
// SelectorCaches (which holds the relative-selector cache), so enabling
|
|
// parsing is sufficient.
|
|
fn parse_has(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
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 {
|
|
// Must be stable per node. DomElement is Copy and gets a fresh stack
|
|
// address on every traversal step, so OpaqueElement::new(self) returns a
|
|
// different id for the same node each call. That breaks `:has` anchor
|
|
// matching, which compares the anchor's opaque against the element
|
|
// reached by walking up the tree. Key off the node's stable slot in the
|
|
// tree's Vec instead (OpaqueElement only compares the address, never
|
|
// dereferences it, and the Vec is not mutated during a query).
|
|
let inner = self.tree.borrow_inner();
|
|
match inner.nodes.get(self.node_id.index()) {
|
|
Some(slot) => OpaqueElement::new(slot),
|
|
None => 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
|
|
}
|
|
}
|
|
|
|
// Thread-local LRU cache of parsed selectors. Without this every
|
|
// querySelector / querySelectorAll re-parses the selector string;
|
|
// for batch-heavy DOM access (agent scraping a table, framework
|
|
// repeatedly polling for elements) the parse cost adds up to tens
|
|
// of ms per page. Cap of 256 entries fits a typical page's distinct
|
|
// selectors without unbounded memory growth.
|
|
thread_local! {
|
|
static SELECTOR_CACHE: std::cell::RefCell<
|
|
std::collections::HashMap<String, std::sync::Arc<SelectorList<ObscuraSelector>>>,
|
|
> = std::cell::RefCell::new(std::collections::HashMap::with_capacity(64));
|
|
}
|
|
const SELECTOR_CACHE_CAP: usize = 256;
|
|
|
|
fn parse_selector_uncached(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))
|
|
}
|
|
|
|
pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
|
|
// Hot path: cached. Cold path: parse + insert.
|
|
if let Some(cached) = SELECTOR_CACHE.with(|c| c.borrow().get(selector).cloned()) {
|
|
return Ok((*cached).clone());
|
|
}
|
|
let parsed = parse_selector_uncached(selector)?;
|
|
let cached = std::sync::Arc::new(parsed.clone());
|
|
SELECTOR_CACHE.with(|c| {
|
|
let mut cache = c.borrow_mut();
|
|
// Crude eviction: if at cap, dump the whole table. A real LRU
|
|
// would be more memory-friendly but selectors are small and 256
|
|
// is comfortably above a single page's distinct-selector count.
|
|
if cache.len() >= SELECTOR_CACHE_CAP {
|
|
cache.clear();
|
|
}
|
|
cache.insert(selector.to_string(), cached);
|
|
});
|
|
Ok(parsed)
|
|
}
|
|
|
|
/// If `selector` is a bare ASCII id selector like `#main`, return the id (without
|
|
/// the `#`). Conservative: escapes, combinators, commas, whitespace, non-ASCII, or
|
|
/// a non-letter/underscore first character fall through to the full selector engine.
|
|
fn simple_id_selector(selector: &str) -> Option<&str> {
|
|
let id = selector.trim().strip_prefix('#')?;
|
|
let first = id.as_bytes().first().copied()?;
|
|
if !(first.is_ascii_alphabetic() || first == b'_') {
|
|
return None;
|
|
}
|
|
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
|
|
Some(id)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
impl DomTree {
|
|
pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> {
|
|
self.query_selector_from(self.document(), selector)
|
|
}
|
|
|
|
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> {
|
|
self.query_selector_all_from(self.document(), selector)
|
|
}
|
|
|
|
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
|
|
// Fast path: a bare "#id" selector resolves through the id index in O(1)
|
|
// instead of scanning every descendant. The index holds the first element
|
|
// in tree order per id, which is exactly what the full scan would return.
|
|
// In quirks mode `#id` matches ASCII-case-insensitively, but the id
|
|
// index is keyed on the exact-case id, so skip the fast path and let the
|
|
// selector engine (below) do the case-insensitive match.
|
|
if !self.is_quirks() {
|
|
if let Some(id) = simple_id_selector(selector) {
|
|
match self.get_element_by_id(id) {
|
|
// querySelector matches strict descendants of root only, so the
|
|
// indexed element must have root among its ancestors.
|
|
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
|
|
// Index miss or stale entry (detached node) — fall through to full scan.
|
|
// The id_index is best-effort: it only registers nodes at creation time
|
|
// and does not update on reparent, so it can point to a detached clone
|
|
// while the live node (with the same id) is elsewhere in the tree.
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
let selector_list = parse_selector(selector)?;
|
|
let mut caches = selectors::context::SelectorCaches::default();
|
|
let mut context = MatchingContext::new(
|
|
MatchingMode::Normal,
|
|
None,
|
|
&mut caches,
|
|
self.selector_quirks_mode(),
|
|
NeedsSelectorFlags::No,
|
|
MatchingForInvalidation::No,
|
|
);
|
|
|
|
for desc_id in self.descendants(root) {
|
|
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)
|
|
}
|
|
|
|
// Map the document's quirks flag onto the selector crate's QuirksMode. In
|
|
// quirks mode the crate matches class/id selectors ASCII-case-insensitively.
|
|
fn selector_quirks_mode(&self) -> QuirksMode {
|
|
if self.is_quirks() {
|
|
QuirksMode::Quirks
|
|
} else {
|
|
QuirksMode::NoQuirks
|
|
}
|
|
}
|
|
|
|
pub fn query_selector_all_from(&self, root: NodeId, 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,
|
|
self.selector_quirks_mode(),
|
|
NeedsSelectorFlags::No,
|
|
MatchingForInvalidation::No,
|
|
);
|
|
let mut results = Vec::new();
|
|
|
|
for desc_id in self.descendants(root) {
|
|
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_has_parses() {
|
|
// Isolate parse from match: :has must parse, not error.
|
|
assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse");
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_has() {
|
|
let tree = parse_html(r#"<a><p class="bt">x</p></a>"#);
|
|
let all = tree.query_selector_all("a:has(p.bt)").unwrap();
|
|
assert_eq!(all.len(), 1, "a:has(p.bt) should match the <a>");
|
|
let none = tree.query_selector_all("a:has(span.bt)").unwrap();
|
|
assert_eq!(none.len(), 0, "a:has(span.bt) should match nothing");
|
|
}
|
|
|
|
#[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 quirks_mode_matches_class_and_id_case_insensitively() {
|
|
// No doctype => quirks mode; class/id match ASCII case-insensitively.
|
|
let tree = parse_html(r#"<div class="Foo" id="Bar">x</div>"#);
|
|
assert!(
|
|
tree.query_selector(".foo").unwrap().is_some(),
|
|
".foo should match class=\"Foo\" in quirks mode"
|
|
);
|
|
assert!(
|
|
tree.query_selector("#bar").unwrap().is_some(),
|
|
"#bar should match id=\"Bar\" in quirks mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn standards_mode_matches_class_and_id_case_sensitively() {
|
|
// With a doctype => no-quirks; class/id remain case-sensitive.
|
|
let tree = parse_html(r#"<!DOCTYPE html><div class="Foo" id="Bar">x</div>"#);
|
|
assert!(
|
|
tree.query_selector(".foo").unwrap().is_none(),
|
|
".foo must NOT match class=\"Foo\" in standards mode"
|
|
);
|
|
assert!(
|
|
tree.query_selector("#bar").unwrap().is_none(),
|
|
"#bar must NOT match id=\"Bar\" in standards mode"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_all_from_scopes_to_subtree() {
|
|
let tree = parse_html(
|
|
r#"<div id="a"><span class="x">in a</span></div><div id="b"><span class="x">in b</span></div>"#,
|
|
);
|
|
let a = tree.get_element_by_id("a").expect("div#a");
|
|
let b = tree.get_element_by_id("b").expect("div#b");
|
|
|
|
// Document-rooted: sees both spans.
|
|
assert_eq!(tree.query_selector_all(".x").unwrap().len(), 2);
|
|
// Scoped to #a: only its descendant span.
|
|
let in_a = tree.query_selector_all_from(a, ".x").unwrap();
|
|
assert_eq!(in_a.len(), 1);
|
|
let in_b = tree.query_selector_all_from(b, ".x").unwrap();
|
|
assert_eq!(in_b.len(), 1);
|
|
assert_ne!(in_a[0], in_b[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_from_returns_first_in_subtree_only() {
|
|
let tree = parse_html(
|
|
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
|
|
);
|
|
let s = tree.get_element_by_id("s").expect("section#s");
|
|
|
|
// Scoped to #s: skip the outside paragraph; return the first inside.
|
|
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside");
|
|
assert_eq!(tree.text_content(first_in_s), "first");
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_from_excludes_self() {
|
|
// The root element itself must not match its own scoped query, per
|
|
// the spec: querySelector matches descendants only.
|
|
let tree = parse_html(r#"<div id="root" class="x"><span>child</span></div>"#);
|
|
let root = tree.get_element_by_id("root").expect("div#root");
|
|
|
|
// Only descendants are candidates: `.x` on root finds nothing.
|
|
assert!(tree.query_selector_from(root, ".x").unwrap().is_none());
|
|
// `span` finds the child.
|
|
assert!(tree.query_selector_from(root, "span").unwrap().is_some());
|
|
}
|
|
}
|