Skip to main content

css_to_xpath/translate/
mod.rs

1//! Translation from Servo's parsed selector representation to XPath.
2
3pub mod error;
4mod generic;
5mod nth;
6mod pseudo;
7pub mod xpath_expr;
8
9pub use error::Error;
10
11use selectors::attr::{NamespaceConstraint, ParsedAttrSelectorOperation, ParsedCaseSensitivity};
12use selectors::parser::{Combinator, Component, Selector};
13
14use crate::parser::{self, CssToXpathImpl};
15use xpath_expr::{Condition, XPathExpr, is_safe_name};
16
17/// Which translator family the pseudo-class overrides come from: generic
18/// or HTML (both `html` and `xhtml` use the HTML overrides; only `html`
19/// lowercases names).
20#[derive(Clone, Copy, PartialEq, Eq)]
21pub(crate) enum Kind {
22    Generic,
23    Html,
24}
25
26/// The translator flavour: which pseudo-class overrides and name-casing
27/// rules to apply. `Html` and `Xhtml` share the HTML overrides; only
28/// `Html` lowercases element and attribute names.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum Mode {
31    Generic,
32    Html,
33    Xhtml,
34}
35
36/// One struct with a kind tag and lowercasing flags. Casing is applied
37/// here in the translator, never via Servo's parser settings, so the
38/// translator families differ only in these fields.
39pub struct Translator {
40    pub(crate) kind: Kind,
41    pub(crate) lower_case_element_names: bool,
42    pub(crate) lower_case_attribute_names: bool,
43}
44
45/// The namespace constraint on a type or attribute selector: none
46/// written, any, explicitly none, or a specific prefix.
47#[derive(Clone, Copy)]
48enum NsConstraint<'a> {
49    /// No namespace separator written (`e`, `[foo]`).
50    None,
51    /// `*|e`, `[*|foo]`: any namespace, including none.
52    Any,
53    /// `|e`, `[|foo]`: explicitly no namespace.
54    ExplicitNone,
55    /// `ns|e`, `[ns|foo]`: a specific prefix (identity-mapped, no URL).
56    Prefix(&'a str),
57}
58
59impl Translator {
60    pub fn new(mode: Mode) -> Self {
61        match mode {
62            Mode::Generic => Translator {
63                kind: Kind::Generic,
64                lower_case_element_names: false,
65                lower_case_attribute_names: false,
66            },
67            Mode::Html => Translator {
68                kind: Kind::Html,
69                lower_case_element_names: true,
70                lower_case_attribute_names: true,
71            },
72            Mode::Xhtml => Translator {
73                kind: Kind::Html,
74                lower_case_element_names: false,
75                lower_case_attribute_names: false,
76            },
77        }
78    }
79
80    /// Translate comma-separated selector groups, each prefixed, joined
81    /// with " | ".
82    pub fn css_to_xpath(&self, css: &str, prefix: &str) -> Result<String, Error> {
83        let list = parser::parse(css)?;
84        let mut parts: Vec<String> = Vec::new();
85        for sel in list.slice() {
86            parts.push(self.selector_to_xpath(sel, prefix)?);
87        }
88        Ok(parts.join(" | "))
89    }
90
91    /// Iteration bridge: Servo iterates compound selectors right-to-left
92    /// (match order), but the XPath is built left-to-right. Collect
93    /// Servo's sequences + combinators, then fold from the leftmost
94    /// compound.
95    fn selector_to_xpath(
96        &self,
97        selector: &Selector<CssToXpathImpl>,
98        prefix: &str,
99    ) -> Result<String, Error> {
100        let seqs = collect_seqs(selector);
101
102        // :scope is the node the XPath is evaluated from. In the leftmost
103        // compound it anchors the expression on the self:: axis, which
104        // replaces the prefix (`:scope > a` is `self::*/a`, the context
105        // node's `a` children). Anywhere else the context node would have
106        // to be named from inside a predicate, which XPath 1.0 cannot do.
107        let leftmost = seqs.len() - 1;
108        for (compound, _) in &seqs[..leftmost] {
109            if compound.iter().any(|c| matches!(c, Component::Scope)) {
110                return Err(Error::Unsupported(
111                    "the `:scope` pseudo-class outside the leftmost compound".into(),
112                ));
113            }
114        }
115        let scope_anchored = seqs[leftmost]
116            .0
117            .iter()
118            .any(|c| matches!(c, Component::Scope));
119
120        // Leftmost compound first, then fold rightwards.
121        let mut xpath = if scope_anchored {
122            let compound: Vec<&Component<CssToXpathImpl>> = seqs[leftmost]
123                .0
124                .iter()
125                .filter(|c| !matches!(c, Component::Scope))
126                .copied()
127                .collect();
128            let mut xp = self.compound_to_xpath(&compound)?;
129            xp.path = "self::".to_owned();
130            xp
131        } else {
132            self.compound_to_xpath(&seqs[leftmost].0)?
133        };
134        for i in (0..leftmost).rev() {
135            let combinator = seqs[i]
136                .1
137                .ok_or_else(|| Error::Unsupported("an unexpected selector structure".into()))?;
138            let right = self.compound_to_xpath(&seqs[i].0)?;
139            xpath = self.apply_combinator(combinator, xpath, &right)?;
140        }
141
142        let prefix = if scope_anchored { "" } else { prefix };
143        Ok(format!("{prefix}{}", xpath.str()))
144    }
145
146    /// Translate one compound selector (a sequence of simple selectors).
147    /// Element-ish components (namespace, type) always precede condition
148    /// components in a valid compound; conditions are applied in source
149    /// order.
150    fn compound_to_xpath(
151        &self,
152        components: &[&Component<CssToXpathImpl>],
153    ) -> Result<XPathExpr, Error> {
154        let mut ns = NsConstraint::None;
155        let mut element: Option<&str> = None;
156        let mut xpath: Option<XPathExpr> = None;
157
158        for component in components {
159            match component {
160                Component::Namespace(prefix, _) if xpath.is_none() => {
161                    ns = NsConstraint::Prefix(prefix.as_str());
162                }
163                // The sentinel default namespace (see CssToXpathParser):
164                // plain `e` and type-less compounds — no constraint written.
165                Component::DefaultNamespace(_) if xpath.is_none() => {
166                    ns = NsConstraint::None;
167                }
168                Component::ExplicitAnyNamespace if xpath.is_none() => {
169                    ns = NsConstraint::Any;
170                }
171                Component::ExplicitNoNamespace if xpath.is_none() => {
172                    ns = NsConstraint::ExplicitNone;
173                }
174                Component::ExplicitUniversalType if xpath.is_none() => {}
175                Component::LocalName(local_name) if xpath.is_none() => {
176                    element = Some(local_name.name.as_str());
177                }
178                other => {
179                    let xp = match xpath {
180                        Some(ref mut xp) => xp,
181                        None => {
182                            xpath = Some(self.xpath_element(ns, element));
183                            xpath.as_mut().expect("just set")
184                        }
185                    };
186                    self.apply_simple(xp, other)?;
187                }
188            }
189        }
190
191        Ok(match xpath {
192            Some(xp) => xp,
193            None => self.xpath_element(ns, element),
194        })
195    }
196
197    /// Build the element part of the expression from the namespace
198    /// constraint and element name.
199    fn xpath_element(&self, ns: NsConstraint, element: Option<&str>) -> XPathExpr {
200        let (mut name, mut safe) = match element {
201            None => ("*".to_owned(), true),
202            Some(e) => {
203                let safe = is_safe_name(e);
204                let e = if self.lower_case_element_names {
205                    e.to_lowercase()
206                } else {
207                    e.to_owned()
208                };
209                (e, safe)
210            }
211        };
212        match ns {
213            NsConstraint::Any if name != "*" => {
214                // '*|e': 'e' in any namespace, including none. An unprefixed
215                // XPath name test only matches the null namespace, so test
216                // against local-name() instead. The of-type nodetest counts
217                // by local name too, an approximation: siblings sharing the
218                // name across namespaces are distinct types per the spec,
219                // but XPath 1.0 cannot compare a sibling's namespace
220                // against the matched element's.
221                let cond = format!("local-name() = {}", xpath_expr::xpath_literal(&name));
222                let mut xpath = XPathExpr::new("*");
223                xpath.name_test = Some(format!("*[{cond}]"));
224                xpath.add_condition(&cond);
225                return xpath;
226            }
227            NsConstraint::ExplicitNone if name == "*" || !safe => {
228                // A safe '|e' is just an unprefixed XPath name test, which
229                // matches exactly the null namespace. '|*' and names
230                // needing quoting check namespace-uri() explicitly: a
231                // quoted name() test alone would also match the name in a
232                // default namespace.
233                let mut xpath = XPathExpr::new(&name);
234                xpath.add_name_test();
235                xpath.add_condition("namespace-uri() = ''");
236                if name != "*" {
237                    // The of-type nodetest must carry the namespace pin
238                    // set by the condition above
239                    xpath.name_test = Some(format!(
240                        "*[name() = {} and namespace-uri() = '']",
241                        xpath_expr::xpath_literal(&name)
242                    ));
243                }
244                return xpath;
245            }
246            NsConstraint::Prefix(prefix) => {
247                // Namespace prefixes are case-sensitive.
248                // https://www.w3.org/TR/css-namespaces-3/#prefixes
249                safe = safe && is_safe_name(prefix);
250                name = format!("{prefix}:{name}");
251            }
252            // '*|*' and '|e' translate to an unqualified name test.
253            _ => {}
254        }
255        let mut xpath = XPathExpr::new(&name);
256        if !safe {
257            xpath.add_name_test();
258        }
259        xpath
260    }
261
262    /// Dispatch over the non-element components of a compound — the
263    /// allow-list over `Component` variants. Anything outside the
264    /// supported construct set errors, never approximates.
265    fn apply_simple(
266        &self,
267        xpath: &mut XPathExpr,
268        component: &Component<CssToXpathImpl>,
269    ) -> Result<(), Error> {
270        match component {
271            // :root
272            Component::Root => {
273                xpath.add_condition("not(parent::*)");
274                Ok(())
275            }
276            // :empty
277            Component::Empty => {
278                xpath.add_condition("not(*) and not(string-length())");
279                Ok(())
280            }
281            // :first-child, :nth-child(an+b), :only-of-type, ... — Servo
282            // collapses the whole family into NthSelectorData.
283            Component::Nth(data) => self.apply_nth(xpath, data, None),
284            // :nth-child(an+b of S) / :nth-last-child(an+b of S)
285            Component::NthOf(data) => {
286                self.apply_nth(xpath, data.nth_data(), Some(data.selectors()))
287            }
288            // :not(). Nesting inside other functional pseudo-classes is
289            // allowed (Selectors Level 4).
290            Component::Negation(list) => {
291                match self.arg_conditions(list.slice(), ":not()")? {
292                    Some(conditions) if !conditions.is_empty() => {
293                        // not(...) supplies its own grouping, so the
294                        // or-join needs no parentheses.
295                        let joined = Condition::join_or(&conditions);
296                        xpath.add_condition(&format!("not({})", joined.expr));
297                    }
298                    // A universal argument makes the negation unmatchable.
299                    _ => xpath.add_condition("0"),
300                }
301                Ok(())
302            }
303            // :is()/:matches() and :where() — identical translations: the
304            // arguments OR together into a single condition that is AND-ed
305            // onto the outer expression, keeping the compound a conjunction.
306            Component::Is(list) | Component::Where(list) => {
307                let context = match component {
308                    Component::Is(_) => ":is()",
309                    _ => ":where()",
310                };
311                // None means an argument matched everything, so the whole
312                // pseudo-class is a no-op constraint.
313                if let Some(conditions) = self.arg_conditions(list.slice(), context)?
314                    && !conditions.is_empty()
315                {
316                    xpath.push_condition(Condition::join_or(&conditions));
317                }
318                Ok(())
319            }
320            // :has(): each argument is a relative selector whose optional
321            // leading combinator scopes the match (`>` child, `~`
322            // subsequent sibling, `+` next sibling; omitted means
323            // descendant). Unlike the other functional pseudo-classes,
324            // :has() looks forward, so a complex argument extends the
325            // existence-test path step by step, leftmost compound first.
326            Component::Has(relatives) => {
327                let mut conditions: Vec<String> = Vec::new();
328                for relative in relatives.iter() {
329                    let seqs = collect_seqs(&relative.selector);
330                    // The leftmost sequence is the anchor (the candidate
331                    // element itself); its combinator slot carries the
332                    // argument's leading combinator.
333                    let anchor = &seqs[seqs.len() - 1].0;
334                    let anchor_only = seqs.len() >= 2
335                        && anchor.len() == 1
336                        && matches!(anchor[0], Component::RelativeSelectorAnchor);
337                    if !anchor_only {
338                        return Err(Error::Unsupported(
339                            "an unexpected selector structure inside `:has()`".into(),
340                        ));
341                    }
342                    let mut test = String::new();
343                    for i in (0..seqs.len() - 1).rev() {
344                        let first = i == seqs.len() - 2;
345                        let combinator = seqs[i].1;
346                        // The first step is an axis from the candidate
347                        // element; later steps join onto the path.
348                        let axis = match (first, combinator) {
349                            (true, Some(Combinator::Descendant)) => ".//",
350                            (true, Some(Combinator::Child)) => "child::",
351                            (
352                                true,
353                                Some(Combinator::NextSibling) | Some(Combinator::LaterSibling),
354                            ) => "following-sibling::",
355                            (false, Some(Combinator::Descendant)) => "//",
356                            (false, Some(Combinator::Child)) => "/",
357                            (
358                                false,
359                                Some(Combinator::NextSibling) | Some(Combinator::LaterSibling),
360                            ) => "/following-sibling::",
361                            (_, other) => {
362                                return Err(Error::Unsupported(format!(
363                                    "an unexpected combinator ({other:?}) inside `:has()`"
364                                )));
365                            }
366                        };
367                        let mut sub = self.compound_to_xpath(&seqs[i].0)?;
368                        // A prefixed name stays in the node test
369                        // (`.//svg:g`) so it resolves through the
370                        // namespace map, except under `+` where the [1]
371                        // position predicate needs the node test to
372                        // stay `*`.
373                        if !sub.element.contains(':') {
374                            sub.add_name_test();
375                        } else if matches!(combinator, Some(Combinator::NextSibling)) {
376                            let element = std::mem::replace(&mut sub.element, "*".to_owned());
377                            sub.add_condition(&format!("self::{element}"));
378                        }
379                        if matches!(combinator, Some(Combinator::NextSibling)) {
380                            // Only the immediately following sibling:
381                            // constrain position before applying the match
382                            // conditions.
383                            sub.add_predicate("1");
384                        }
385                        test.push_str(axis);
386                        test.push_str(&sub.str());
387                    }
388                    conditions.push(test);
389                }
390                if !conditions.is_empty() {
391                    xpath.add_condition(&conditions.join(" | "));
392                }
393                Ok(())
394            }
395            // :hover, :checked, :lang(), ... — translator-dependent.
396            Component::NonTSPseudoClass(pc) => self.apply_pseudo_class(xpath, pc),
397            // e#myid
398            Component::ID(id) => {
399                self.attrib_equals(xpath, "@id", id.as_str());
400                Ok(())
401            }
402            // .foo is defined as [class~=foo] in the spec
403            Component::Class(class_name) => {
404                self.attrib_includes(xpath, "@class", class_name.as_str());
405                Ok(())
406            }
407            Component::AttributeInNoNamespaceExists { local_name, .. } => {
408                let attrib = self.attrib_expr(NsConstraint::None, local_name.as_str());
409                xpath.add_condition(&attrib);
410                Ok(())
411            }
412            Component::AttributeInNoNamespace {
413                local_name,
414                operator,
415                value,
416                case_sensitivity,
417            } => {
418                let attrib = self.attrib_expr(NsConstraint::None, local_name.as_str());
419                let (attrib, value) = apply_case_flag(attrib, value.as_str(), case_sensitivity);
420                self.attrib_operator(xpath, &attrib, *operator, &value)
421            }
422            Component::AttributeOther(attr) => {
423                let ns = match attr.namespace {
424                    Some(NamespaceConstraint::Specific((ref prefix, _))) => {
425                        NsConstraint::Prefix(prefix.as_str())
426                    }
427                    Some(NamespaceConstraint::Any) => NsConstraint::Any,
428                    // '[|foo]' is equivalent to '[foo]': unprefixed
429                    // attribute names have no namespace.
430                    None => NsConstraint::None,
431                };
432                let attrib = self.attrib_expr(ns, attr.local_name.as_str());
433                match attr.operation {
434                    ParsedAttrSelectorOperation::Exists => {
435                        xpath.add_condition(&attrib);
436                        Ok(())
437                    }
438                    ParsedAttrSelectorOperation::WithValue {
439                        operator,
440                        case_sensitivity,
441                        ref value,
442                    } => {
443                        let (attrib, value) =
444                            apply_case_flag(attrib, value.as_str(), &case_sensitivity);
445                        self.attrib_operator(xpath, &attrib, operator, &value)
446                    }
447                }
448            }
449            unsupported => Err(Error::Unsupported(describe_component(unsupported))),
450        }
451    }
452
453    /// Attribute-name handling: lowercase (html), safety check, namespace
454    /// qualification (note: a specific namespace prefix is not part of the
455    /// safety check).
456    fn attrib_expr(&self, ns: NsConstraint, local_name: &str) -> String {
457        let name = if self.lower_case_attribute_names {
458            local_name.to_lowercase()
459        } else {
460            local_name.to_owned()
461        };
462        let safe = is_safe_name(&name);
463        match ns {
464            NsConstraint::Any => {
465                // '[*|attr]': 'attr' in any namespace, including none. An
466                // unprefixed XPath attribute test only matches attributes
467                // with no namespace, so test against local-name() instead.
468                format!("@*[local-name() = {}]", xpath_expr::xpath_literal(&name))
469            }
470            NsConstraint::Prefix(prefix) => {
471                let name = format!("{prefix}:{name}");
472                if safe {
473                    format!("@{name}")
474                } else {
475                    format!(
476                        "attribute::*[name() = {}]",
477                        xpath_expr::xpath_literal(&name)
478                    )
479                }
480            }
481            NsConstraint::None | NsConstraint::ExplicitNone => {
482                if safe {
483                    format!("@{name}")
484                } else {
485                    format!(
486                        "attribute::*[name() = {}]",
487                        xpath_expr::xpath_literal(&name)
488                    )
489                }
490            }
491        }
492    }
493
494    /// Join two compound translations with a combinator.
495    fn apply_combinator(
496        &self,
497        combinator: Combinator,
498        mut left: XPathExpr,
499        right: &XPathExpr,
500    ) -> Result<XPathExpr, Error> {
501        match combinator {
502            Combinator::Descendant => left.join("//", right),
503            Combinator::Child => left.join("/", right),
504            Combinator::LaterSibling => left.join("/following-sibling::", right),
505            Combinator::NextSibling => {
506                left.join("/following-sibling::", right);
507                // The node test moves into a self:: predicate so the [1]
508                // position test counts every sibling, not only same-name
509                // ones: *[1][self::element][existing conditions].
510                let target_element = std::mem::replace(&mut left.element, "*".to_owned());
511                left.add_predicate("1");
512                left.add_predicate(&format!("self::{target_element}"));
513            }
514            // PseudoElement / SlotAssignment / Part combinators can never be
515            // produced: the corresponding parser hooks are disabled.
516            other => {
517                return Err(Error::Unsupported(format!("the {other:?} combinator")));
518            }
519        }
520        Ok(left)
521    }
522
523    /// Harvest the conditions of a pseudo-class argument list, the shared
524    /// pattern of :not()/:is()/:where() and the nth `of S` handling:
525    /// translate each argument into a condition on the candidate element.
526    ///
527    /// Returns `None` when any argument matches everything (e.g. `*`): the
528    /// OR of the list is then trivially true, so callers must not constrain
529    /// on the remaining arguments.
530    fn arg_conditions(
531        &self,
532        selectors: &[Selector<CssToXpathImpl>],
533        context: &str,
534    ) -> Result<Option<Vec<Condition>>, Error> {
535        let mut conditions = Vec::new();
536        let mut trivially_true = false;
537        for selector in selectors {
538            let seqs = collect_seqs(selector);
539            match self.argument_condition(&seqs, 0, context)? {
540                None => trivially_true = true,
541                Some(condition) => conditions.push(condition),
542            }
543        }
544        Ok(if trivially_true {
545            None
546        } else {
547            Some(conditions)
548        })
549    }
550
551    /// The condition imposed on the candidate element by the argument
552    /// chain from `seqs[idx]` leftwards. The compound's element becomes a
553    /// condition — a `self::` node test for prefixed names (so the prefix
554    /// resolves through the namespace map, like a top-level `svg|g`), a
555    /// `name()` comparison otherwise. A complex argument applies its
556    /// rightmost compound to the candidate, with everything to its left
557    /// becoming an existence test through reversed axes, recursively:
558    /// `:is(a > b ~ c)` matches a `c` with a preceding sibling `b` whose
559    /// parent is an `a`.
560    ///
561    /// `None` means the chain imposes no condition (a bare `*` argument).
562    fn argument_condition(
563        &self,
564        seqs: &[(Vec<&Component<CssToXpathImpl>>, Option<Combinator>)],
565        idx: usize,
566        context: &str,
567    ) -> Result<Option<Condition>, Error> {
568        let (compound, combinator) = &seqs[idx];
569        let mut sub = self.compound_to_xpath(compound)?;
570        if sub.element.contains(':') {
571            let element = std::mem::replace(&mut sub.element, "*".to_owned());
572            sub.add_condition(&format!("self::{element}"));
573        } else {
574            sub.add_name_test();
575        }
576        if idx + 1 < seqs.len() {
577            // The axis pointing back at where the left-hand side of the
578            // combinator must be, relative to the element matched here.
579            let axis = match combinator {
580                Some(Combinator::Descendant) => "ancestor::*",
581                Some(Combinator::Child) => "parent::*",
582                Some(Combinator::LaterSibling) => "preceding-sibling::*",
583                Some(Combinator::NextSibling) => "preceding-sibling::*[1]",
584                other => {
585                    return Err(Error::Unsupported(format!(
586                        "an unexpected combinator ({other:?}) inside `{context}`"
587                    )));
588                }
589            };
590            let rev_test = match self.argument_condition(seqs, idx + 1, context)? {
591                Some(inner) => format!("{axis}[{}]", inner.expr),
592                None => axis.to_owned(),
593            };
594            sub.add_condition(&rev_test);
595        }
596        Ok(sub.condition())
597    }
598}
599
600/// Collect a selector's compound sequences in match order: `seqs[i]` is
601/// (compound, combinator between this compound and the one to its left),
602/// so `seqs[0]` is the rightmost compound and only the last entry's
603/// combinator is `None`.
604fn collect_seqs(
605    selector: &Selector<CssToXpathImpl>,
606) -> Vec<(Vec<&Component<CssToXpathImpl>>, Option<Combinator>)> {
607    let mut iter = selector.iter();
608    let mut seqs: Vec<(Vec<&Component<CssToXpathImpl>>, Option<Combinator>)> = Vec::new();
609    loop {
610        let compound: Vec<&Component<CssToXpathImpl>> = (&mut iter).collect();
611        let combinator = iter.next_sequence();
612        let done = combinator.is_none();
613        seqs.push((compound, combinator));
614        if done {
615            break;
616        }
617    }
618    seqs
619}
620
621/// The Level 4 case-sensitivity flag handling.
622///
623/// `[attr="value" i]`: compare the ASCII-lowercased attribute (via XPath
624/// `translate()`) against the ASCII-lowercased value. An empty value needs
625/// no lowercasing, and skipping it keeps the existence tests exact. The `s`
626/// flag, the no-flag default, and Servo's HTML-legacy-attribute default all
627/// mean the ordinary case-sensitive translation.
628fn apply_case_flag(
629    attrib: String,
630    value: &str,
631    case_sensitivity: &ParsedCaseSensitivity,
632) -> (String, String) {
633    match case_sensitivity {
634        ParsedCaseSensitivity::AsciiCaseInsensitive if !value.is_empty() => (
635            format!(
636                "translate({attrib}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', \
637                 'abcdefghijklmnopqrstuvwxyz')"
638            ),
639            value.to_ascii_lowercase(),
640        ),
641        _ => (attrib, value.to_owned()),
642    }
643}
644
645/// Human-readable construct names for unsupported-error messages.
646fn describe_component(component: &Component<CssToXpathImpl>) -> String {
647    match component {
648        // Top-level :scope is handled (or rejected) in selector_to_xpath,
649        // so reaching this arm means :scope sits inside a functional
650        // pseudo-class argument, where the context node is unreachable.
651        Component::Scope | Component::ImplicitScope => {
652            "the `:scope` pseudo-class inside a functional pseudo-class".into()
653        }
654        Component::Slotted(..) => "the `::slotted()` pseudo-element".into(),
655        Component::Part(..) => "the `::part()` pseudo-element".into(),
656        Component::Host(..) => "the `:host` pseudo-class".into(),
657        Component::ParentSelector => "the `&` parent selector".into(),
658        // PseudoElement carries an uninhabited type and the remaining
659        // variants require parser features this crate never enables; they
660        // are unreachable, but erroring beats panicking (panic = abort
661        // would tear down the caller's process).
662        other => format!("an unexpected construct ({other:?})"),
663    }
664}