Improve modern rendering and output parity
This commit is contained in:
1 parent
f428aa37fa
commit
c8a5477296
25 files changed
+12885
-5053
No files matched your search
@@ -9,8 +9,8 @@ use selectors::matching::{
|
||||
NeedsSelectorFlags,
|
||||
};
|
||||
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
||||
use selectors::{Element, OpaqueElement, SelectorList};
|
||||
use selectors::visitor::SelectorVisitor;
|
||||
use selectors::{Element, OpaqueElement, SelectorList};
|
||||
|
||||
use crate::tree::{DomTree, NodeData, NodeId};
|
||||
|
||||
@@ -251,7 +251,13 @@ impl<'a> DomElement<'a> {
|
||||
.map(|name| {
|
||||
matches!(
|
||||
name.local.as_ref(),
|
||||
"input" | "button" | "select" | "textarea" | "optgroup" | "option" | "fieldset"
|
||||
"input"
|
||||
| "button"
|
||||
| "select"
|
||||
| "textarea"
|
||||
| "optgroup"
|
||||
| "option"
|
||||
| "fieldset"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
@@ -390,20 +396,26 @@ impl<'a> Element for DomElement<'a> {
|
||||
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)
|
||||
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();
|
||||
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,
|
||||
@@ -441,10 +453,9 @@ impl<'a> Element for DomElement<'a> {
|
||||
.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
|
||||
})
|
||||
attrs
|
||||
.iter()
|
||||
.any(|a| a.name.ns == html5ever::ns!() && a.name.local == local_name.0)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
@@ -469,7 +480,9 @@ impl<'a> Element for DomElement<'a> {
|
||||
// correctly are) made every such base rule silently inert.
|
||||
PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"),
|
||||
PseudoClass::Disabled => self.is_form_control() && self.has_boolean_attr("disabled"),
|
||||
PseudoClass::Checked => self.has_boolean_attr("checked") || self.has_boolean_attr("selected"),
|
||||
PseudoClass::Checked => {
|
||||
self.has_boolean_attr("checked") || self.has_boolean_attr("selected")
|
||||
}
|
||||
// Dynamic user-interaction pseudo-classes have no meaning against
|
||||
// a static DOM snapshot with no live user input.
|
||||
PseudoClass::Hover
|
||||
@@ -640,7 +653,10 @@ fn simple_id_selector(selector: &str) -> Option<&str> {
|
||||
if !(first.is_ascii_alphabetic() || first == b'_') {
|
||||
return None;
|
||||
}
|
||||
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
|
||||
if id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
{
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
@@ -656,7 +672,11 @@ impl DomTree {
|
||||
self.query_selector_all_from(self.document(), selector)
|
||||
}
|
||||
|
||||
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
|
||||
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.
|
||||
@@ -714,7 +734,11 @@ impl DomTree {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
|
||||
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(
|
||||
@@ -746,7 +770,10 @@ impl DomTree {
|
||||
/// Test one element as the selector subject, including when it is
|
||||
/// detached or is a direct child of a shadow-tree compatibility root.
|
||||
pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result<bool, String> {
|
||||
if !self.with_node(nid, |node| node.is_element()).unwrap_or(false) {
|
||||
if !self
|
||||
.with_node(nid, |node| node.is_element())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let selector_list = parse_selector(selector)?;
|
||||
@@ -768,7 +795,7 @@ impl DomTree {
|
||||
|
||||
/// Parse a single selector once for repeated single-element matching, and
|
||||
/// precompute its specificity, ancestor hashes, and "subject key" (the
|
||||
/// rightmost id/class/tag used to bucket the rule for fast candidate
|
||||
/// rightmost id/class/attribute/tag used to bucket the rule for fast candidate
|
||||
/// lookup). Returns `None` if the selector fails to parse.
|
||||
///
|
||||
/// This is the primitive a stylesheet cascade builds on: compile every rule
|
||||
@@ -778,9 +805,14 @@ impl DomTree {
|
||||
let list = parse_selector(selector).ok()?;
|
||||
let sel = list.slice().first()?.clone();
|
||||
let specificity = sel.specificity();
|
||||
let key = subject_key(&sel);
|
||||
let keys = SubjectKeys::from_vec(subject_keys(&sel));
|
||||
let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks);
|
||||
Some(CompiledSelector { sel, specificity, key, hashes })
|
||||
Some(CompiledSelector {
|
||||
sel,
|
||||
specificity,
|
||||
keys,
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a single element match a precompiled selector? Allocates a fresh
|
||||
@@ -798,6 +830,8 @@ impl DomTree {
|
||||
Matcher {
|
||||
caches: selectors::context::SelectorCaches::default(),
|
||||
ancestors: AncestorFilter::new(),
|
||||
candidate_generation: 0,
|
||||
candidate_seen: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -809,9 +843,41 @@ impl DomTree {
|
||||
pub struct Matcher {
|
||||
caches: selectors::context::SelectorCaches,
|
||||
ancestors: AncestorFilter,
|
||||
candidate_generation: u32,
|
||||
candidate_seen: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
/// Start one indexed-candidate collection without clearing the reusable
|
||||
/// seen table. Stylesheet rules that live in several selector buckets use
|
||||
/// this to avoid matching and cascading the same rule twice.
|
||||
#[doc(hidden)]
|
||||
pub fn begin_candidate_collection(&mut self, candidate_count: usize) {
|
||||
self.candidate_generation = self.candidate_generation.wrapping_add(1);
|
||||
if self.candidate_generation == 0 {
|
||||
self.candidate_seen.fill(0);
|
||||
self.candidate_generation = 1;
|
||||
}
|
||||
self.candidate_seen.resize(candidate_count, 0);
|
||||
}
|
||||
|
||||
/// Return true once per candidate index in the current collection.
|
||||
#[doc(hidden)]
|
||||
pub fn mark_candidate(&mut self, index: usize) -> bool {
|
||||
debug_assert!(index < self.candidate_seen.len());
|
||||
let Some(seen) = self.candidate_seen.get_mut(index) else {
|
||||
// Candidate collection is an optimization. If a future caller
|
||||
// supplies inconsistent bounds, fail open to an extra match rather
|
||||
// than silently dropping authored CSS.
|
||||
return true;
|
||||
};
|
||||
if *seen == self.candidate_generation {
|
||||
return false;
|
||||
}
|
||||
*seen = self.candidate_generation;
|
||||
true
|
||||
}
|
||||
|
||||
/// Match `nid` (matched as if it were the subject element; the ancestor
|
||||
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
|
||||
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
|
||||
@@ -827,7 +893,13 @@ impl Matcher {
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
let element = DomElement::new(tree, nid);
|
||||
selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context)
|
||||
selectors::matching::matches_selector(
|
||||
&compiled.sel,
|
||||
0,
|
||||
Some(&compiled.hashes),
|
||||
&element,
|
||||
&mut context,
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `nid`'s hashes onto the ancestor filter before descending into its
|
||||
@@ -854,7 +926,10 @@ struct AncestorFilter {
|
||||
|
||||
impl AncestorFilter {
|
||||
fn new() -> Self {
|
||||
AncestorFilter { filter: Box::default(), levels: Vec::new() }
|
||||
AncestorFilter {
|
||||
filter: Box::default(),
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(&self) -> &selectors::bloom::BloomFilter {
|
||||
@@ -865,13 +940,25 @@ impl AncestorFilter {
|
||||
let mut hashes = Vec::new();
|
||||
tree.with_node(nid, |node| {
|
||||
if let Some(elem) = node.as_element() {
|
||||
push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssLocalName(elem.local.clone()).precomputed_hash(),
|
||||
);
|
||||
if let Some(id) = node.get_attribute("id") {
|
||||
push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssString(id.to_string()).precomputed_hash(),
|
||||
);
|
||||
}
|
||||
if let Some(class) = node.get_attribute("class") {
|
||||
for c in class.split_whitespace() {
|
||||
push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssString(c.to_string()).precomputed_hash(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,8 +985,13 @@ fn push_hash(hashes: &mut Vec<u32>, filter: &mut selectors::bloom::BloomFilter,
|
||||
/// only tested against elements that carry its key.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum SelectorKey {
|
||||
/// The document element. `:root` can match only this one subject, so it is
|
||||
/// substantially more selective than the universal bucket despite having
|
||||
/// no id/class/tag token.
|
||||
Root,
|
||||
Id(String),
|
||||
Class(String),
|
||||
Attribute(String),
|
||||
Local(String),
|
||||
Universal,
|
||||
}
|
||||
@@ -908,51 +1000,359 @@ pub enum SelectorKey {
|
||||
pub struct CompiledSelector {
|
||||
sel: parser::Selector<ObscuraSelector>,
|
||||
specificity: u32,
|
||||
key: SelectorKey,
|
||||
keys: SubjectKeys,
|
||||
hashes: parser::AncestorHashes,
|
||||
}
|
||||
|
||||
impl CompiledSelector {
|
||||
/// The one bucket usable by legacy selector indexes. Selectors whose
|
||||
/// subject has multiple disjoint alternatives deliberately report
|
||||
/// `Universal`; use [`Self::candidate_keys`] to opt into multi-bucket
|
||||
/// indexing.
|
||||
pub fn key(&self) -> &SelectorKey {
|
||||
&self.key
|
||||
match &self.keys {
|
||||
SubjectKeys::One(key) => key,
|
||||
// Existing single-bucket consumers must keep treating disjoint
|
||||
// alternatives as universal until they opt into `candidate_keys`.
|
||||
SubjectKeys::Many(_) => &SelectorKey::Universal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicated buckets that together cover every element this selector
|
||||
/// can match. A consumer must insert the rule in every returned bucket and
|
||||
/// deduplicate rule candidates before matching, since one element can
|
||||
/// carry keys from more than one alternative.
|
||||
pub fn candidate_keys(&self) -> &[SelectorKey] {
|
||||
match &self.keys {
|
||||
SubjectKeys::One(key) => std::slice::from_ref(key),
|
||||
SubjectKeys::Many(keys) => keys,
|
||||
}
|
||||
}
|
||||
pub fn specificity(&self) -> u32 {
|
||||
self.specificity
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the index key from a selector's rightmost compound: prefer id, then
|
||||
/// class, then local name; fall back to Universal for `*`, attribute-only, or
|
||||
/// pseudo-only subjects.
|
||||
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> SelectorKey {
|
||||
enum SubjectKeys {
|
||||
One(SelectorKey),
|
||||
Many(Box<[SelectorKey]>),
|
||||
}
|
||||
|
||||
impl SubjectKeys {
|
||||
fn from_vec(mut keys: Vec<SelectorKey>) -> Self {
|
||||
if keys.len() == 1 {
|
||||
Self::One(keys.pop().unwrap())
|
||||
} else {
|
||||
Self::Many(keys.into_boxed_slice())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_rank(key: &SelectorKey) -> u8 {
|
||||
match key {
|
||||
SelectorKey::Universal => 0,
|
||||
SelectorKey::Local(_) => 1,
|
||||
SelectorKey::Attribute(_) => 2,
|
||||
SelectorKey::Class(_) => 3,
|
||||
SelectorKey::Id(_) => 4,
|
||||
SelectorKey::Root => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique_key(keys: &mut Vec<SelectorKey>, key: SelectorKey) {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate buckets for a selector's rightmost compound. A single ordinary
|
||||
/// id/class/local selector keeps the allocation-free common path. A pure
|
||||
/// `:is()`/`:where()` subject returns the deduplicated union of its arms, but
|
||||
/// only when every arm has a concrete key; one universal/pseudo-only
|
||||
/// arm makes the whole set universal because that arm can match an element
|
||||
/// carrying none of the other keys.
|
||||
///
|
||||
/// When an outer compound key exists, disjoint functional-pseudo keys replace
|
||||
/// it only if every arm is strictly more selective. This is the same
|
||||
/// conservative contract as Gecko's `SelectorMap::find_bucket`: for
|
||||
/// `.control:is(button, #save)` keep `.control`, while
|
||||
/// `.control:is(#save, #cancel)` may use the two id buckets.
|
||||
fn subject_keys(sel: &parser::Selector<ObscuraSelector>) -> Vec<SelectorKey> {
|
||||
use parser::Component;
|
||||
let mut local: Option<String> = None;
|
||||
let mut attribute: Option<String> = None;
|
||||
let mut class: Option<String> = None;
|
||||
let mut id: Option<String> = None;
|
||||
let mut root = false;
|
||||
let mut disjoint_sets: Vec<Vec<SelectorKey>> = Vec::new();
|
||||
for comp in sel.iter_raw_match_order() {
|
||||
match comp {
|
||||
Component::Combinator(_) => break,
|
||||
Component::ID(v) => id = Some(v.0.clone()),
|
||||
Component::Root => root = true,
|
||||
Component::Class(v) => class = Some(v.0.clone()),
|
||||
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
|
||||
Component::AttributeInNoNamespaceExists {
|
||||
local_name_lower, ..
|
||||
} => attribute = Some(local_name_lower.0.to_string()),
|
||||
Component::AttributeInNoNamespace { local_name, .. } => {
|
||||
attribute = Some(local_name.0.to_string())
|
||||
}
|
||||
Component::AttributeOther(selector) => {
|
||||
attribute = Some(selector.local_name_lower.0.to_string())
|
||||
}
|
||||
Component::Is(list) | Component::Where(list) => {
|
||||
let mut keys = Vec::new();
|
||||
let mut universal = false;
|
||||
for alternative in list.slice() {
|
||||
let alternative_keys = subject_keys(alternative);
|
||||
if alternative_keys
|
||||
.iter()
|
||||
.any(|key| matches!(key, SelectorKey::Universal))
|
||||
{
|
||||
universal = true;
|
||||
break;
|
||||
}
|
||||
for key in alternative_keys {
|
||||
push_unique_key(&mut keys, key);
|
||||
}
|
||||
}
|
||||
if universal || keys.is_empty() {
|
||||
keys.clear();
|
||||
keys.push(SelectorKey::Universal);
|
||||
}
|
||||
disjoint_sets.push(keys);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(i) = id {
|
||||
let direct = if root {
|
||||
SelectorKey::Root
|
||||
} else if let Some(i) = id {
|
||||
SelectorKey::Id(i)
|
||||
} else if let Some(c) = class {
|
||||
SelectorKey::Class(c)
|
||||
} else if let Some(a) = attribute {
|
||||
SelectorKey::Attribute(a)
|
||||
} else if let Some(l) = local {
|
||||
SelectorKey::Local(l)
|
||||
} else {
|
||||
SelectorKey::Universal
|
||||
};
|
||||
|
||||
let direct_rank = key_rank(&direct);
|
||||
let mut best: Option<Vec<SelectorKey>> = None;
|
||||
for keys in disjoint_sets {
|
||||
if keys.iter().any(|key| key_rank(key) <= direct_rank) {
|
||||
continue;
|
||||
}
|
||||
let min_rank = keys.iter().map(key_rank).min().unwrap_or(0);
|
||||
let replaces = best.as_ref().is_none_or(|current| {
|
||||
let current_min = current.iter().map(key_rank).min().unwrap_or(0);
|
||||
min_rank > current_min || (min_rank == current_min && keys.len() < current.len())
|
||||
});
|
||||
if replaces {
|
||||
best = Some(keys);
|
||||
}
|
||||
}
|
||||
best.unwrap_or_else(|| vec![direct])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tree_sink::parse_html;
|
||||
|
||||
use super::SelectorKey;
|
||||
|
||||
fn candidate_keys(tree: &crate::tree::DomTree, selector: &str) -> Vec<SelectorKey> {
|
||||
tree.compile_rule_selector(selector)
|
||||
.unwrap_or_else(|| panic!("failed to compile {selector}"))
|
||||
.candidate_keys()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
fn element_has_selector_key(
|
||||
tree: &crate::tree::DomTree,
|
||||
node: crate::tree::NodeId,
|
||||
key: &SelectorKey,
|
||||
) -> bool {
|
||||
let node = tree.get_node(node).expect("matched node must exist");
|
||||
match key {
|
||||
SelectorKey::Root => node
|
||||
.parent
|
||||
.and_then(|parent| tree.get_node(parent))
|
||||
.is_some_and(|parent| parent.is_document()),
|
||||
SelectorKey::Universal => true,
|
||||
SelectorKey::Id(id) => node.get_attribute("id") == Some(id.as_str()),
|
||||
SelectorKey::Class(class) => node.get_attribute("class").is_some_and(|classes| {
|
||||
classes.split_ascii_whitespace().any(|value| value == class)
|
||||
}),
|
||||
SelectorKey::Attribute(attribute) => node.get_attribute(attribute).is_some(),
|
||||
SelectorKey::Local(local) => node
|
||||
.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == local),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_expand_rightmost_is_and_where() {
|
||||
let tree = parse_html("<main></main>");
|
||||
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.IssueLabel, .Label):hover"),
|
||||
vec![
|
||||
SelectorKey::Class("IssueLabel".into()),
|
||||
SelectorKey::Class("Label".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":where(button, input, select)"),
|
||||
vec![
|
||||
SelectorKey::Local("button".into()),
|
||||
SelectorKey::Local("input".into()),
|
||||
SelectorKey::Local("select".into()),
|
||||
]
|
||||
);
|
||||
|
||||
// A legacy single-bucket index stays correct until it explicitly opts
|
||||
// into inserting all candidate keys.
|
||||
let compiled = tree
|
||||
.compile_rule_selector(":is(.IssueLabel, .Label):hover")
|
||||
.unwrap();
|
||||
assert_eq!(compiled.key(), &SelectorKey::Universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_deduplicate_equivalent_arms() {
|
||||
let tree = parse_html("<main></main>");
|
||||
let compiled = tree
|
||||
.compile_rule_selector(":is(.btn, .btn:hover, :where(.btn))")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
compiled.candidate_keys(),
|
||||
&[SelectorKey::Class("btn".into())]
|
||||
);
|
||||
assert_eq!(compiled.key(), &SelectorKey::Class("btn".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_index_attributes_and_fall_back_for_pseudos() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.btn, [data-kind])"),
|
||||
vec![
|
||||
SelectorKey::Class("btn".into()),
|
||||
SelectorKey::Attribute("data-kind".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.btn, :first-child)"),
|
||||
vec![SelectorKey::Universal]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_index_root_subjects_and_functional_arms() {
|
||||
let tree = parse_html("<html><body><main class=app></main></body></html>");
|
||||
assert_eq!(candidate_keys(&tree, ":root"), vec![SelectorKey::Root]);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":root[data-color-mode]"),
|
||||
vec![SelectorKey::Root],
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(:root, .app)"),
|
||||
vec![SelectorKey::Root, SelectorKey::Class("app".into())],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_only_replace_outer_key_when_more_selective() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".control:is(#save, #cancel)"),
|
||||
vec![
|
||||
SelectorKey::Id("save".into()),
|
||||
SelectorKey::Id("cancel".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".control:is(button, #save)"),
|
||||
vec![SelectorKey::Class("control".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_use_subjects_of_complex_is_arms() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".scope :is(.a > .x, .b > .y)"),
|
||||
vec![
|
||||
SelectorKey::Class("x".into()),
|
||||
SelectorKey::Class("y".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_cover_every_actual_match() {
|
||||
let tree = parse_html(
|
||||
r#"<section class="scope">
|
||||
<div class="alpha control" id="save" data-kind="x"></div>
|
||||
<button class="beta control"></button>
|
||||
<input class="control" id="cancel">
|
||||
<div class="a"><span class="x"></span></div>
|
||||
<div class="b"><span class="y"></span></div>
|
||||
</section>"#,
|
||||
);
|
||||
let selectors = [
|
||||
":is(.alpha, .beta)",
|
||||
":where(button, input)",
|
||||
".control:is(#save, #cancel)",
|
||||
".control:is(button, #save)",
|
||||
":is(.alpha, [data-kind])",
|
||||
":is(:root, .control)",
|
||||
".scope :is(.a > .x, .b > .y)",
|
||||
];
|
||||
|
||||
for selector in selectors {
|
||||
let keys = candidate_keys(&tree, selector);
|
||||
let matches = tree.query_selector_all(selector).unwrap();
|
||||
assert!(!matches.is_empty(), "fixture must exercise {selector}");
|
||||
for matched in matches {
|
||||
assert!(
|
||||
keys.iter()
|
||||
.any(|key| element_has_selector_key(&tree, matched, key)),
|
||||
"{selector} matched an element outside all candidate buckets: {keys:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_candidate_collection_deduplicates_and_reuses_indices() {
|
||||
let tree = parse_html("<main></main>");
|
||||
let mut matcher = tree.matcher();
|
||||
|
||||
matcher.begin_candidate_collection(4);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
|
||||
matcher.begin_candidate_collection(4);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
|
||||
matcher.begin_candidate_collection(9);
|
||||
assert!(matcher.mark_candidate(8));
|
||||
assert!(!matcher.mark_candidate(8));
|
||||
|
||||
matcher.candidate_generation = u32::MAX;
|
||||
matcher.begin_candidate_collection(9);
|
||||
assert_eq!(matcher.candidate_generation, 1);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_tag() {
|
||||
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
||||
@@ -964,8 +1364,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_class() {
|
||||
let tree =
|
||||
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
||||
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();
|
||||
@@ -989,7 +1388,10 @@ mod tests {
|
||||
#[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");
|
||||
assert!(
|
||||
super::parse_selector("a:has(p.bt)").is_ok(),
|
||||
":has failed to parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1005,9 +1407,17 @@ mod tests {
|
||||
fn test_enabled_matches_form_control_without_disabled_attr() {
|
||||
let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#);
|
||||
let enabled = tree.query_selector_all("button:enabled").unwrap();
|
||||
assert_eq!(enabled.len(), 1, ":enabled should match only the non-disabled button");
|
||||
assert_eq!(
|
||||
enabled.len(),
|
||||
1,
|
||||
":enabled should match only the non-disabled button"
|
||||
);
|
||||
let disabled = tree.query_selector_all("button:disabled").unwrap();
|
||||
assert_eq!(disabled.len(), 1, ":disabled should match only the disabled button");
|
||||
assert_eq!(
|
||||
disabled.len(),
|
||||
1,
|
||||
":disabled should match only the disabled button"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1021,33 +1431,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_checked_matches_checked_attribute() {
|
||||
let tree = parse_html(
|
||||
r#"<input type="checkbox" checked><input type="checkbox">"#,
|
||||
);
|
||||
let tree = parse_html(r#"<input type="checkbox" checked><input type="checkbox">"#);
|
||||
assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfocused_snapshot_matches_focus_negation_selectors() {
|
||||
let tree = parse_html(
|
||||
r#"<div class="visually-hidden-focusable">Skip</div>"#,
|
||||
);
|
||||
let tree = parse_html(r#"<div class="visually-hidden-focusable">Skip</div>"#);
|
||||
let hidden = tree
|
||||
.query_selector_all(
|
||||
".visually-hidden-focusable:not(:focus):not(:focus-within)",
|
||||
)
|
||||
.query_selector_all(".visually-hidden-focusable:not(:focus):not(:focus-within)")
|
||||
.unwrap();
|
||||
assert_eq!(hidden.len(), 1);
|
||||
assert_eq!(
|
||||
tree.query_selector_all(":focus-visible").unwrap().len(),
|
||||
0
|
||||
);
|
||||
assert_eq!(tree.query_selector_all(":focus-visible").unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_descendant() {
|
||||
let tree =
|
||||
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
||||
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();
|
||||
@@ -1072,9 +1472,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_attribute() {
|
||||
let tree = parse_html(
|
||||
r#"<input type="text" name="user"><input type="password" name="pass">"#,
|
||||
);
|
||||
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();
|
||||
@@ -1151,13 +1550,15 @@ mod tests {
|
||||
|
||||
#[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 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");
|
||||
let first_in_s = tree
|
||||
.query_selector_from(s, "p")
|
||||
.unwrap()
|
||||
.expect("a p inside");
|
||||
assert_eq!(tree.text_content(first_in_s), "first");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user