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}