css_to_xpath/parser/mod.rs
1//! `SelectorImpl` and `Parser` implementations bridging Servo's `selectors`
2//! crate to this crate's translator.
3
4mod impls;
5
6use cssparser::{
7 Parser as CssParser, ParserInput, SourceLocation, ToCss, Token, match_ignore_ascii_case,
8};
9use selectors::parser::{
10 Component, NonTSPseudoClass, ParseRelative, PseudoElement, RelativeSelector, Selector,
11 SelectorImpl, SelectorList, SelectorParseErrorKind,
12};
13use selectors::visitor::{SelectorListKind, SelectorVisitor};
14use std::fmt;
15
16pub(crate) use impls::CssString;
17
18use crate::translate::error::{Error, ParseErrorKind};
19
20#[derive(Clone, Debug)]
21pub(crate) struct CssToXpathImpl;
22
23impl SelectorImpl for CssToXpathImpl {
24 type ExtraMatchingData<'a> = ();
25 type AttrValue = CssString;
26 type Identifier = CssString;
27 type LocalName = CssString;
28 type NamespaceUrl = CssString;
29 type NamespacePrefix = CssString;
30 type BorrowedNamespaceUrl = str;
31 type BorrowedLocalName = str;
32 type NonTSPseudoClass = PseudoClass;
33 type PseudoElement = NeverPseudoElement;
34}
35
36/// The non-tree-structural pseudo-classes the translators know.
37/// Everything here is the "never matches" set under the generic
38/// translator; the HTML translator overrides `:checked`, `:link`,
39/// `:enabled`, `:disabled`, the form-state family (`:read-only`,
40/// `:read-write`, `:default`, `:placeholder-shown`), and `:lang()`. Any
41/// other pseudo name is rejected at parse time (tree-structural pseudos
42/// are parsed natively by Servo and never reach this type).
43///
44/// Policy for what belongs here versus erroring: pseudo-classes whose
45/// semantics rest on user or runtime state a static document cannot have
46/// (the user-action, link, and target families) parse and never match, as
47/// does `:dir()`, whose *resolved* directionality needs the bidi
48/// algorithm rather than the document tree (see `apply_pseudo_class`).
49/// Names that are unknown, or whose semantics rest on machinery outside
50/// the document tree that a static translation would have to guess at
51/// (`:valid` and the constraint-validation family, `:indeterminate`,
52/// whose checkbox state is IDL-only and whose radio-group arm XPath 1.0
53/// cannot express, `:defined`), error instead, so typos and genuinely
54/// missing features stay loud.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub(crate) enum PseudoClass {
57 AnyLink,
58 Link,
59 Visited,
60 Hover,
61 Active,
62 Focus,
63 FocusWithin,
64 FocusVisible,
65 Target,
66 TargetWithin,
67 LocalLink,
68 Enabled,
69 Disabled,
70 Checked,
71 Required,
72 Optional,
73 ReadOnly,
74 ReadWrite,
75 Default,
76 PlaceholderShown,
77 /// The comma-separated language ranges of `:lang()`, each
78 /// reassembled from the tokens it was spelled with (see
79 /// [`is_valid_lang_range`]).
80 Lang(Vec<String>),
81 /// The single identifier of `:dir()`, kept only so the selector can
82 /// be serialized back: the translation never matches whatever it
83 /// says, so `:dir(rtl)` and `:dir(foo)` translate alike. Selectors 4
84 /// defines `ltr` and `rtl`; any other identifier is accepted rather
85 /// than rejected, since no value can change the output.
86 Dir(String),
87}
88
89impl PseudoClass {
90 fn name(&self) -> &'static str {
91 match self {
92 PseudoClass::AnyLink => "any-link",
93 PseudoClass::Link => "link",
94 PseudoClass::Visited => "visited",
95 PseudoClass::Hover => "hover",
96 PseudoClass::Active => "active",
97 PseudoClass::Focus => "focus",
98 PseudoClass::FocusWithin => "focus-within",
99 PseudoClass::FocusVisible => "focus-visible",
100 PseudoClass::Target => "target",
101 PseudoClass::TargetWithin => "target-within",
102 PseudoClass::LocalLink => "local-link",
103 PseudoClass::Enabled => "enabled",
104 PseudoClass::Disabled => "disabled",
105 PseudoClass::Checked => "checked",
106 PseudoClass::Required => "required",
107 PseudoClass::Optional => "optional",
108 PseudoClass::ReadOnly => "read-only",
109 PseudoClass::ReadWrite => "read-write",
110 PseudoClass::Default => "default",
111 PseudoClass::PlaceholderShown => "placeholder-shown",
112 PseudoClass::Lang(_) => "lang",
113 PseudoClass::Dir(_) => "dir",
114 }
115 }
116}
117
118impl ToCss for PseudoClass {
119 fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
120 dest.write_char(':')?;
121 dest.write_str(self.name())?;
122 match self {
123 PseudoClass::Lang(ranges) => {
124 dest.write_char('(')?;
125 for (i, range) in ranges.iter().enumerate() {
126 if i > 0 {
127 dest.write_str(", ")?;
128 }
129 // A range is written back as the token sequence it
130 // was parsed from: `*` cannot be part of an
131 // identifier, so the pieces around it are serialized
132 // separately (`en-*` as the ident `en-` then `*`).
133 // The empty range has no such token — an identifier
134 // cannot be empty — and is written as the string it
135 // has to be written as.
136 if range.is_empty() {
137 dest.write_str("\"\"")?;
138 }
139 for (j, piece) in range.split('*').enumerate() {
140 if j > 0 {
141 dest.write_char('*')?;
142 }
143 if !piece.is_empty() {
144 cssparser::serialize_identifier(piece, dest)?;
145 }
146 }
147 }
148 dest.write_char(')')
149 }
150 PseudoClass::Dir(value) => {
151 dest.write_char('(')?;
152 cssparser::serialize_identifier(value, dest)?;
153 dest.write_char(')')
154 }
155 _ => Ok(()),
156 }
157 }
158}
159
160impl NonTSPseudoClass for PseudoClass {
161 type Impl = CssToXpathImpl;
162
163 fn is_active_or_hover(&self) -> bool {
164 matches!(self, PseudoClass::Active | PseudoClass::Hover)
165 }
166
167 fn is_user_action_state(&self) -> bool {
168 matches!(
169 self,
170 PseudoClass::Active
171 | PseudoClass::Hover
172 | PseudoClass::Focus
173 | PseudoClass::FocusWithin
174 | PseudoClass::FocusVisible
175 )
176 }
177}
178
179/// Uninhabited: `parse_pseudo_element` is left at its erroring default, so
180/// `::before` etc. fail to parse — pseudo-elements are not supported.
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub(crate) enum NeverPseudoElement {}
183
184impl ToCss for NeverPseudoElement {
185 // The standard way to write a total match on an uninhabited type.
186 // `&self` here can never be a live reference, so clippy's warning
187 // about dereferencing one describes a call that cannot happen.
188 #[allow(clippy::uninhabited_references)]
189 fn to_css<W: fmt::Write>(&self, _dest: &mut W) -> fmt::Result {
190 match *self {}
191 }
192}
193
194impl PseudoElement for NeverPseudoElement {
195 type Impl = CssToXpathImpl;
196}
197
198pub(crate) struct CssToXpathParser<'a> {
199 /// Whether Servo may recover from an invalid `:is()` / `:where()`
200 /// argument instead of failing the whole parse. Only the retry in
201 /// [`parse`] sets this, and it then rejects every recovery bar the
202 /// empty argument list.
203 forgiving: bool,
204 /// The caller's default namespace prefix, or the empty sentinel
205 /// (see [`CssToXpathParser::default_namespace`]). Built once per
206 /// parse, since Servo asks for it once per compound.
207 default_namespace: &'a CssString,
208}
209
210impl<'i> selectors::parser::Parser<'i> for CssToXpathParser<'_> {
211 type Impl = CssToXpathImpl;
212 type Error = SelectorParseErrorKind<'i>;
213
214 /// Strict unless [`parse`] is retrying: a selector that fails to
215 /// parse must surface an error, never be silently dropped the way
216 /// forgiving `:is()`/`:where()` parsing would.
217 fn allow_forgiving_selectors(&self) -> bool {
218 self.forgiving
219 }
220
221 /// Enable `:is()` and `:where()`.
222 fn parse_is_and_where(&self) -> bool {
223 true
224 }
225
226 /// `:matches()` is the legacy alias for `:is()`.
227 fn is_is_alias(&self, name: &str) -> bool {
228 name.eq_ignore_ascii_case("matches")
229 }
230
231 /// Enable `:has()`. The translator restricts the arguments to
232 /// compound selectors (with an optional leading combinator).
233 fn parse_has(&self) -> bool {
234 true
235 }
236
237 /// `:nth-child(an+b of S)` / `:nth-last-child(an+b of S)`,
238 /// CSS Selectors Level 4.
239 fn parse_nth_child_of(&self) -> bool {
240 true
241 }
242
243 /// The supported non-tree-structural pseudo-classes: the "never
244 /// matches" set plus the HTML-translator overrides. Anything else
245 /// errors (see the policy note on `PseudoClass`).
246 fn parse_non_ts_pseudo_class(
247 &self,
248 location: SourceLocation,
249 name: cssparser::CowRcStr<'i>,
250 ) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
251 let pc = match_ignore_ascii_case! { &name,
252 "any-link" => PseudoClass::AnyLink,
253 "link" => PseudoClass::Link,
254 "visited" => PseudoClass::Visited,
255 "hover" => PseudoClass::Hover,
256 "active" => PseudoClass::Active,
257 "focus" => PseudoClass::Focus,
258 "focus-within" => PseudoClass::FocusWithin,
259 "focus-visible" => PseudoClass::FocusVisible,
260 "target" => PseudoClass::Target,
261 "target-within" => PseudoClass::TargetWithin,
262 "local-link" => PseudoClass::LocalLink,
263 "enabled" => PseudoClass::Enabled,
264 "disabled" => PseudoClass::Disabled,
265 "checked" => PseudoClass::Checked,
266 "required" => PseudoClass::Required,
267 "optional" => PseudoClass::Optional,
268 "read-only" => PseudoClass::ReadOnly,
269 "read-write" => PseudoClass::ReadWrite,
270 "default" => PseudoClass::Default,
271 "placeholder-shown" => PseudoClass::PlaceholderShown,
272 _ => {
273 return Err(location.new_custom_error(
274 SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
275 ));
276 },
277 };
278 Ok(pc)
279 }
280
281 /// `:lang()` argument grammar: a comma-separated list of at least
282 /// one language range, each an ident or string optionally glued to
283 /// `*` wildcards. Whitespace is allowed only around the commas: it
284 /// is a range *terminator*, never a separator, so `:lang(en fr)` is
285 /// an error rather than two ranges, and `en *` is not the range
286 /// `en-*`. A range is assembled here, while the tokens' adjacency is
287 /// still known — the tokenizer splits `en-*` into an ident and a
288 /// delimiter. NUMBER/`+`/`-` tokens are rejected. `:dir()` is
289 /// stricter, matching its selectors-4 grammar: exactly one
290 /// identifier.
291 ///
292 /// What the assembled text *says* is not judged here: a range whose
293 /// subtags are malformed (`en-`, `*en`) is a well-formed argument
294 /// that no language tag can match, and it is the translators that
295 /// reject it — by then the range is in hand and the message can name
296 /// it (see `check_lang_range`).
297 ///
298 /// The non-standard text-content pseudo `:contains()` is deliberately
299 /// unsupported and falls through to the rejection arm, as does any
300 /// unknown functional pseudo.
301 fn parse_non_ts_functional_pseudo_class<'t>(
302 &self,
303 name: cssparser::CowRcStr<'i>,
304 parser: &mut CssParser<'i, 't>,
305 _after_part: bool,
306 ) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
307 if name.eq_ignore_ascii_case("dir") {
308 let value = match parser.next() {
309 Ok(Token::Ident(v)) => v.as_ref().to_owned(),
310 _ => {
311 return Err(parser.new_custom_error(
312 SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
313 ));
314 }
315 };
316 if parser.next().is_ok() {
317 return Err(parser.new_custom_error(
318 SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
319 ));
320 }
321 return Ok(PseudoClass::Dir(value));
322 }
323 if !name.eq_ignore_ascii_case("lang") {
324 return Err(parser.new_custom_error(
325 SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
326 ));
327 }
328
329 match parse_lang_ranges(parser) {
330 Some(ranges) => Ok(PseudoClass::Lang(ranges)),
331 None => Err(parser.new_custom_error(
332 SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
333 )),
334 }
335 }
336
337 /// Identity mapping: `svg|g` translates to `svg:g` — a prefix-only
338 /// namespace model with no URL maps.
339 fn namespace_for_prefix(&self, prefix: &CssString) -> Option<CssString> {
340 Some(prefix.clone())
341 }
342
343 /// The caller's default namespace prefix, or a sentinel standing in
344 /// for "none set".
345 ///
346 /// A default namespace is always reported, because without one Servo
347 /// drops the namespace component from both `e` and `*|e` (they match
348 /// identically), and the two must translate differently (`e` vs a
349 /// `local-name()` test). So with none set, plain `e` carries
350 /// `DefaultNamespace("")` — mapped to "no constraint" — while `*|e`
351 /// keeps `ExplicitAnyNamespace`. The empty string can never collide
352 /// with a real prefix (prefixes are non-empty idents, and
353 /// `namespace_for_prefix` is the identity), which is also what makes
354 /// an empty configured prefix mean "no default namespace".
355 ///
356 /// With a prefix set, Servo applies CSS Namespaces 3 for us: the
357 /// prefix reaches `DefaultNamespace` for type selectors and for the
358 /// implicit universal of a type-less compound, but not for the
359 /// featureless compounds of an `:is()` / `:where()` / `:not()`
360 /// argument, and a written `h|e` naming the same prefix collapses
361 /// onto the same component.
362 fn default_namespace(&self) -> Option<CssString> {
363 Some(self.default_namespace.clone())
364 }
365}
366
367/// The body of the `:lang()` argument grammar: the comma-separated
368/// ranges, or `None` if the arguments do not spell out at least one
369/// range. Assembling happens here rather than at translation time
370/// because only the token stream records whether two pieces were
371/// adjacent, and adjacency is the whole difference between the range
372/// `en-*` and the pair `en-`, `*`.
373///
374/// A range is any assembled text, the empty string included: `:lang("")`
375/// is the Level 4 "language not known" range, and the shapes that cannot
376/// match anything are the translators' to reject.
377fn parse_lang_ranges<'i>(parser: &mut CssParser<'i, '_>) -> Option<Vec<String>> {
378 let mut ranges: Vec<String> = Vec::new();
379 let mut current = String::new();
380 // Whether `current` has a piece yet, and whether the next piece
381 // would be adjacent to it. The two are distinct because an empty
382 // string is a piece: `:lang("" *)` has started a range even though
383 // `current` is still empty.
384 let mut started = false;
385 let mut adjacent = true;
386 loop {
387 // Whitespace and comments both terminate a range, so neither may
388 // be skipped over here.
389 let token = match parser.next_including_whitespace_and_comments() {
390 Ok(t) => t.clone(),
391 Err(_) => break, // end of the function's arguments
392 };
393 let piece = match token {
394 Token::WhiteSpace(_) | Token::Comment(_) => {
395 adjacent = false;
396 continue;
397 }
398 Token::Comma => {
399 if !started {
400 return None; // an empty range slot: `,en`, `en,,fr`
401 }
402 ranges.push(std::mem::take(&mut current));
403 (started, adjacent) = (false, true);
404 continue;
405 }
406 Token::Ident(ref v) | Token::QuotedString(ref v) => v.as_ref().to_owned(),
407 Token::Delim('*') => "*".to_owned(),
408 _ => return None,
409 };
410 if started && !adjacent {
411 return None; // two ranges with no comma between them
412 }
413 current.push_str(&piece);
414 started = true;
415 // Adjacency is a fact about the gap between two tokens, so it is
416 // restored the moment a piece is taken: only whitespace or a
417 // comment *after* this one can separate it from the next. Leaving
418 // it false here would spread a run of whitespace over the rest of
419 // the range and reject a multi-token range after a comma, so that
420 // `:lang(*-CH, en)` parsed but `:lang(en, *-CH)` did not.
421 adjacent = true;
422 }
423 if !started {
424 return None; // no ranges at all, or a trailing comma
425 }
426 ranges.push(current);
427 Some(ranges)
428}
429
430/// The maximum functional-pseudo-class nesting depth accepted, measured
431/// as parenthesis nesting in the source selector. Both Servo's parser and
432/// this crate's translator recurse once per nesting level (as does
433/// dropping the resulting selector tree), so an unbounded depth would
434/// overflow the stack — a hard abort, not a panic, so the caller cannot
435/// catch it.
436///
437/// The value is set from the profile that costs the most stack per level,
438/// against the smallest stack the crate can be run on. An unoptimized
439/// build spends about 16 KB a level (against about 4 KB optimized), so 32
440/// levels need roughly 600 KB: a comfortable fit in the 1 MiB a library
441/// does not get to choose — the default reserve of a Windows main thread,
442/// rustc's `wasm32-unknown-unknown` stack, and whatever a thread pool
443/// hands its workers. Sizing against Rust's more generous 2 MB default
444/// for a spawned thread instead would let a debug build abort on those
445/// targets at a depth this limit promises to accept, which is the limit
446/// failing at the one job it has.
447///
448/// 32 is still far beyond any hand-written selector, and the depth counted
449/// is every parenthesis pair, including ones that do not recurse at all
450/// (`:nth-child(2n+1)`, `:lang(en)`) and ones that spend two per level
451/// (`:nth-child(2 of :is(…))`), so real selectors sit further under it
452/// than the number suggests.
453pub const MAX_NESTING_DEPTH: usize = 32;
454
455/// The facts about a selector that must be known before Servo is entered,
456/// gathered in one linear walk that skips strings, escapes, and comments.
457struct Scan {
458 /// The byte offset of the first `|` of the Level 4 column combinator
459 /// `||`, if the selector uses one. Outside strings, escapes, and
460 /// comments a doubled pipe can only be that combinator (a single `|`
461 /// occurs in namespace prefixes and `|=`, never doubled). Servo has
462 /// no column-combinator support and its parse error misreads the
463 /// second pipe as namespace syntax
464 /// (`ExplicitNamespaceUnexpectedToken`), so the construct is caught
465 /// before parsing and named properly. Column selection has no XPath
466 /// 1.0 translation anyway: column membership depends on
467 /// `colspan`/`rowspan` layout arithmetic.
468 column_combinator: Option<usize>,
469 /// The byte offset of the first `(` that opened a level deeper than
470 /// [`MAX_NESTING_DEPTH`], if any — the point at which the selector
471 /// went too deep for the parser and translator to recurse through
472 /// safely, and so the point to put a caret under. The *first* such
473 /// parenthesis, not the innermost, so the position does not move
474 /// with however much deeper the rest of the selector goes.
475 too_deep: Option<usize>,
476 /// The byte offset of the first `&` nesting selector, if the
477 /// selector uses one. Outside strings, escapes, and comments an `&`
478 /// can only be that selector: it appears in no other selector
479 /// production. This crate parses with nesting disabled — a `&` has
480 /// no meaning without the enclosing rule a selector-to-XPath
481 /// function never sees — so Servo does not recognise it as the
482 /// start of a compound and fails on whatever comes next instead,
483 /// reporting `&` as an empty selector or a dangling combinator.
484 /// Catching it here names the construct the caller actually wrote.
485 nesting_selector: Option<usize>,
486 /// The byte offset of the first `:scope` this crate cannot
487 /// translate, and which of the two ways it is out of place. Both
488 /// are lexical facts — `:scope` is unsupported inside any
489 /// functional pseudo-class argument, and at the top level anywhere
490 /// but the leftmost compound of its group — so the walk can decide
491 /// them from the parenthesis depth and whether a combinator has
492 /// been passed, and hand the translator's own check a position it
493 /// has no way to recover. See [`ScopeSite`].
494 misplaced_scope: Option<(usize, ScopeSite)>,
495 /// The byte offset of the first `:host(`, if the selector uses one.
496 /// Shadow-DOM host selection has no XPath 1.0 translation at all,
497 /// so — like `||` — the mere presence of the construct is the
498 /// error, wherever it sits. Only the functional form is looked for:
499 /// a bare `:host` is not a pseudo-class this crate's parser accepts,
500 /// so it fails to parse and never reaches translation.
501 host: Option<usize>,
502}
503
504/// Which of the two positions a [`Scan::misplaced_scope`] was found in,
505/// since they are reported as different constructs.
506#[derive(Clone, Copy, Eq, PartialEq)]
507enum ScopeSite {
508 /// Inside a functional pseudo-class argument, where an XPath 1.0
509 /// predicate cannot name the context node at all.
510 Functional,
511 /// At the top level, but not in the leftmost compound of its group
512 /// — the one place `:scope` translates, by anchoring the whole
513 /// expression on the `self::` axis instead of the caller's prefix.
514 NotLeftmost,
515}
516
517impl ScopeSite {
518 /// The construct phrase for this site, as the object of "uses …".
519 /// Worded exactly as the translator's own check words it, so the
520 /// two cannot diverge: only the position is new.
521 fn construct(self) -> &'static str {
522 match self {
523 ScopeSite::Functional => "the `:scope` pseudo-class inside a functional pseudo-class",
524 ScopeSite::NotLeftmost => "the `:scope` pseudo-class outside the leftmost compound",
525 }
526 }
527}
528
529/// How far into its selector-list group the walk has got, which is all
530/// that is needed to place a top-level `:scope`: it is supported in the
531/// leftmost compound of a group and nowhere else, so the question is
532/// only whether a combinator has been passed since the last top-level
533/// comma.
534#[derive(Default)]
535struct GroupPosition {
536 /// Whether anything that is part of a compound has been seen in
537 /// this group yet, so that leading whitespace is not a combinator.
538 content_seen: bool,
539 /// Whether whitespace has been seen after some content: a
540 /// descendant combinator if any content follows it, and nothing at
541 /// all if the group ends there.
542 space_pending: bool,
543 /// Whether a combinator — descendant, `>`, `+` or `~` — has been
544 /// passed, i.e. whether the leftmost compound is behind the walk.
545 combinator_seen: bool,
546}
547
548/// The string handling here diverges from the CSS tokenizer on one point:
549/// a newline inside a string ends it there, as a bad-string token, where
550/// this walk stays "in string" until the closing quote or the end of the
551/// input. So the walk can treat as string content — and skip — text the
552/// tokenizer reads as syntax, which for `||` only loses a nicer error
553/// message and for parentheses could undercount the depth.
554///
555/// Neither matters, because reaching that state needs a string that no
556/// newline-free closing quote follows, and cssparser turns the newline
557/// into a bad-string token that fails the parse. The skipped text is
558/// everything after that point, so nothing in it is ever parsed, let
559/// alone recursed into. Every selector that does parse is one the walk
560/// and the tokenizer agree about.
561fn scan(css: &str) -> Scan {
562 let bytes = css.as_bytes();
563 let mut i = 0;
564 let mut quote: Option<u8> = None;
565 let mut depth: usize = 0;
566 let mut brackets: usize = 0;
567 let mut group = GroupPosition::default();
568 let mut scan = Scan {
569 column_combinator: None,
570 too_deep: None,
571 nesting_selector: None,
572 misplaced_scope: None,
573 host: None,
574 };
575 while i < bytes.len() {
576 let b = bytes[i];
577 match quote {
578 Some(q) => {
579 if b == b'\\' {
580 i += 1; // skip the escaped character
581 } else if b == q {
582 quote = None;
583 }
584 }
585 None => {
586 // Where the walk is within its selector-list group,
587 // which only the top level has: inside a functional
588 // argument (`depth > 0`) a `:scope` is unsupported
589 // wherever it sits, and inside `[...]` nothing is a
590 // combinator — `[a~=b]`'s tilde least of all.
591 if depth == 0 && brackets == 0 {
592 match b {
593 b',' => group = GroupPosition::default(),
594 b'>' | b'+' | b'~' => {
595 group.combinator_seen = true;
596 group.content_seen = true;
597 group.space_pending = false;
598 }
599 b' ' | b'\t' | b'\n' | b'\r' | b'\x0C' => {
600 group.space_pending |= group.content_seen;
601 }
602 // A comment is neither content nor whitespace:
603 // it is removed before the selector grammar
604 // sees it, so `a/**/b` is one compound.
605 b'/' if bytes.get(i + 1) == Some(&b'*') => {}
606 _ => {
607 group.combinator_seen |= group.space_pending;
608 group.space_pending = false;
609 group.content_seen = true;
610 }
611 }
612 }
613 match b {
614 b'\\' => i += 1, // skip the escaped character
615 b'"' | b'\'' => quote = Some(b),
616 b'/' if bytes.get(i + 1) == Some(&b'*') => {
617 // Skip the comment body and its closing "*/".
618 i += 2;
619 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
620 i += 1;
621 }
622 i += 1;
623 }
624 b'|' if bytes.get(i + 1) == Some(&b'|') => {
625 scan.column_combinator.get_or_insert(i);
626 }
627 b'(' => {
628 depth += 1;
629 if depth > MAX_NESTING_DEPTH {
630 scan.too_deep.get_or_insert(i);
631 }
632 }
633 // Unbalanced closers are Servo's to reject, not this
634 // walk's: just never go below zero.
635 b')' => depth = depth.saturating_sub(1),
636 b'[' => brackets += 1,
637 b']' => brackets = brackets.saturating_sub(1),
638 b'&' => {
639 scan.nesting_selector.get_or_insert(i);
640 }
641 // A literal `:scope` / `:host(` here is the
642 // pseudo-class and nothing else — an escaped colon and
643 // a quoted or commented-out one are all skipped above,
644 // and any other ident starting with those letters
645 // (`:scoped`, `::scope`) is a pseudo-class this crate's
646 // parser rejects. Both findings are only ever consulted
647 // after the parse has succeeded, which is what makes
648 // that last part sound.
649 b':' if bytes[i..].starts_with(b":scope") && brackets == 0 => {
650 let site = if depth > 0 {
651 Some(ScopeSite::Functional)
652 } else if group.combinator_seen {
653 Some(ScopeSite::NotLeftmost)
654 } else {
655 None // the leftmost compound, where it is supported
656 };
657 if let Some(site) = site {
658 scan.misplaced_scope.get_or_insert((i, site));
659 }
660 }
661 b':' if bytes[i..].starts_with(b":host(") => {
662 scan.host.get_or_insert(i);
663 }
664 _ => {}
665 }
666 }
667 }
668 i += 1;
669 }
670 scan
671}
672
673/// The error one parse attempt produced, before it is turned into an
674/// [`Error`] — the kind is needed to tell an empty selector apart from
675/// everything else.
676type ParseFailure<'i> = cssparser::ParseError<'i, SelectorParseErrorKind<'i>>;
677
678/// Parse a full selector list (comma-separated groups).
679///
680/// Selectors 4 gives `:is()` and `:where()` a *forgiving* argument list,
681/// of which this crate wants exactly one part: an empty list is valid and
682/// matches nothing. Dropping *invalid* arguments is not wanted — a
683/// translation library must not quietly ignore what it was handed — so
684/// the strict parse decides, and forgiving parsing is only a retry whose
685/// result is accepted when the sole thing it recovered from was an empty
686/// argument list.
687pub(crate) fn parse(
688 css: &str,
689 default_namespace: Option<&str>,
690) -> Result<SelectorList<CssToXpathImpl>, Error> {
691 let scan = scan(css);
692 if let Some(offset) = scan.column_combinator {
693 return Err(Error::unsupported_at("the `||` column combinator", offset));
694 }
695 if let Some(offset) = scan.too_deep {
696 return Err(Error::unsupported_at(
697 format!("functional pseudo-classes nested more than {MAX_NESTING_DEPTH} levels deep"),
698 offset,
699 ));
700 }
701 // Reported after the other two, so a selector with more than one
702 // problem keeps the message it had before this check existed.
703 if let Some(offset) = scan.nesting_selector {
704 return Err(Error::unsupported_at("the `&` nesting selector", offset));
705 }
706 let list = parse_lists(css, default_namespace)?;
707 // The remaining findings are constructs Servo parses happily and
708 // the translator then rejects, so they are consulted only once the
709 // parse has succeeded: a selector that is *also* invalid CSS keeps
710 // the parse error it has always reported, and the walk never has to
711 // be right about text that never parsed. What the walk adds is the
712 // position, which the translator — handed components with no source
713 // offsets — cannot recover for itself.
714 if let Some((offset, site)) = scan.misplaced_scope {
715 return Err(Error::unsupported_at(site.construct(), offset));
716 }
717 if let Some(offset) = scan.host {
718 return Err(Error::unsupported_at("the `:host` pseudo-class", offset));
719 }
720 Ok(list)
721}
722
723/// The strict parse, and the forgiving retry that only an empty `:is()`
724/// / `:where()` argument list earns.
725fn parse_lists(
726 css: &str,
727 default_namespace: Option<&str>,
728) -> Result<SelectorList<CssToXpathImpl>, Error> {
729 let default_namespace = CssString::from(default_namespace.unwrap_or(""));
730 let strict = match parse_list(css, false, &default_namespace) {
731 Ok(list) => return Ok(list),
732 Err(e) => e,
733 };
734 // The strict parse stops at the first error in source order, and an
735 // empty argument list is reported as `EmptySelector`. Any other
736 // strict error therefore sits before, or is, something the forgiving
737 // parse cannot accept cleanly either, and every outcome of the retry
738 // below would report it unchanged — so the retry is skipped.
739 if !is_empty_selector(&strict) {
740 return Err(parse_error(css, &strict));
741 }
742 match parse_list(css, true, &default_namespace) {
743 Ok(list) if dropped_nothing(&list) => Ok(list),
744 // The forgiving parse recovered from a genuinely invalid
745 // argument: the strict error is the one that names it, and
746 // points at it.
747 Ok(_) => Err(parse_error(css, &strict)),
748 // Both parses failed. An empty argument list is no longer an
749 // error, so a strict `EmptySelector` may well be blaming one,
750 // while the forgiving parse — which accepts those — stopped at
751 // whatever is actually wrong.
752 Err(e) if is_empty_selector(&strict) => Err(parse_error(css, &e)),
753 Err(_) => Err(parse_error(css, &strict)),
754 }
755}
756
757/// One parse of the whole selector list.
758fn parse_list<'i>(
759 css: &'i str,
760 forgiving: bool,
761 default_namespace: &CssString,
762) -> Result<SelectorList<CssToXpathImpl>, ParseFailure<'i>> {
763 let mut input = ParserInput::new(css);
764 let mut parser = CssParser::new(&mut input);
765 SelectorList::parse(
766 &CssToXpathParser {
767 forgiving,
768 default_namespace,
769 },
770 &mut parser,
771 ParseRelative::No,
772 )
773}
774
775fn parse_error(css: &str, e: &ParseFailure<'_>) -> Error {
776 let reported = byte_offset(css, e.location);
777 let mut kind = ParseErrorKind::from_kind(&e.kind);
778 // `EmptySelector` is what `selectors` reports whenever a compound
779 // ends up with no components, which covers both "there was nothing
780 // here" and "none of what was here parsed". Only the first is what
781 // the name says, so the second is re-reported by its cause.
782 if matches!(kind, ParseErrorKind::EmptySelector)
783 && let Some(blocked) = blocking_token(css, reported)
784 {
785 kind = blocked;
786 }
787 let offset = named_token_offset(css, reported, &kind).unwrap_or(reported);
788 Error::Parse { kind, offset }
789}
790
791/// Where the token a message echoes starts, when it is one of the two
792/// next to `reported`.
793///
794/// The position `cssparser` and `selectors` report is the parser's
795/// stopping point, which is beside the offending token rather than on
796/// it, and which of the two sides depends on where the parser that
797/// failed took its location from. Taking it before reading a token
798/// leaves the position on the whitespace in front of that token
799/// (`[a=b c]`, reported on the space); taking it after leaves it past
800/// the token (`:nth-child(foo)`, reported on the `)`), and past a
801/// function token means on its first argument (`a::part(b)`, reported
802/// on the `b`). Both are one token from what the message names, so both
803/// are offered to the kind, and the caret moves to whichever it claims.
804///
805/// The token that follows is tried first, so a position already sitting
806/// on the offending token stays where it is. `None` when neither
807/// neighbour is the one named — a kind that echoes no token at all, or
808/// a name further off than one token, as `::before`'s is — leaving the
809/// reported position, which is still the best the parser knows.
810fn named_token_offset(css: &str, reported: usize, kind: &ParseErrorKind) -> Option<usize> {
811 // The walk re-tokenises from the start, which is wasted on a kind
812 // that could not claim either neighbour anyway.
813 if !kind.echoes_token() {
814 return None;
815 }
816 let mut following = None;
817 let mut preceding = None;
818 walk_tokens(css, |start, end, token| {
819 if end == reported {
820 preceding = Some((start, token.clone()));
821 }
822 // Whitespace and comments are skipped on the way forward: they
823 // are what the position lands on when it was taken before the
824 // token, and never what a message names.
825 if start >= reported && !matches!(token, Token::WhiteSpace(_) | Token::Comment(_)) {
826 following = Some((start, token.clone()));
827 return false;
828 }
829 true
830 });
831 [following, preceding]
832 .into_iter()
833 .flatten()
834 .find(|(_, token)| kind.names_token(token))
835 .map(|(start, _)| start)
836}
837
838/// Call `visit` with the byte span of every token of `css`, whitespace
839/// and comments included, until it returns `false`.
840///
841/// `cssparser`'s `Parser` skips the body of a block whose opening token
842/// it just handed back, rather than descending into it, so the walk
843/// restarts just past every opener: most of a selector's tokens sit
844/// inside `[...]` or a functional argument, and a caret has to be able
845/// to point at those.
846fn walk_tokens<'i>(css: &'i str, mut visit: impl FnMut(usize, usize, &Token<'i>) -> bool) {
847 let mut base = 0;
848 loop {
849 let mut input = ParserInput::new(&css[base..]);
850 let mut parser = CssParser::new(&mut input);
851 let restart = loop {
852 let start = base + parser.position().byte_index();
853 // The token is cloned out because reading the position
854 // again needs the parser back.
855 let Ok(token) = parser.next_including_whitespace_and_comments().cloned() else {
856 return;
857 };
858 let end = base + parser.position().byte_index();
859 if !visit(start, end, &token) {
860 return;
861 }
862 if matches!(
863 token,
864 Token::Function(_)
865 | Token::ParenthesisBlock
866 | Token::SquareBracketBlock
867 | Token::CurlyBracketBlock
868 ) {
869 break end;
870 }
871 };
872 base = restart;
873 }
874}
875
876/// The token a group blamed for being empty in fact stopped at, when
877/// there was one: `#1abc` names an ID that CSS cannot spell unescaped,
878/// not an absent selector, and the same hash one compound later
879/// (`p#1abc`) is already reported that way.
880///
881/// `None` when the group really is empty — end of input, or the comma
882/// that closes it — leaving [`ParseErrorKind::EmptySelector`] to mean
883/// only what it says.
884fn blocking_token(css: &str, offset: usize) -> Option<ParseErrorKind> {
885 let mut input = ParserInput::new(css.get(offset..)?);
886 let mut parser = CssParser::new(&mut input);
887 match parser.next() {
888 Ok(Token::Comma) | Err(_) => None,
889 Ok(token) => Some(ParseErrorKind::unexpected_token(token)),
890 }
891}
892
893fn is_empty_selector(e: &ParseFailure<'_>) -> bool {
894 matches!(
895 e.kind,
896 cssparser::ParseErrorKind::Custom(SelectorParseErrorKind::EmptySelector)
897 )
898}
899
900/// Whether a forgiving parse recovered from nothing but empty `:is()` /
901/// `:where()` argument lists.
902fn dropped_nothing(list: &SelectorList<CssToXpathImpl>) -> bool {
903 list.slice()
904 .iter()
905 .all(|selector| selector.visit(&mut DroppedArgument))
906}
907
908/// Finds an argument the forgiving parse dropped. Every `visit_*` method
909/// returns `false` to stop the walk the moment one turns up, so a
910/// completed walk means there was none.
911struct DroppedArgument;
912
913impl SelectorVisitor for DroppedArgument {
914 type Impl = CssToXpathImpl;
915
916 fn visit_simple_selector(&mut self, component: &Component<CssToXpathImpl>) -> bool {
917 // The empty argument lists are skipped below, so any invalid
918 // component reaching here stands for a dropped argument.
919 !matches!(component, Component::Invalid(_))
920 }
921
922 fn visit_selector_list(
923 &mut self,
924 _list_kind: SelectorListKind,
925 list: &[Selector<CssToXpathImpl>],
926 ) -> bool {
927 if is_empty_forgiving_list(list) {
928 return true;
929 }
930 list.iter().all(|nested| nested.visit(self))
931 }
932
933 fn visit_relative_selector_list(&mut self, list: &[RelativeSelector<CssToXpathImpl>]) -> bool {
934 // `:has()` is never parsed forgivingly, but its arguments can
935 // nest `:is()`, and the default implementation does not descend.
936 list.iter().all(|relative| relative.selector.visit(self))
937 }
938}
939
940/// Whether `list` is what an empty `:is()` / `:where()` argument list
941/// parses to. Forgiving recovery replaces an argument it could not parse
942/// with a single [`Component::Invalid`] holding the source text, so an
943/// empty list is one such argument whose text holds no tokens: `:is()`,
944/// `:is( )`, `:is(/**/)`. A list of two — `:is(a,)` — is a dropped
945/// argument, not an empty list.
946pub(crate) fn is_empty_forgiving_list(list: &[Selector<CssToXpathImpl>]) -> bool {
947 let [selector] = list else {
948 return false;
949 };
950 let mut components = selector.iter_raw_match_order();
951 let Some(Component::Invalid(source)) = components.next() else {
952 return false;
953 };
954 if components.next().is_some() {
955 return false;
956 }
957 // Servo keeps the source text it could not parse, so whether the
958 // list was empty is decided on that text: nothing but whitespace and
959 // comments.
960 let mut input = ParserInput::new(source.as_str());
961 CssParser::new(&mut input).is_exhausted()
962}
963
964/// The byte offset within `css` that `location` points at.
965///
966/// A `SourceLocation` cannot be used as an index: its line is 0-indexed,
967/// its column is 1-indexed, and — the part that bites — the column
968/// counts UTF-16 code units, so a tab counts as one unit but renders as
969/// several columns, a CJK character counts as one but renders as two,
970/// and a non-BMP character counts as two but is a single character. A
971/// byte offset is what the caret renderer needs to look at the source
972/// text itself.
973fn byte_offset(css: &str, location: SourceLocation) -> usize {
974 let bytes = css.as_bytes();
975 // Walk to the start of the error's line. `\r\n`, `\r`, `\n` and `\f`
976 // are all line breaks, matching cssparser's own line counter.
977 let mut offset = 0;
978 let mut line = 0;
979 while line < location.line && offset < bytes.len() {
980 match bytes[offset] {
981 b'\r' => {
982 offset += 1;
983 if bytes.get(offset) == Some(&b'\n') {
984 offset += 1;
985 }
986 line += 1;
987 }
988 b'\n' | b'\x0C' => {
989 offset += 1;
990 line += 1;
991 }
992 _ => offset += 1,
993 }
994 }
995 // Then across `column - 1` UTF-16 code units of that line. A column
996 // in the middle of a surrogate pair is not reachable from a token
997 // boundary, but `saturating_sub` keeps one from running away.
998 let mut units = location.column.saturating_sub(1);
999 for c in css[offset..].chars() {
1000 if units == 0 {
1001 break;
1002 }
1003 units = units.saturating_sub(c.len_utf16() as u32);
1004 offset += c.len_utf8();
1005 }
1006 offset
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 fn css(pc: &PseudoClass) -> String {
1014 let mut s = String::new();
1015 pc.to_css(&mut s).unwrap();
1016 s
1017 }
1018
1019 #[test]
1020 fn pseudo_class_to_css_names() {
1021 assert_eq!(css(&PseudoClass::AnyLink), ":any-link");
1022 assert_eq!(css(&PseudoClass::Link), ":link");
1023 assert_eq!(css(&PseudoClass::Visited), ":visited");
1024 assert_eq!(css(&PseudoClass::Hover), ":hover");
1025 assert_eq!(css(&PseudoClass::Active), ":active");
1026 assert_eq!(css(&PseudoClass::Focus), ":focus");
1027 assert_eq!(css(&PseudoClass::FocusWithin), ":focus-within");
1028 assert_eq!(css(&PseudoClass::FocusVisible), ":focus-visible");
1029 assert_eq!(css(&PseudoClass::Target), ":target");
1030 assert_eq!(css(&PseudoClass::TargetWithin), ":target-within");
1031 assert_eq!(css(&PseudoClass::LocalLink), ":local-link");
1032 assert_eq!(css(&PseudoClass::Enabled), ":enabled");
1033 assert_eq!(css(&PseudoClass::Disabled), ":disabled");
1034 assert_eq!(css(&PseudoClass::Checked), ":checked");
1035 assert_eq!(css(&PseudoClass::Required), ":required");
1036 assert_eq!(css(&PseudoClass::Optional), ":optional");
1037 }
1038
1039 #[test]
1040 fn pseudo_class_to_css_lang() {
1041 assert_eq!(css(&PseudoClass::Lang(vec!["en".into()])), ":lang(en)");
1042 assert_eq!(
1043 css(&PseudoClass::Lang(vec!["en".into(), "fr".into()])),
1044 ":lang(en, fr)"
1045 );
1046 // A wildcard is not part of an identifier, so a range carrying
1047 // one is written as the tokens it was parsed from.
1048 assert_eq!(css(&PseudoClass::Lang(vec!["de-*".into()])), ":lang(de-*)");
1049 assert_eq!(css(&PseudoClass::Lang(vec!["*".into()])), ":lang(*)");
1050 assert_eq!(
1051 css(&PseudoClass::Lang(vec!["de-*".into(), "*".into()])),
1052 ":lang(de-*, *)"
1053 );
1054 // Values are run through `serialize_identifier`, not written raw:
1055 // a leading digit needs escaping to remain a valid CSS identifier.
1056 assert_eq!(css(&PseudoClass::Lang(vec!["1x".into()])), ":lang(\\31 x)");
1057 // The empty range has no identifier to write — an identifier
1058 // cannot be empty — so it is written as the string it was
1059 // parsed from, and stays a range on the way back in.
1060 assert_eq!(css(&PseudoClass::Lang(vec![String::new()])), ":lang(\"\")");
1061 assert_eq!(
1062 css(&PseudoClass::Lang(vec![String::new(), "en".into()])),
1063 ":lang(\"\", en)"
1064 );
1065 }
1066
1067 /// The `:lang()` argument grammar, at the level the parser decides
1068 /// it: whether a token run assembles into ranges at all.
1069 #[test]
1070 fn lang_range_grammar() {
1071 fn ranges(css: &str) -> Option<Vec<String>> {
1072 let mut input = ParserInput::new(css);
1073 let mut parser = CssParser::new(&mut input);
1074 parser.expect_function_matching("lang").ok()?;
1075 parser
1076 .parse_nested_block(|p| {
1077 Ok::<_, cssparser::ParseError<'_, ()>>(parse_lang_ranges(p))
1078 })
1079 .ok()?
1080 }
1081 let one = |css: &str, range: &str| {
1082 assert_eq!(
1083 ranges(css).as_deref(),
1084 Some(&[range.to_owned()][..]),
1085 "{css}"
1086 );
1087 };
1088 one("lang(en)", "en");
1089 one("lang( en )", "en");
1090 one("lang(\"en\")", "en");
1091 one("lang(en-*)", "en-*");
1092 one("lang(*)", "*");
1093 one("lang(*-CH)", "*-CH");
1094 one("lang(\"en nz\")", "en nz");
1095 // The empty string is a range like any other here: what a range
1096 // *means* — the empty one included — is settled by the
1097 // translators, which is also where a malformed one is rejected.
1098 one("lang(\"\")", "");
1099 one("lang(en-)", "en-");
1100 one("lang(--x)", "--x");
1101 one("lang(en--)", "en--");
1102 one("lang(en*)", "en*");
1103 one("lang(*en)", "*en");
1104 assert_eq!(
1105 ranges("lang( en , fr )"),
1106 Some(vec!["en".to_owned(), "fr".to_owned()])
1107 );
1108 for css in [
1109 "lang()",
1110 "lang(en fr)", // whitespace is not a separator
1111 "lang(en *)", // ... and does not build `en-*` either
1112 "lang(,)",
1113 "lang(,en)",
1114 "lang(en,)",
1115 "lang(en,,fr)",
1116 "lang(5)",
1117 "lang(-)",
1118 "lang(en/**/fr)", // a comment separates tokens as whitespace does
1119 ] {
1120 assert_eq!(ranges(css), None, "{css}");
1121 }
1122 }
1123
1124 #[test]
1125 fn pseudo_class_to_css_dir() {
1126 assert_eq!(css(&PseudoClass::Dir("ltr".into())), ":dir(ltr)");
1127 }
1128
1129 #[test]
1130 fn pseudo_class_is_active_or_hover() {
1131 assert!(PseudoClass::Active.is_active_or_hover());
1132 assert!(PseudoClass::Hover.is_active_or_hover());
1133 assert!(!PseudoClass::Focus.is_active_or_hover());
1134 assert!(!PseudoClass::Link.is_active_or_hover());
1135 assert!(!PseudoClass::Target.is_active_or_hover());
1136 }
1137
1138 #[test]
1139 fn pseudo_class_is_user_action_state() {
1140 assert!(PseudoClass::Active.is_user_action_state());
1141 assert!(PseudoClass::Hover.is_user_action_state());
1142 assert!(PseudoClass::Focus.is_user_action_state());
1143 assert!(PseudoClass::FocusWithin.is_user_action_state());
1144 assert!(PseudoClass::FocusVisible.is_user_action_state());
1145 assert!(!PseudoClass::Link.is_user_action_state());
1146 assert!(!PseudoClass::Target.is_user_action_state());
1147 assert!(!PseudoClass::Enabled.is_user_action_state());
1148 assert!(!PseudoClass::Checked.is_user_action_state());
1149 }
1150}
1151
1152/// Pins the early exits in [`parse_lists`] and [`named_token_offset`]:
1153/// both skip work on the grounds that it cannot change the result, so
1154/// the result is checked against the implementation that did the work.
1155#[cfg(test)]
1156mod early_exit_tests {
1157 use super::*;
1158 use proptest::prelude::*;
1159
1160 const CORPUS: &str = include_str!("../../tests/corpus/selectors.txt");
1161
1162 /// `parse_lists` and `parse_error` as they were before the early
1163 /// exits: the forgiving retry after every strict failure, and the
1164 /// token walk for every kind.
1165 fn reference(
1166 css: &str,
1167 default_namespace: Option<&str>,
1168 ) -> Result<SelectorList<CssToXpathImpl>, Error> {
1169 let default_namespace = CssString::from(default_namespace.unwrap_or(""));
1170 let strict = match parse_list(css, false, &default_namespace) {
1171 Ok(list) => return Ok(list),
1172 Err(e) => e,
1173 };
1174 match parse_list(css, true, &default_namespace) {
1175 Ok(list) if dropped_nothing(&list) => Ok(list),
1176 Ok(_) => Err(reference_error(css, &strict)),
1177 Err(e) if is_empty_selector(&strict) => Err(reference_error(css, &e)),
1178 Err(_) => Err(reference_error(css, &strict)),
1179 }
1180 }
1181
1182 fn reference_error(css: &str, e: &ParseFailure<'_>) -> Error {
1183 let reported = byte_offset(css, e.location);
1184 let mut kind = ParseErrorKind::from_kind(&e.kind);
1185 if matches!(kind, ParseErrorKind::EmptySelector)
1186 && let Some(blocked) = blocking_token(css, reported)
1187 {
1188 kind = blocked;
1189 }
1190 let mut following = None;
1191 let mut preceding = None;
1192 walk_tokens(css, |start, end, token| {
1193 if end == reported {
1194 preceding = Some((start, token.clone()));
1195 }
1196 if start >= reported && !matches!(token, Token::WhiteSpace(_) | Token::Comment(_)) {
1197 following = Some((start, token.clone()));
1198 return false;
1199 }
1200 true
1201 });
1202 let offset = [following, preceding]
1203 .into_iter()
1204 .flatten()
1205 .find(|(_, token)| kind.names_token(token))
1206 .map_or(reported, |(start, _)| start);
1207 Error::Parse { kind, offset }
1208 }
1209
1210 fn assert_agrees(css: &str) -> Result<(), TestCaseError> {
1211 let shown =
1212 |r: Result<SelectorList<CssToXpathImpl>, Error>| r.map(|list| list.to_css_string());
1213 prop_assert_eq!(
1214 shown(parse_lists(css, None)),
1215 shown(reference(css, None)),
1216 "{:?}",
1217 css
1218 );
1219 Ok(())
1220 }
1221
1222 /// Characters that make up selector syntax, weighted towards the
1223 /// ones that open, close and separate the constructs at issue.
1224 const PIECES: &[&str] = &[
1225 ":is(",
1226 ":where(",
1227 ":not(",
1228 ":has(",
1229 ":nth-child(",
1230 "::before",
1231 ":foo",
1232 "(",
1233 ")",
1234 "[",
1235 "]",
1236 ",",
1237 " ",
1238 ">",
1239 "+",
1240 "~",
1241 "|",
1242 "*",
1243 "=",
1244 "\"",
1245 "'",
1246 "\\",
1247 "/**/",
1248 "/*",
1249 "#",
1250 ".",
1251 "a",
1252 "b",
1253 "1",
1254 "-",
1255 "&",
1256 "{",
1257 "}",
1258 "\u{e9}",
1259 ];
1260
1261 fn piece() -> impl Strategy<Value = String> {
1262 proptest::sample::select(PIECES).prop_map(str::to_owned)
1263 }
1264
1265 fn corpus_line() -> impl Strategy<Value = String> {
1266 let lines: Vec<&'static str> = CORPUS.lines().collect();
1267 proptest::sample::select(lines).prop_map(str::to_owned)
1268 }
1269
1270 /// A corpus selector with a few pieces spliced in at arbitrary
1271 /// character positions, and a few characters removed.
1272 fn mutated() -> impl Strategy<Value = String> {
1273 (
1274 corpus_line(),
1275 proptest::collection::vec((any::<proptest::sample::Index>(), piece()), 0..4),
1276 proptest::collection::vec(any::<proptest::sample::Index>(), 0..3),
1277 )
1278 .prop_map(|(line, inserts, removals)| {
1279 let mut chars: Vec<char> = line.chars().collect();
1280 for index in removals {
1281 if !chars.is_empty() {
1282 chars.remove(index.index(chars.len()));
1283 }
1284 }
1285 for (index, piece) in inserts {
1286 let at = index.index(chars.len() + 1);
1287 chars.splice(at..at, piece.chars());
1288 }
1289 chars.into_iter().collect()
1290 })
1291 }
1292
1293 #[test]
1294 fn corpus_agrees() {
1295 for line in CORPUS.lines() {
1296 assert_agrees(line).unwrap();
1297 }
1298 }
1299
1300 proptest! {
1301 #![proptest_config(ProptestConfig { cases: 4096, ..ProptestConfig::default() })]
1302
1303 #[test]
1304 fn mutated_corpus_agrees(css in mutated()) {
1305 assert_agrees(&css)?;
1306 }
1307
1308 #[test]
1309 fn assembled_pieces_agree(pieces in proptest::collection::vec(piece(), 0..12)) {
1310 assert_agrees(&pieces.concat())?;
1311 }
1312 }
1313}