From 2a9a1336ec89950a72dc49c0bc7c05587820c65a Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Wed, 4 Mar 2026 20:24:25 +0100 Subject: [PATCH] Implement CSS selector matching engine - Simple selectors: type, universal, class, ID, attribute, pseudo-class - Compound selectors: all parts must match - Complex selectors with combinators: descendant, child, adjacent/general sibling - Selector list matching (comma-separated) - Specificity calculation (a, b, c) with Ord implementation - Rule collection: gather matching rules sorted by specificity and source order - Declaration collection with !important separation - Right-to-left matching for correct combinator evaluation - All attribute operators: exact, includes, dash-match, prefix, suffix, substring - 34 tests covering all selector types, combinators, specificity, and rule collection Co-Authored-By: Claude Opus 4.6 --- crates/style/src/lib.rs | 2 + crates/style/src/matching.rs | 825 +++++++++++++++++++++++++++++++++++ 2 files changed, 827 insertions(+) create mode 100644 crates/style/src/matching.rs diff --git a/crates/style/src/lib.rs b/crates/style/src/lib.rs index 0c89502..16396c0 100644 --- a/crates/style/src/lib.rs +++ b/crates/style/src/lib.rs @@ -1 +1,3 @@ //! Selector matching and computed style resolution. + +pub mod matching; diff --git a/crates/style/src/matching.rs b/crates/style/src/matching.rs new file mode 100644 index 0000000..f933ca7 --- /dev/null +++ b/crates/style/src/matching.rs @@ -0,0 +1,825 @@ +//! CSS selector matching engine. +//! +//! Matches selectors against DOM elements and computes specificity. + +use we_css::parser::{ + AttributeOp, AttributeSelector, Combinator, CompoundSelector, Declaration, Rule, Selector, + SelectorComponent, SelectorList, SimpleSelector, StyleRule, Stylesheet, +}; +use we_dom::{Document, NodeData, NodeId}; + +/// Selector specificity as (a, b, c): +/// a = number of ID selectors +/// b = number of class selectors, attribute selectors, pseudo-classes +/// c = number of type selectors, pseudo-elements +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Specificity { + pub a: u32, + pub b: u32, + pub c: u32, +} + +impl Specificity { + pub fn new(a: u32, b: u32, c: u32) -> Self { + Self { a, b, c } + } +} + +impl PartialOrd for Specificity { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Specificity { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.a + .cmp(&other.a) + .then(self.b.cmp(&other.b)) + .then(self.c.cmp(&other.c)) + } +} + +/// A matched rule: a style rule paired with the specificity of the selector +/// that matched, plus source order for tie-breaking. +#[derive(Debug, Clone)] +pub struct MatchedRule<'a> { + pub rule: &'a StyleRule, + pub specificity: Specificity, + pub source_order: usize, +} + +/// Check if a selector list matches an element. Returns true if any selector +/// in the comma-separated list matches. +pub fn matches_selector_list(doc: &Document, node: NodeId, selector_list: &SelectorList) -> bool { + selector_list + .selectors + .iter() + .any(|sel| matches_selector(doc, node, sel)) +} + +/// Check if a complex selector matches an element. +/// +/// A complex selector is a chain of compound selectors joined by combinators, +/// stored left-to-right: `[Compound, Combinator, Compound, ...]`. +/// We match right-to-left: the rightmost compound (key selector) must match +/// the target element, then we walk the tree based on combinators. +pub fn matches_selector(doc: &Document, node: NodeId, selector: &Selector) -> bool { + // Extract compounds and combinators from the components list. + // Components alternate: Compound, Combinator, Compound, Combinator, ... + let compounds: Vec<&CompoundSelector> = selector + .components + .iter() + .filter_map(|c| match c { + SelectorComponent::Compound(cs) => Some(cs), + _ => None, + }) + .collect(); + + let combinators: Vec = selector + .components + .iter() + .filter_map(|c| match c { + SelectorComponent::Combinator(comb) => Some(*comb), + _ => None, + }) + .collect(); + + if compounds.is_empty() { + return false; + } + + // The key selector is the last compound. + if !matches_compound(doc, node, compounds[compounds.len() - 1]) { + return false; + } + + // Walk right-to-left through the remaining compounds. + // compounds[i] is connected to compounds[i+1] by combinators[i]. + let mut current = node; + for i in (0..compounds.len() - 1).rev() { + let compound = compounds[i]; + let combinator = combinators[i]; + + match combinator { + Combinator::Descendant => { + // Walk up ancestor chain until we find a match or run out. + let mut found = false; + let mut ancestor = doc.parent(current); + while let Some(anc) = ancestor { + if matches_compound(doc, anc, compound) { + current = anc; + found = true; + break; + } + ancestor = doc.parent(anc); + } + if !found { + return false; + } + } + Combinator::Child => { + // Direct parent must match. + if let Some(parent) = doc.parent(current) { + if matches_compound(doc, parent, compound) { + current = parent; + } else { + return false; + } + } else { + return false; + } + } + Combinator::AdjacentSibling => { + // Previous element sibling must match. + if let Some(prev) = prev_element_sibling(doc, current) { + if matches_compound(doc, prev, compound) { + current = prev; + } else { + return false; + } + } else { + return false; + } + } + Combinator::GeneralSibling => { + // Any previous element sibling must match. + let mut found = false; + let mut prev = prev_element_sibling(doc, current); + while let Some(sib) = prev { + if matches_compound(doc, sib, compound) { + current = sib; + found = true; + break; + } + prev = prev_element_sibling(doc, sib); + } + if !found { + return false; + } + } + } + } + + true +} + +/// Check if a compound selector matches an element. All simple selectors +/// in the compound must match. +pub fn matches_compound(doc: &Document, node: NodeId, compound: &CompoundSelector) -> bool { + // Must be an element node. + if !matches!(doc.node_data(node), NodeData::Element { .. }) { + return false; + } + compound.simple.iter().all(|s| matches_simple(doc, node, s)) +} + +/// Check if a simple selector matches an element. +pub fn matches_simple(doc: &Document, node: NodeId, selector: &SimpleSelector) -> bool { + match selector { + SimpleSelector::Universal => { + matches!(doc.node_data(node), NodeData::Element { .. }) + } + SimpleSelector::Type(name) => doc + .tag_name(node) + .map(|tag| tag.eq_ignore_ascii_case(name)) + .unwrap_or(false), + SimpleSelector::Class(class_name) => { + if let Some(class_attr) = doc.get_attribute(node, "class") { + class_attr.split_ascii_whitespace().any(|c| c == class_name) + } else { + false + } + } + SimpleSelector::Id(id) => doc + .get_attribute(node, "id") + .map(|val| val == id) + .unwrap_or(false), + SimpleSelector::Attribute(attr_sel) => matches_attribute(doc, node, attr_sel), + SimpleSelector::PseudoClass(_name) => { + // Pseudo-classes are not yet implemented; always return false. + false + } + } +} + +/// Check if an attribute selector matches an element. +fn matches_attribute(doc: &Document, node: NodeId, sel: &AttributeSelector) -> bool { + let attr_value = doc.get_attribute(node, &sel.name); + + match (&sel.op, &sel.value) { + // [attr] — attribute presence + (None, _) => attr_value.is_some(), + + (Some(op), Some(expected)) => { + let actual = match attr_value { + Some(v) => v, + None => return false, + }; + + match op { + // [attr=val] + AttributeOp::Exact => actual == expected, + // [attr~=val] — whitespace-separated list contains val + AttributeOp::Includes => actual.split_ascii_whitespace().any(|w| w == expected), + // [attr|=val] — equals val or starts with val- + AttributeOp::DashMatch => { + actual == expected + || (actual.starts_with(expected.as_str()) + && actual.as_bytes().get(expected.len()) == Some(&b'-')) + } + // [attr^=val] + AttributeOp::Prefix => actual.starts_with(expected.as_str()), + // [attr$=val] + AttributeOp::Suffix => actual.ends_with(expected.as_str()), + // [attr*=val] + AttributeOp::Substring => actual.contains(expected.as_str()), + } + } + + // op without value — shouldn't happen from parser, but treat as no match + (Some(_), None) => false, + } +} + +/// Calculate specificity of a selector. +pub fn specificity(selector: &Selector) -> Specificity { + let mut a = 0u32; + let mut b = 0u32; + let mut c = 0u32; + + for component in &selector.components { + if let SelectorComponent::Compound(compound) = component { + for simple in &compound.simple { + match simple { + SimpleSelector::Id(_) => a += 1, + SimpleSelector::Class(_) + | SimpleSelector::Attribute(_) + | SimpleSelector::PseudoClass(_) => b += 1, + SimpleSelector::Type(_) => c += 1, + SimpleSelector::Universal => {} + } + } + } + } + + Specificity::new(a, b, c) +} + +/// Collect all matching rules from a stylesheet for a given element, +/// sorted by specificity and source order (ascending — highest priority last). +pub fn collect_matching_rules<'a>( + doc: &Document, + node: NodeId, + stylesheet: &'a Stylesheet, +) -> Vec> { + let mut matched = Vec::new(); + collect_from_rules(doc, node, &stylesheet.rules, &mut matched, &mut 0); + matched.sort_by(|a, b| { + a.specificity + .cmp(&b.specificity) + .then(a.source_order.cmp(&b.source_order)) + }); + matched +} + +fn collect_from_rules<'a>( + doc: &Document, + node: NodeId, + rules: &'a [Rule], + matched: &mut Vec>, + order: &mut usize, +) { + for rule in rules { + match rule { + Rule::Style(style_rule) => { + let current_order = *order; + *order += 1; + + // Check each selector in the selector list. + for selector in &style_rule.selectors.selectors { + if matches_selector(doc, node, selector) { + matched.push(MatchedRule { + rule: style_rule, + specificity: specificity(selector), + source_order: current_order, + }); + // Only add the rule once even if multiple selectors match. + break; + } + } + } + Rule::Media(media_rule) => { + // For now, assume all media rules match (media query evaluation + // is out of scope for this issue). + collect_from_rules(doc, node, &media_rule.rules, matched, order); + } + Rule::Import(_) => { + // Imports are resolved at a higher level; skip here. + } + } + } +} + +/// Find the previous element sibling (skipping text/comment nodes). +fn prev_element_sibling(doc: &Document, node: NodeId) -> Option { + let mut prev = doc.prev_sibling(node); + while let Some(p) = prev { + if matches!(doc.node_data(p), NodeData::Element { .. }) { + return Some(p); + } + prev = doc.prev_sibling(p); + } + None +} + +/// Get all declarations that apply to a node, in cascade order. +/// Returns declarations from matching rules, sorted by specificity and source order. +/// Important declarations are placed after normal declarations. +pub fn get_declarations_for_node<'a>( + doc: &Document, + node: NodeId, + stylesheet: &'a Stylesheet, +) -> Vec<&'a Declaration> { + let matched_rules = collect_matching_rules(doc, node, stylesheet); + + let mut normal: Vec<(Specificity, usize, &'a Declaration)> = Vec::new(); + let mut important: Vec<(Specificity, usize, &'a Declaration)> = Vec::new(); + + for matched in &matched_rules { + for decl in &matched.rule.declarations { + if decl.important { + important.push((matched.specificity, matched.source_order, decl)); + } else { + normal.push((matched.specificity, matched.source_order, decl)); + } + } + } + + // Normal declarations are already sorted by specificity/order from collect_matching_rules. + // Important declarations override normal ones regardless of specificity, + // but among themselves they follow specificity order too. + let mut result: Vec<&'a Declaration> = Vec::new(); + for (_, _, decl) in &normal { + result.push(decl); + } + for (_, _, decl) in &important { + result.push(decl); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use we_css::parser::Parser; + + #[allow(dead_code)] + struct TestDom { + doc: Document, + html: NodeId, + body: NodeId, + div_main: NodeId, + p_intro: NodeId, + text_hello: NodeId, + p2: NodeId, + span: NodeId, + a_link: NodeId, + } + + fn make_test_dom() -> TestDom { + let mut doc = Document::new(); + let root = doc.root(); + + let html = doc.create_element("html"); + doc.append_child(root, html); + + let body = doc.create_element("body"); + doc.append_child(html, body); + + let div = doc.create_element("div"); + doc.set_attribute(div, "id", "main"); + doc.set_attribute(div, "class", "container wide"); + doc.append_child(body, div); + + let p1 = doc.create_element("p"); + doc.set_attribute(p1, "class", "intro"); + doc.append_child(div, p1); + + let text1 = doc.create_text("Hello"); + doc.append_child(p1, text1); + + let p2 = doc.create_element("p"); + doc.append_child(div, p2); + + let span = doc.create_element("span"); + doc.set_attribute(span, "data-x", "foo"); + doc.append_child(p2, span); + + let a = doc.create_element("a"); + doc.set_attribute(a, "href", "https://example.com"); + doc.set_attribute(a, "class", "link"); + doc.append_child(div, a); + + TestDom { + doc, + html, + body, + div_main: div, + p_intro: p1, + text_hello: text1, + p2, + span, + a_link: a, + } + } + + // ----------------------------------------------------------------------- + // Type selector + // ----------------------------------------------------------------------- + + #[test] + fn type_selector_matches() { + let t = make_test_dom(); + let sel = parse_first_selector("p {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(matches_selector(&t.doc, t.p2, &sel)); + assert!(!matches_selector(&t.doc, t.div_main, &sel)); + } + + #[test] + fn type_selector_case_insensitive() { + let t = make_test_dom(); + let sel = parse_first_selector("P {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + } + + // ----------------------------------------------------------------------- + // Universal selector + // ----------------------------------------------------------------------- + + #[test] + fn universal_selector_matches_any_element() { + let t = make_test_dom(); + let sel = parse_first_selector("* {}"); + assert!(matches_selector(&t.doc, t.html, &sel)); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(!matches_selector(&t.doc, t.text_hello, &sel)); + } + + // ----------------------------------------------------------------------- + // Class selector + // ----------------------------------------------------------------------- + + #[test] + fn class_selector_matches() { + let t = make_test_dom(); + let sel = parse_first_selector(".intro {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(!matches_selector(&t.doc, t.p2, &sel)); + } + + #[test] + fn class_selector_matches_multi_class() { + let t = make_test_dom(); + let sel = parse_first_selector(".wide {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + } + + // ----------------------------------------------------------------------- + // ID selector + // ----------------------------------------------------------------------- + + #[test] + fn id_selector_matches() { + let t = make_test_dom(); + let sel = parse_first_selector("#main {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + assert!(!matches_selector(&t.doc, t.p_intro, &sel)); + } + + // ----------------------------------------------------------------------- + // Attribute selectors + // ----------------------------------------------------------------------- + + #[test] + fn attribute_presence() { + let t = make_test_dom(); + let sel = parse_first_selector("[data-x] {}"); + assert!(matches_selector(&t.doc, t.span, &sel)); + assert!(!matches_selector(&t.doc, t.p_intro, &sel)); + } + + #[test] + fn attribute_exact_match() { + let t = make_test_dom(); + let sel = parse_first_selector("[data-x=\"foo\"] {}"); + assert!(matches_selector(&t.doc, t.span, &sel)); + + let sel2 = parse_first_selector("[data-x=\"bar\"] {}"); + assert!(!matches_selector(&t.doc, t.span, &sel2)); + } + + #[test] + fn attribute_includes() { + let t = make_test_dom(); + let sel = parse_first_selector("[class~=\"container\"] {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + + let sel2 = parse_first_selector("[class~=\"wide\"] {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel2)); + + let sel3 = parse_first_selector("[class~=\"contain\"] {}"); + assert!(!matches_selector(&t.doc, t.div_main, &sel3)); + } + + #[test] + fn attribute_prefix() { + let t = make_test_dom(); + let sel = parse_first_selector("[href^=\"https\"] {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel)); + } + + #[test] + fn attribute_suffix() { + let t = make_test_dom(); + let sel = parse_first_selector("[href$=\".com\"] {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel)); + } + + #[test] + fn attribute_substring() { + let t = make_test_dom(); + let sel = parse_first_selector("[href*=\"example\"] {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel)); + } + + #[test] + fn attribute_dash_match() { + let mut doc = Document::new(); + let root = doc.root(); + let el = doc.create_element("div"); + doc.set_attribute(el, "lang", "en-US"); + doc.append_child(root, el); + + let sel = parse_first_selector("[lang|=\"en\"] {}"); + assert!(matches_selector(&doc, el, &sel)); + + let sel2 = parse_first_selector("[lang|=\"en-US\"] {}"); + assert!(matches_selector(&doc, el, &sel2)); + + let sel3 = parse_first_selector("[lang|=\"fr\"] {}"); + assert!(!matches_selector(&doc, el, &sel3)); + } + + // ----------------------------------------------------------------------- + // Compound selectors + // ----------------------------------------------------------------------- + + #[test] + fn compound_selector() { + let t = make_test_dom(); + let sel = parse_first_selector("p.intro {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(!matches_selector(&t.doc, t.p2, &sel)); + } + + #[test] + fn compound_type_id_class() { + let t = make_test_dom(); + let sel = parse_first_selector("div#main.container {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + } + + // ----------------------------------------------------------------------- + // Descendant combinator + // ----------------------------------------------------------------------- + + #[test] + fn descendant_combinator() { + let t = make_test_dom(); + let sel = parse_first_selector("body p {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(matches_selector(&t.doc, t.p2, &sel)); + assert!(!matches_selector(&t.doc, t.div_main, &sel)); + } + + #[test] + fn descendant_combinator_deep() { + let t = make_test_dom(); + let sel = parse_first_selector("html span {}"); + assert!(matches_selector(&t.doc, t.span, &sel)); + } + + // ----------------------------------------------------------------------- + // Child combinator + // ----------------------------------------------------------------------- + + #[test] + fn child_combinator() { + let t = make_test_dom(); + let sel = parse_first_selector("div > p {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(matches_selector(&t.doc, t.p2, &sel)); + + let sel2 = parse_first_selector("div > span {}"); + assert!(!matches_selector(&t.doc, t.span, &sel2)); + } + + // ----------------------------------------------------------------------- + // Adjacent sibling combinator + // ----------------------------------------------------------------------- + + #[test] + fn adjacent_sibling_combinator() { + let t = make_test_dom(); + let sel = parse_first_selector("p.intro + p {}"); + assert!(matches_selector(&t.doc, t.p2, &sel)); + + let sel2 = parse_first_selector("p + a {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel2)); + + // p.intro + a — not adjacent (p2 is between) + let sel3 = parse_first_selector("p.intro + a {}"); + assert!(!matches_selector(&t.doc, t.a_link, &sel3)); + } + + // ----------------------------------------------------------------------- + // General sibling combinator + // ----------------------------------------------------------------------- + + #[test] + fn general_sibling_combinator() { + let t = make_test_dom(); + let sel = parse_first_selector("p ~ a {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel)); + + let sel2 = parse_first_selector("p.intro ~ a {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel2)); + } + + // ----------------------------------------------------------------------- + // Selector list + // ----------------------------------------------------------------------- + + #[test] + fn selector_list_matches() { + let t = make_test_dom(); + let ss = Parser::parse("span, a {}"); + let rule = match &ss.rules[0] { + Rule::Style(r) => r, + _ => panic!("expected style rule"), + }; + assert!(matches_selector_list(&t.doc, t.span, &rule.selectors)); + assert!(matches_selector_list(&t.doc, t.a_link, &rule.selectors)); + assert!(!matches_selector_list(&t.doc, t.div_main, &rule.selectors)); + } + + // ----------------------------------------------------------------------- + // Specificity + // ----------------------------------------------------------------------- + + #[test] + fn specificity_type_selector() { + let sel = parse_first_selector("p {}"); + assert_eq!(specificity(&sel), Specificity::new(0, 0, 1)); + } + + #[test] + fn specificity_class_selector() { + let sel = parse_first_selector(".intro {}"); + assert_eq!(specificity(&sel), Specificity::new(0, 1, 0)); + } + + #[test] + fn specificity_id_selector() { + let sel = parse_first_selector("#main {}"); + assert_eq!(specificity(&sel), Specificity::new(1, 0, 0)); + } + + #[test] + fn specificity_compound() { + let sel = parse_first_selector("div#main.container {}"); + assert_eq!(specificity(&sel), Specificity::new(1, 1, 1)); + } + + #[test] + fn specificity_complex() { + let sel = parse_first_selector("body div > p.intro {}"); + assert_eq!(specificity(&sel), Specificity::new(0, 1, 3)); + } + + #[test] + fn specificity_universal() { + let sel = parse_first_selector("* {}"); + assert_eq!(specificity(&sel), Specificity::new(0, 0, 0)); + } + + #[test] + fn specificity_attribute() { + let sel = parse_first_selector("[data-x] {}"); + assert_eq!(specificity(&sel), Specificity::new(0, 1, 0)); + } + + #[test] + fn specificity_ordering() { + let s1 = Specificity::new(0, 0, 1); + let s2 = Specificity::new(0, 1, 0); + let s3 = Specificity::new(1, 0, 0); + assert!(s1 < s2); + assert!(s2 < s3); + assert!(s1 < s3); + } + + // ----------------------------------------------------------------------- + // Collect matching rules + // ----------------------------------------------------------------------- + + #[test] + fn collect_matching_rules_basic() { + let t = make_test_dom(); + let ss = Parser::parse( + r#" + p { color: red; } + .intro { font-size: 16px; } + div { margin: 0; } + "#, + ); + + let matched = collect_matching_rules(&t.doc, t.p_intro, &ss); + assert_eq!(matched.len(), 2); + assert_eq!(matched[0].specificity, Specificity::new(0, 0, 1)); + assert_eq!(matched[1].specificity, Specificity::new(0, 1, 0)); + } + + #[test] + fn collect_matching_rules_source_order() { + let t = make_test_dom(); + let ss = Parser::parse( + r#" + p { color: red; } + p { color: blue; } + "#, + ); + + let matched = collect_matching_rules(&t.doc, t.p_intro, &ss); + assert_eq!(matched.len(), 2); + assert!(matched[0].source_order < matched[1].source_order); + } + + // ----------------------------------------------------------------------- + // Important declarations + // ----------------------------------------------------------------------- + + #[test] + fn important_declarations_come_after_normal() { + let t = make_test_dom(); + let ss = Parser::parse( + r#" + p { color: red !important; } + #main p { color: blue; } + "#, + ); + + let decls = get_declarations_for_node(&t.doc, t.p_intro, &ss); + assert_eq!(decls.len(), 2); + assert!(!decls[0].important); + assert!(decls[1].important); + } + + // ----------------------------------------------------------------------- + // Text node doesn't match + // ----------------------------------------------------------------------- + + #[test] + fn text_node_never_matches() { + let t = make_test_dom(); + let sel = parse_first_selector("* {}"); + assert!(!matches_selector(&t.doc, t.text_hello, &sel)); + } + + // ----------------------------------------------------------------------- + // Complex multi-level selector + // ----------------------------------------------------------------------- + + #[test] + fn complex_multi_level() { + let t = make_test_dom(); + let sel = parse_first_selector("html body div > p.intro {}"); + assert!(matches_selector(&t.doc, t.p_intro, &sel)); + assert!(!matches_selector(&t.doc, t.p2, &sel)); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + fn parse_first_selector(css: &str) -> Selector { + let ss = Parser::parse(css); + match &ss.rules[0] { + Rule::Style(r) => r.selectors.selectors[0].clone(), + _ => panic!("expected style rule"), + } + } +} -- 2.51.2