Skip to main content

css_to_xpath/
lib.rs

1mod parser;
2mod translate;
3
4pub use translate::{Error, Mode, Translator};
5
6/// The version of this crate, from `Cargo.toml`.
7pub const VERSION: &str = env!("CARGO_PKG_VERSION");
8
9/// Translate a CSS selector to an XPath 1.0 expression.
10///
11/// # Arguments
12///
13/// * `css` — A CSS selector string.
14/// * `prefix` — An XPath path prefix prepended to the result
15///   (e.g. `"descendant-or-self::"`).  Pass `""` for none.
16/// * `mode` — The translator flavour: [`Mode::Generic`], [`Mode::Html`], or
17///   [`Mode::Xhtml`].
18///
19/// # Errors
20///
21/// Returns an [`Error`] when the selector is syntactically invalid or uses
22/// an unsupported construct.
23pub fn css_to_xpath(css: &str, prefix: &str, mode: Mode) -> Result<String, Error> {
24    Translator::new(mode).css_to_xpath(css, prefix)
25}
26
27#[cfg(test)]
28mod tests {
29    use crate::translate::{Mode, Translator};
30
31    fn xpath(css: &str) -> String {
32        Translator::new(Mode::Generic)
33            .css_to_xpath(css, "")
34            .unwrap()
35    }
36
37    /// Type, namespace, and attribute selector forms.
38    #[test]
39    fn simple_selectors() {
40        assert_eq!(xpath("*"), "*");
41        assert_eq!(xpath("e"), "e");
42        assert_eq!(xpath("*|e"), "*[local-name() = 'e']");
43        assert_eq!(xpath("|e"), "e");
44        assert_eq!(xpath("|*"), "*[namespace-uri() = '']");
45        assert_eq!(xpath("*|*"), "*");
46        assert_eq!(xpath("e|f"), "e:f");
47        assert_eq!(xpath("svg|*"), "svg:*");
48        assert_eq!(xpath("e[foo]"), "e[@foo]");
49        assert_eq!(xpath("e[foo|bar]"), "e[@foo:bar]");
50        assert_eq!(xpath("[*|foo]"), "*[@*[local-name() = 'foo']]");
51        assert_eq!(xpath("[|foo]"), "*[@foo]");
52        assert_eq!(xpath("e[foo=\"bar\"]"), "e[@foo = 'bar']");
53        assert_eq!(xpath("e[foo=\"\"]"), "e[@foo = '']");
54        assert_eq!(
55            xpath("e[foo|=\"\"]"),
56            "e[@foo and (@foo = '' or starts-with(@foo, '-'))]"
57        );
58        assert_eq!(
59            xpath("e[foo~=\"bar\"]"),
60            "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]"
61        );
62        assert_eq!(
63            xpath("e[foo^=\"bar\"]"),
64            "e[@foo and starts-with(@foo, 'bar')]"
65        );
66        assert_eq!(
67            xpath("e[foo$=\"bar\"]"),
68            "e[@foo and substring(@foo, string-length(@foo)-2) = 'bar']"
69        );
70        assert_eq!(
71            xpath("e[foo*=\"bar\"]"),
72            "e[@foo and contains(@foo, 'bar')]"
73        );
74        assert_eq!(
75            xpath("e[hreflang|=\"en\"]"),
76            "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]"
77        );
78    }
79
80    #[test]
81    fn class_id_combinators() {
82        assert_eq!(
83            xpath("e.warning"),
84            "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' warning ')]"
85        );
86        assert_eq!(xpath("e#myid"), "e[@id = 'myid']");
87        assert_eq!(xpath("e f"), "e//f");
88        assert_eq!(xpath("e > f"), "e/f");
89        assert_eq!(xpath("e + f"), "e/following-sibling::*[1][self::f]");
90        assert_eq!(xpath("e ~ f"), "e/following-sibling::f");
91        assert_eq!(
92            xpath("e + f[bar]"),
93            "e/following-sibling::*[1][self::f][@bar]"
94        );
95        assert_eq!(xpath("e + *"), "e/following-sibling::*[1][self::*]");
96        assert_eq!(xpath("div#container p"), "div[@id = 'container']//p");
97        assert_eq!(xpath("a , b"), "a | b");
98    }
99
100    #[test]
101    fn unsafe_names_and_escapes() {
102        assert_eq!(xpath("di\\[v"), "*[name() = 'di[v']");
103        assert_eq!(xpath("[h\\]ref]"), "*[attribute::*[name() = 'h]ref']]");
104        assert_eq!(xpath("di\u{a0}v"), "*[name() = 'di\u{a0}v']");
105        // Unicode escapes are decoded to the characters they represent,
106        // in idents, hashes, and strings alike.
107        assert_eq!(xpath("#\\31 23"), "*[@id = '123']");
108        assert_eq!(xpath("\\31 23"), "*[name() = '123']");
109        assert_eq!(xpath("[\\31 23]"), "*[attribute::*[name() = '123']]");
110        assert_eq!(xpath("e[foo='\\31 23']"), "e[@foo = '123']");
111        assert_eq!(xpath("e[foo='x\\79 z']"), "e[@foo = 'xyz']");
112        // '*|' bypasses the safe-name fallback: quoting handles it.
113        assert_eq!(xpath("*|di\\[v"), "*[local-name() = 'di[v']");
114        assert_eq!(xpath("[*|h\\]ref]"), "*[@*[local-name() = 'h]ref']]");
115        // '|' with a name needing quoting keeps the no-namespace
116        // constraint alongside the name() test.
117        assert_eq!(
118            xpath("|di\\[v"),
119            "*[name() = 'di[v' and namespace-uri() = '']"
120        );
121        assert_eq!(xpath("|é"), "*[name() = 'é' and namespace-uri() = '']");
122    }
123
124    #[test]
125    fn case_sensitivity_flags() {
126        const LOWER_FOO: &str = "translate(@foo, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', \
127                                 'abcdefghijklmnopqrstuvwxyz')";
128        assert_eq!(xpath("e[foo=\"Bar\" i]"), format!("e[{LOWER_FOO} = 'bar']"));
129        // Flag idents are themselves case-insensitive.
130        assert_eq!(xpath("e[foo=\"Bar\" I]"), format!("e[{LOWER_FOO} = 'bar']"));
131        assert_eq!(
132            xpath("e[foo^=\"Bar\" i]"),
133            format!("e[{LOWER_FOO} and starts-with({LOWER_FOO}, 'bar')]")
134        );
135        assert_eq!(
136            xpath("e[foo$=\"Bar\" i]"),
137            format!(
138                "e[{LOWER_FOO} and substring({LOWER_FOO}, \
139                 string-length({LOWER_FOO})-2) = 'bar']"
140            )
141        );
142        // ASCII-only lowering: non-ASCII characters are left alone.
143        assert_eq!(
144            xpath("e[foo=\"B\u{e4}r\" i]"),
145            format!("e[{LOWER_FOO} = 'b\u{e4}r']")
146        );
147        // An empty value keeps the exact translation.
148        assert_eq!(xpath("e[foo=\"\" i]"), "e[@foo = '']");
149        // 's' requests the default case-sensitive matching.
150        assert_eq!(xpath("e[foo=\"Bar\" s]"), "e[@foo = 'Bar']");
151        // The flag composes with namespaced attribute forms.
152        assert_eq!(
153            xpath("e[*|foo=\"Bar\" i]"),
154            format!(
155                "e[translate(@*[local-name() = 'foo'], \
156                 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', \
157                 'abcdefghijklmnopqrstuvwxyz') = 'bar']"
158            )
159        );
160    }
161
162    #[test]
163    fn unsupported_errors() {
164        let t = Translator::new(Mode::Generic);
165        // The non-standard [a!=b] and :contains() are not supported.
166        assert!(t.css_to_xpath("e[foo!=\"bar\"]", "").is_err());
167        assert!(t.css_to_xpath("e:contains(\"foo\")", "").is_err());
168        assert!(t.css_to_xpath("e::before", "").is_err());
169        assert!(t.css_to_xpath("e:", "").is_err());
170        assert!(t.css_to_xpath("", "").is_err());
171        // A flag requires an operator and value.
172        assert!(t.css_to_xpath("[rel i]", "").is_err());
173        assert!(t.css_to_xpath("[rel=stylesheet k]", "").is_err());
174        assert!(t.css_to_xpath("[rel=stylesheet i i]", "").is_err());
175        // Unknown pseudo-classes error.
176        assert!(t.css_to_xpath("e:unknown-pseudo", "").is_err());
177        assert!(t.css_to_xpath("e:first-line", "").is_err()); // pseudo-element
178        // The Level 4 column combinator and grid-structural pseudos have
179        // no XPath 1.0 translation: column membership rests on
180        // colspan/rowspan layout arithmetic. `||` is caught before Servo
181        // misparses it as namespace syntax...
182        assert!(t.css_to_xpath("col || td", "").is_err());
183        assert!(t.css_to_xpath("col||td", "").is_err());
184        assert!(t.css_to_xpath("e:nth-col(2)", "").is_err());
185        assert!(t.css_to_xpath("e:nth-last-col(2n)", "").is_err());
186        // ...while pipes in strings, escapes, and comments stay valid.
187        assert!(t.css_to_xpath("[foo=\"a||b\"]", "").is_ok());
188        assert!(t.css_to_xpath("a\\|\\|b", "").is_ok());
189        assert!(t.css_to_xpath("a /* || */ b", "").is_ok());
190        // Pseudo-classes outside the never-match policy (see PseudoClass)
191        // error rather than silently matching nothing: form validity and
192        // state could be at least partially translated some day, and
193        // erroring keeps typos loud.
194        assert!(t.css_to_xpath("e:valid", "").is_err());
195        assert!(t.css_to_xpath("e:user-invalid", "").is_err());
196        assert!(t.css_to_xpath("e:read-only", "").is_err());
197        assert!(t.css_to_xpath("e:placeholder-shown", "").is_err());
198        assert!(t.css_to_xpath("e:defined", "").is_err());
199        // :scope is supported in the leftmost compound only, and never
200        // inside functional pseudo-class arguments (the context node is
201        // unreachable from an XPath 1.0 predicate).
202        assert!(t.css_to_xpath("a :scope", "").is_err());
203        assert!(t.css_to_xpath("a > :scope", "").is_err());
204        assert!(t.css_to_xpath(":scope :scope", "").is_err());
205        assert!(t.css_to_xpath("e:is(:scope)", "").is_err());
206        assert!(t.css_to_xpath("e:not(:scope)", "").is_err());
207        assert!(t.css_to_xpath("e:has(:scope)", "").is_err());
208        assert!(t.css_to_xpath("e:nth-child(2 of :scope)", "").is_err());
209        // A leading combinator is :has()-only; dangling and doubled
210        // combinators are parse errors everywhere.
211        assert!(t.css_to_xpath("e:is(> a)", "").is_err());
212        assert!(t.css_to_xpath("e:has(> > a)", "").is_err());
213        assert!(t.css_to_xpath("e:has(>)", "").is_err());
214        assert!(t.css_to_xpath("e:has(a >)", "").is_err());
215        // Nested :has() is rejected (selectors-4).
216        assert!(t.css_to_xpath("e:has(a:has(b))", "").is_err());
217        assert!(t.css_to_xpath("e:has(> a:has(b))", "").is_err());
218        // of-type pseudos are not implemented on `*` — including compounds
219        // that leave the type implicit (`.foo` is `*.foo`) or carry it
220        // only inside a pseudo-class argument. XPath 1.0 cannot compare a
221        // sibling's name with the matched element's own name, so only a
222        // type named in the compound itself gives a sibling node test.
223        assert!(t.css_to_xpath("*:first-of-type", "").is_err());
224        assert!(t.css_to_xpath("*:nth-last-of-type(2)", "").is_err());
225        assert!(t.css_to_xpath("*:only-of-type", "").is_err());
226        assert!(t.css_to_xpath(".foo:first-of-type", "").is_err());
227        assert!(t.css_to_xpath("[bar]:nth-of-type(2)", "").is_err());
228        assert!(t.css_to_xpath(":is(e):first-of-type", "").is_err());
229        // :lang()/:dir() argument validation; a lone '-' is not a valid
230        // ident.
231        assert!(t.css_to_xpath(":lang()", "").is_err());
232        assert!(t.css_to_xpath(":lang(5)", "").is_err());
233        assert!(t.css_to_xpath(":lang(-)", "").is_err());
234        // An+B must be whitespace-exact and integer-valued.
235        assert!(t.css_to_xpath("e:nth-child(3 7)", "").is_err());
236        assert!(t.css_to_xpath("e:nth-child(2 n)", "").is_err());
237        assert!(t.css_to_xpath("e:nth-child(2.5)", "").is_err());
238        assert!(t.css_to_xpath("e:nth-child(2e1)", "").is_err());
239    }
240
241    /// The nth-* family and its an+b arithmetic.
242    #[test]
243    fn nth_family() {
244        assert_eq!(
245            xpath("e:nth-child(1)"),
246            "e[count(preceding-sibling::*) = 0]"
247        );
248        assert_eq!(
249            xpath("e:nth-child(3n+2)"),
250            "e[count(preceding-sibling::*) >= 1 and (count(preceding-sibling::*) +2) mod 3 = 0]"
251        );
252        assert_eq!(
253            xpath("e:nth-child(3n-2)"),
254            "e[count(preceding-sibling::*) mod 3 = 0]"
255        );
256        assert_eq!(
257            xpath("e:nth-child(-n+6)"),
258            "e[count(preceding-sibling::*) <= 5]"
259        );
260        assert_eq!(xpath("e:nth-child(n)"), "e");
261        assert_eq!(xpath("e:nth-child(odd)"), xpath("e:nth-child(2n+1)"));
262        assert_eq!(xpath("e:nth-child(even)"), xpath("e:nth-child(2n)"));
263        // An+B is ASCII case-insensitive per css-syntax; Servo handles it
264        // natively.
265        assert_eq!(xpath("e:nth-child(2N)"), xpath("e:nth-child(2n)"));
266        assert_eq!(xpath("e:nth-child(ODD)"), xpath("e:nth-child(odd)"));
267        assert_eq!(xpath("e:nth-child(EVEN)"), xpath("e:nth-child(even)"));
268        assert_eq!(xpath("e:nth-child(-N+3)"), xpath("e:nth-child(-n+3)"));
269        assert_eq!(
270            xpath("e:nth-last-child(1)"),
271            "e[count(following-sibling::*) = 0]"
272        );
273        assert_eq!(
274            xpath("e:nth-last-child(2n)"),
275            "e[(count(following-sibling::*) +1) mod 2 = 0]"
276        );
277        assert_eq!(
278            xpath("e:nth-last-child(2n+1)"),
279            "e[count(following-sibling::*) mod 2 = 0]"
280        );
281        assert_eq!(
282            xpath("e:nth-last-child(2n+2)"),
283            "e[count(following-sibling::*) >= 1 and (count(following-sibling::*) +1) mod 2 = 0]"
284        );
285        assert_eq!(
286            xpath("e:nth-last-child(3n+1)"),
287            "e[count(following-sibling::*) mod 3 = 0]"
288        );
289        assert_eq!(
290            xpath("e:nth-last-child(-n+2)"),
291            "e[count(following-sibling::*) <= 1]"
292        );
293        assert_eq!(
294            xpath("e:nth-of-type(1)"),
295            "e[count(preceding-sibling::e) = 0]"
296        );
297        assert_eq!(
298            xpath("e:nth-last-of-type(1)"),
299            "e[count(following-sibling::e) = 0]"
300        );
301        assert_eq!(
302            xpath("div e:nth-last-of-type(1) .aclass"),
303            "div//e[count(following-sibling::e) = 0]//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' aclass ')]"
304        );
305        // Servo collapses :first-child & co. into nth data; the general
306        // an+b form covers them (see translate::nth).
307        assert_eq!(xpath("e:first-child"), "e[count(preceding-sibling::*) = 0]");
308        assert_eq!(xpath("e:last-child"), "e[count(following-sibling::*) = 0]");
309        assert_eq!(
310            xpath("e:first-of-type"),
311            "e[count(preceding-sibling::e) = 0]"
312        );
313        assert_eq!(
314            xpath("e:last-of-type"),
315            "e[count(following-sibling::e) = 0]"
316        );
317        assert_eq!(
318            xpath("e:only-child"),
319            "e[count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0]"
320        );
321        assert_eq!(
322            xpath("e:only-of-type"),
323            "e[count(preceding-sibling::e) = 0 and count(following-sibling::e) = 0]"
324        );
325        // Element names needing quoting fold into a name() condition; the
326        // of-type pseudos count same-type siblings through the same test.
327        assert_eq!(
328            xpath("é:first-of-type"),
329            "*[name() = 'é' and count(preceding-sibling::*[name() = 'é']) = 0]"
330        );
331        assert_eq!(
332            xpath("é:nth-of-type(2)"),
333            "*[name() = 'é' and count(preceding-sibling::*[name() = 'é']) = 1]"
334        );
335        assert_eq!(
336            xpath("é:nth-last-of-type(1)"),
337            "*[name() = 'é' and count(following-sibling::*[name() = 'é']) = 0]"
338        );
339        assert_eq!(
340            xpath("é:only-of-type"),
341            "*[name() = 'é' and count(preceding-sibling::*[name() = 'é']) = 0 and count(following-sibling::*[name() = 'é']) = 0]"
342        );
343        assert_eq!(
344            xpath("e ~ f:nth-child(3)"),
345            "e/following-sibling::f[count(preceding-sibling::*) = 2]"
346        );
347        // Early exits: a=1 with b<=1 matches everything; a<0 with b<1 is
348        // impossible.
349        assert_eq!(xpath("e:nth-child(n+1)"), "e");
350        assert_eq!(xpath("e:nth-child(n-5)"), "e");
351        assert_eq!(xpath("e:nth-child(-n)"), "e[0]");
352        assert_eq!(xpath("e:nth-child(-2n-1)"), "e[0]");
353        assert_eq!(xpath("e:nth-child(-n+0)"), "e[0]");
354        assert_eq!(
355            xpath("e:nth-child(-n+1)"),
356            "e[count(preceding-sibling::*) <= 0]"
357        );
358        assert_eq!(
359            xpath("e:nth-child(-2n+2)"),
360            "e[count(preceding-sibling::*) <= 1 and (count(preceding-sibling::*) +1) mod -2 = 0]"
361        );
362    }
363
364    /// `of S` selector lists (CSS Level 4), nth-child only.
365    #[test]
366    fn nth_child_of() {
367        assert_eq!(
368            xpath("div:nth-child(2 of .foo)"),
369            "div[count(preceding-sibling::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]) = 1 and @class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"
370        );
371        // a=1, b<=1: only the current-element check remains.
372        assert_eq!(
373            xpath("li:nth-child(n of .item)"),
374            "li[@class and contains(concat(' ', normalize-space(@class), ' '), ' item ')]"
375        );
376        // Impossible series keeps the current-element check after the 0.
377        assert_eq!(
378            xpath("li:nth-child(-n of .item)"),
379            "li[0 and @class and contains(concat(' ', normalize-space(@class), ' '), ' item ')]"
380        );
381        // An element argument folds into a name() test.
382        assert_eq!(
383            xpath("div:nth-child(2 of div.foo)"),
384            "div[count(preceding-sibling::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ') and name() = 'div']) = 1 and @class and contains(concat(' ', normalize-space(@class), ' '), ' foo ') and name() = 'div']"
385        );
386        // A universal argument makes the list match everything, like a
387        // plain :nth-child.
388        assert_eq!(
389            xpath("li:nth-child(2 of .foo, *)"),
390            "li[count(preceding-sibling::*) = 1]"
391        );
392    }
393
394    /// Structural pseudos and the generic never-match set.
395    #[test]
396    fn structural_and_never_match_pseudos() {
397        assert_eq!(xpath("e:empty"), "e[not(*) and not(string-length())]");
398        assert_eq!(xpath("e:EmPTY"), "e[not(*) and not(string-length())]");
399        assert_eq!(xpath("e:root"), "e[not(parent::*)]");
400        // The generic never-match set.
401        for pseudo in [
402            "any-link",
403            "link",
404            "visited",
405            "hover",
406            "active",
407            "focus",
408            "focus-within",
409            "focus-visible",
410            "target",
411            "target-within",
412            "local-link",
413            "enabled",
414            "disabled",
415            "checked",
416            "required",
417            "optional",
418        ] {
419            assert_eq!(xpath(&format!("a:{pseudo}")), "a[0]");
420        }
421        assert_eq!(xpath("a:dir(ltr)"), "a[0]");
422    }
423
424    #[test]
425    fn negation_matching_where_has() {
426        assert_eq!(
427            xpath("e:not(:nth-child(odd))"),
428            "e[not(count(preceding-sibling::*) mod 2 = 0)]"
429        );
430        assert_eq!(xpath("e:nOT(*)"), "e[0]");
431        assert_eq!(xpath("e:not(a)"), "e[not(name() = 'a')]");
432        assert_eq!(xpath("e:not(a, b)"), "e[not(name() = 'a' or name() = 'b')]");
433        // A universal argument makes :not() unmatchable...
434        assert_eq!(xpath("div:not(a, *)"), "div[0]");
435        // :where() / :is() OR their arguments together into one condition
436        // that ANDs with the rest of the compound.
437        assert_eq!(xpath("div:where(p)"), "div[name() = 'p']");
438        assert_eq!(
439            xpath("div:where(p, span)"),
440            "div[name() = 'p' or name() = 'span']"
441        );
442        assert_eq!(
443            xpath("*:where(div.content)"),
444            "*[@class and contains(concat(' ', normalize-space(@class), ' '), ' content ') and name() = 'div']"
445        );
446        assert_eq!(
447            xpath("div:where(p):where(span)"),
448            "div[name() = 'p' and name() = 'span']"
449        );
450        assert_eq!(xpath("div:is(p)"), "div[name() = 'p']");
451        // :matches() is the legacy alias for :is().
452        assert_eq!(xpath("div:matches(p)"), "div[name() = 'p']");
453        // ...and :is()/:where() a no-op constraint.
454        assert_eq!(xpath("e:is(*)"), "e");
455        assert_eq!(xpath("div:is(a, *)"), "div");
456        assert_eq!(xpath("div:where(a, *)"), "div");
457        // :has().
458        assert_eq!(xpath("div:has(p)"), "div[.//*[name() = 'p']]");
459        assert_eq!(
460            xpath("div:has(.foo)"),
461            "div[.//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"
462        );
463        assert_eq!(
464            xpath("div:has(p, span)"),
465            "div[.//*[name() = 'p'] | .//*[name() = 'span']]"
466        );
467        assert_eq!(
468            xpath("div:has(p):has(span)"),
469            "div[.//*[name() = 'p'] and .//*[name() = 'span']]"
470        );
471        assert_eq!(
472            xpath("section:has(div.content)"),
473            "section[.//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' content ') and name() = 'div']]"
474        );
475        assert_eq!(xpath("div:has(*)"), "div[.//*]");
476        // Leading combinators in :has() (selectors-4 relative selectors).
477        assert_eq!(xpath("e:has(> img)"), "e[child::*[name() = 'img']]");
478        assert_eq!(xpath("e:has(~ p)"), "e[following-sibling::*[name() = 'p']]");
479        assert_eq!(
480            xpath("e:has(+ p)"),
481            "e[following-sibling::*[1][name() = 'p']]"
482        );
483        assert_eq!(
484            xpath("e:has(> a, ~ p)"),
485            "e[child::*[name() = 'a'] | following-sibling::*[name() = 'p']]"
486        );
487        assert_eq!(
488            xpath("e:has(> .foo)"),
489            "e[child::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"
490        );
491        assert_eq!(
492            xpath("e:has(+ p.foo)"),
493            "e[following-sibling::*[1][@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ') and name() = 'p']]"
494        );
495        // Nested :not() (Selectors Level 4).
496        assert_eq!(xpath(":not(:not(a))"), "*[not(not(name() = 'a'))]");
497        assert_eq!(xpath("e:is(:not(f))"), "e[not(name() = 'f')]");
498        assert_eq!(xpath("e:has(:not(f))"), "e[.//*[not(name() = 'f')]]");
499        // Prefixed names inside arguments stay node tests, resolved
500        // through the namespace map like a top-level `svg|g` — not a
501        // string comparison against the document's prefix.
502        assert_eq!(xpath("e:is(svg|g)"), "e[self::svg:g]");
503        assert_eq!(xpath("e:not(svg|g)"), "e[not(self::svg:g)]");
504        assert_eq!(xpath("e:is(svg|*)"), "e[self::svg:*]");
505        assert_eq!(xpath("e:has(svg|g)"), "e[.//svg:g]");
506        assert_eq!(xpath("e:has(> svg|g)"), "e[child::svg:g]");
507        assert_eq!(xpath("e:has(~ svg|g)"), "e[following-sibling::svg:g]");
508        assert_eq!(
509            xpath("e:has(+ svg|g)"),
510            "e[following-sibling::*[1][self::svg:g]]"
511        );
512        assert_eq!(
513            xpath("e:has(svg|g.foo)"),
514            "e[.//svg:g[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"
515        );
516    }
517
518    /// Complex selectors (with combinators) inside the functional
519    /// pseudo-classes (Selectors Level 4). :is()/:where()/:not() and the
520    /// nth `of S` lists match their argument at the candidate element, so
521    /// each combinator becomes an existence test through the reversed
522    /// axis; :has() looks forward, extending its path compound by
523    /// compound.
524    #[test]
525    fn complex_pseudo_arguments() {
526        // One reversed axis per combinator.
527        assert_eq!(
528            xpath("e:is(a b)"),
529            "e[name() = 'b' and ancestor::*[name() = 'a']]"
530        );
531        assert_eq!(
532            xpath("e:is(a > b)"),
533            "e[name() = 'b' and parent::*[name() = 'a']]"
534        );
535        assert_eq!(
536            xpath("e:is(a + b)"),
537            "e[name() = 'b' and preceding-sibling::*[1][name() = 'a']]"
538        );
539        assert_eq!(
540            xpath("e:is(a ~ b)"),
541            "e[name() = 'b' and preceding-sibling::*[name() = 'a']]"
542        );
543        // Longer chains nest, each step wrapping the remainder.
544        assert_eq!(
545            xpath("e:is(a b c)"),
546            "e[name() = 'c' and ancestor::*[name() = 'b' and ancestor::*[name() = 'a']]]"
547        );
548        assert_eq!(
549            xpath("e:is(a > b ~ c)"),
550            "e[name() = 'c' and preceding-sibling::*[name() = 'b' and parent::*[name() = 'a']]]"
551        );
552        assert_eq!(
553            xpath("e:is(a + b > c)"),
554            "e[name() = 'c' and parent::*[name() = 'b' and preceding-sibling::*[1][name() = 'a']]]"
555        );
556        // :not() negates the whole chain condition; complex and compound
557        // arguments OR together ('and' binds tighter than 'or').
558        assert_eq!(
559            xpath("e:not(a b)"),
560            "e[not(name() = 'b' and ancestor::*[name() = 'a'])]"
561        );
562        assert_eq!(
563            xpath("e:not(a > b + c)"),
564            "e[not(name() = 'c' and preceding-sibling::*[1][name() = 'b' and parent::*[name() = 'a']])]"
565        );
566        assert_eq!(
567            xpath("e:is(a b, c)"),
568            "e[name() = 'b' and ancestor::*[name() = 'a'] or name() = 'c']"
569        );
570        assert_eq!(
571            xpath("e:is(a, b c)"),
572            "e[name() = 'a' or name() = 'c' and ancestor::*[name() = 'b']]"
573        );
574        // Universal steps: a bare-`*` left-hand side is a bare axis test,
575        // a bare-`*` rightmost compound leaves only the chain test, and a
576        // universal *argument* still makes the list trivially true (or
577        // :not() unmatchable).
578        assert_eq!(xpath("e:is(* b)"), "e[name() = 'b' and ancestor::*]");
579        assert_eq!(xpath("e:is(a *)"), "e[ancestor::*[name() = 'a']]");
580        assert_eq!(xpath("e:not(a *)"), "e[not(ancestor::*[name() = 'a'])]");
581        assert_eq!(xpath("e:is(a b, *)"), "e");
582        assert_eq!(xpath("e:not(a b, *)"), "e[0]");
583        // Conditions on chain steps come before each step's name test.
584        assert_eq!(
585            xpath("e:is(a.x b.y)"),
586            "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' y ') and \
587             name() = 'b' and \
588             ancestor::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' x ') \
589             and name() = 'a']]"
590        );
591        assert_eq!(
592            xpath("e:is(a[foo='bar'] > b)"),
593            "e[name() = 'b' and parent::*[@foo = 'bar' and name() = 'a']]"
594        );
595        assert_eq!(
596            xpath("e:is(a:first-child b)"),
597            "e[name() = 'b' and ancestor::*[count(preceding-sibling::*) = 0 and name() = 'a']]"
598        );
599        assert_eq!(
600            xpath("e:is(a:hover b)"),
601            "e[name() = 'b' and ancestor::*[0 and name() = 'a']]"
602        );
603        // Nested pseudo-classes inside chain steps; an or-group condition
604        // is parenthesized when conjoined with the chain test.
605        assert_eq!(
606            xpath("e:is(:not(a) b)"),
607            "e[name() = 'b' and ancestor::*[not(name() = 'a')]]"
608        );
609        assert_eq!(
610            xpath("e:not(:is(a b))"),
611            "e[not(name() = 'b' and ancestor::*[name() = 'a'])]"
612        );
613        assert_eq!(
614            xpath("e:is(:not(a b) c)"),
615            "e[name() = 'c' and ancestor::*[not(name() = 'b' and ancestor::*[name() = 'a'])]]"
616        );
617        assert_eq!(
618            xpath("e:is(:is(a, b) c)"),
619            "e[name() = 'c' and ancestor::*[name() = 'a' or name() = 'b']]"
620        );
621        assert_eq!(
622            xpath("e:is(c :is(a, b))"),
623            "e[(name() = 'a' or name() = 'b') and ancestor::*[name() = 'c']]"
624        );
625        // Prefixed names in chain steps stay self:: node tests.
626        assert_eq!(
627            xpath("ns|e:is(a b)"),
628            "ns:e[name() = 'b' and ancestor::*[name() = 'a']]"
629        );
630        assert_eq!(
631            xpath("e:is(ns|a b)"),
632            "e[name() = 'b' and ancestor::*[self::ns:a]]"
633        );
634        assert_eq!(
635            xpath("e:is(a ns|b)"),
636            "e[self::ns:b and ancestor::*[name() = 'a']]"
637        );
638        // :has() walks forward: one joiner per combinator, with the
639        // leading combinator choosing the first axis.
640        assert_eq!(
641            xpath("e:has(a b)"),
642            "e[.//*[name() = 'a']//*[name() = 'b']]"
643        );
644        assert_eq!(
645            xpath("e:has(a > b)"),
646            "e[.//*[name() = 'a']/*[name() = 'b']]"
647        );
648        assert_eq!(
649            xpath("e:has(a + b)"),
650            "e[.//*[name() = 'a']/following-sibling::*[1][name() = 'b']]"
651        );
652        assert_eq!(
653            xpath("e:has(a ~ b)"),
654            "e[.//*[name() = 'a']/following-sibling::*[name() = 'b']]"
655        );
656        assert_eq!(
657            xpath("e:has(> a b)"),
658            "e[child::*[name() = 'a']//*[name() = 'b']]"
659        );
660        assert_eq!(
661            xpath("e:has(> a > b)"),
662            "e[child::*[name() = 'a']/*[name() = 'b']]"
663        );
664        assert_eq!(
665            xpath("e:has(+ a > b)"),
666            "e[following-sibling::*[1][name() = 'a']/*[name() = 'b']]"
667        );
668        assert_eq!(
669            xpath("e:has(~ a + b)"),
670            "e[following-sibling::*[name() = 'a']/following-sibling::*[1][name() = 'b']]"
671        );
672        assert_eq!(
673            xpath("e:has(a > b + c)"),
674            "e[.//*[name() = 'a']/*[name() = 'b']/following-sibling::*[1][name() = 'c']]"
675        );
676        assert_eq!(
677            xpath("e:has(> a:is(b c))"),
678            "e[child::*[name() = 'c' and ancestor::*[name() = 'b'] and name() = 'a']]"
679        );
680        assert_eq!(
681            xpath("e:has(a.x > b.y)"),
682            "e[.//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' x ') \
683             and name() = 'a']/*[@class and \
684             contains(concat(' ', normalize-space(@class), ' '), ' y ') and name() = 'b']]"
685        );
686        // Prefixed names stay path node tests, except under `+` where the
687        // [1] position predicate needs the node test to stay `*`.
688        assert_eq!(xpath("e:has(ns|a > b)"), "e[.//ns:a/*[name() = 'b']]");
689        assert_eq!(
690            xpath("e:has(a + ns|b)"),
691            "e[.//*[name() = 'a']/following-sibling::*[1][self::ns:b]]"
692        );
693        // `of S` with complex selectors: the chain condition filters the
694        // counted siblings and constrains the current element.
695        assert_eq!(
696            xpath("e:nth-child(2n of a b)"),
697            "e[(count(preceding-sibling::*[name() = 'b' and ancestor::*[name() = 'a']]) +1) \
698             mod 2 = 0 and name() = 'b' and ancestor::*[name() = 'a']]"
699        );
700        assert_eq!(
701            xpath("e:nth-child(2n of a > b)"),
702            "e[(count(preceding-sibling::*[name() = 'b' and parent::*[name() = 'a']]) +1) \
703             mod 2 = 0 and name() = 'b' and parent::*[name() = 'a']]"
704        );
705        assert_eq!(
706            xpath("e:nth-last-child(3 of a b)"),
707            "e[count(following-sibling::*[name() = 'b' and ancestor::*[name() = 'a']]) = 2 \
708             and name() = 'b' and ancestor::*[name() = 'a']]"
709        );
710    }
711
712    /// :scope (Selectors Level 4) anchors the expression at the node the
713    /// XPath is evaluated from: the leftmost compound moves onto the
714    /// self:: axis and the prefix is not applied.
715    #[test]
716    fn scope_pseudo() {
717        let t = Translator::new(Mode::Generic);
718        assert_eq!(xpath(":scope"), "self::*");
719        assert_eq!(xpath(":ScoPE"), "self::*");
720        assert_eq!(xpath(":scope > a"), "self::*/a");
721        assert_eq!(xpath(":scope a"), "self::*//a");
722        assert_eq!(
723            xpath(":scope + a"),
724            "self::*/following-sibling::*[1][self::a]"
725        );
726        assert_eq!(xpath(":scope ~ a"), "self::*/following-sibling::a");
727        // Other simple selectors in the :scope compound constrain the
728        // context node itself.
729        assert_eq!(xpath("div:scope"), "self::div");
730        assert_eq!(xpath("svg|g:scope"), "self::svg:g");
731        assert_eq!(
732            xpath(":scope.foo > a"),
733            "self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]/a"
734        );
735        assert_eq!(
736            xpath(":scope:first-child"),
737            "self::*[count(preceding-sibling::*) = 0]"
738        );
739        // The prefix is replaced by the self:: anchor, per selector group.
740        assert_eq!(
741            t.css_to_xpath(":scope > a", "descendant-or-self::")
742                .unwrap(),
743            "self::*/a"
744        );
745        assert_eq!(
746            t.css_to_xpath("a, :scope > b", "descendant-or-self::")
747                .unwrap(),
748            "descendant-or-self::a | self::*/b"
749        );
750    }
751
752    #[test]
753    fn lang_and_dir() {
754        // Generic: XPath's lang() does prefix matching natively.
755        assert_eq!(xpath("e:lang(en)"), "e[lang('en')]");
756        assert_eq!(xpath("e:lang(\"en\")"), "e[lang('en')]");
757        assert_eq!(xpath("e:lang(en-*)"), "e[lang('en')]");
758        assert_eq!(xpath("e:lang(*)"), "e[true()]");
759        assert_eq!(xpath("e:lang(en, fr)"), "e[lang('en') or lang('fr')]");
760        // Whitespace is a separator too.
761        assert_eq!(xpath("e:lang(en fr)"), "e[lang('en') or lang('fr')]");
762        // A bare * stays match-anything even alongside other ranges: it
763        // must not be confused with the head of an interior wildcard.
764        assert_eq!(xpath("e:lang(*, fr)"), "e[true() or lang('fr')]");
765        // HTML: nearest lang-attributed ancestor, lowercased prefix match.
766        let html = Translator::new(Mode::Html);
767        assert_eq!(
768            html.css_to_xpath("e:lang(EN)", "").unwrap(),
769            "e[ancestor-or-self::*[@lang][1][starts-with(concat(translate(@lang, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-'), 'en-')]]"
770        );
771        assert_eq!(
772            html.css_to_xpath("e:lang(*)", "").unwrap(),
773            "e[ancestor-or-self::*[@lang]]"
774        );
775        // xhtml shares the HTML overrides.
776        let xhtml = Translator::new(Mode::Xhtml);
777        assert_eq!(
778            xhtml.css_to_xpath("E:lang(*)", "").unwrap(),
779            "E[ancestor-or-self::*[@lang]]"
780        );
781        // Interior wildcards (RFC 4647 extended filtering) are valid CSS
782        // but inexpressible in XPath 1.0, so both spellings error rather
783        // than over-match (unquoted *-CH) or never match (quoted "*-CH").
784        let t = Translator::new(Mode::Generic);
785        for sel in [
786            "e:lang(*-CH)",
787            "e:lang(\"*-CH\")",
788            "e:lang(de-*-DE)",
789            "e:lang(\"de-*-DE\")",
790        ] {
791            assert!(t.css_to_xpath(sel, "").is_err(), "{sel} should error");
792            assert!(
793                html.css_to_xpath(sel, "").is_err(),
794                "{sel} should error (html)"
795            );
796        }
797        // :dir() takes exactly one identifier (selectors-4) — none of
798        // :lang()'s strings, wildcards, or lists. It never matches in any
799        // translator: resolved directionality needs runtime bidi
800        // resolution, and a nearest-@dir approximation is deliberately
801        // not attempted (see apply_pseudo_class).
802        assert_eq!(xpath("e:dir(rtl)"), "e[0]");
803        assert_eq!(html.css_to_xpath("e:dir(rtl)", "").unwrap(), "e[0]");
804        assert_eq!(xhtml.css_to_xpath("e:dir(ltr)", "").unwrap(), "e[0]");
805        assert!(t.css_to_xpath("e:dir()", "").is_err());
806        assert!(t.css_to_xpath("e:dir(ltr rtl)", "").is_err());
807        assert!(t.css_to_xpath("e:dir(ltr, rtl)", "").is_err());
808        assert!(t.css_to_xpath("e:dir(\"ltr\")", "").is_err());
809        assert!(t.css_to_xpath("e:dir(*)", "").is_err());
810    }
811
812    /// The HTML translator's pseudo-class overrides.
813    #[test]
814    fn html_pseudo_overrides() {
815        let html = Translator::new(Mode::Html);
816        let h = |css: &str| html.css_to_xpath(css, "").unwrap();
817        assert_eq!(
818            h("a:link"),
819            "a[@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')]"
820        );
821        // :any-link is :link plus :visited; with no visited state in a
822        // static document the two coincide, so they share a translation.
823        assert_eq!(h("a:any-link"), h("a:link"));
824        assert_eq!(h("a:ANY-link"), h("a:link"));
825        // @type comparisons fold case (HTML enumerated attribute), so
826        // type="RADIO" reads as a radio. The fold is the same translate()
827        // the `i` attribute flag uses.
828        let t_lc = "translate(@type, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')";
829        assert_eq!(
830            h("input:checked"),
831            format!(
832                "input[(@selected and name(.) = 'option') or (@checked and \
833                 (name(.) = 'input' or name(.) = 'command')and \
834                 ({t_lc} = 'checkbox' or {t_lc} = 'radio'))]"
835            )
836        );
837        // :required/:optional test the @required attribute over the
838        // elements it applies to; input types where it has no effect
839        // match neither.
840        assert_eq!(
841            h("input:required"),
842            format!(
843                "input[@required and ((name(.) = 'input' and not(\
844                 {t_lc} = 'hidden' or {t_lc} = 'range' or {t_lc} = 'color' or \
845                 {t_lc} = 'submit' or {t_lc} = 'image' or {t_lc} = 'reset' or \
846                 {t_lc} = 'button')) or name(.) = 'select' or name(.) = 'textarea')]"
847            )
848        );
849        assert_eq!(
850            h("select:optional"),
851            format!(
852                "select[not(@required) and ((name(.) = 'input' and not(\
853                 {t_lc} = 'hidden' or {t_lc} = 'range' or {t_lc} = 'color' or \
854                 {t_lc} = 'submit' or {t_lc} = 'image' or {t_lc} = 'reset' or \
855                 {t_lc} = 'button')) or name(.) = 'select' or name(.) = 'textarea')]"
856            )
857        );
858        // :disabled/:enabled fold @type case and apply HTML's
859        // "actually disabled" carve-out: a control inside a disabled
860        // fieldset's first legend is NOT disabled. Expressed by counting —
861        // more disabled-fieldset ancestors than protecting first-legends.
862        let fd = "count(ancestor::fieldset[@disabled]) > \
863                  count(ancestor::legend[not(preceding-sibling::legend)]\
864                  [parent::fieldset[@disabled]])";
865        assert_eq!(
866            h("input:disabled"),
867            format!(
868                "input[( @disabled and ( \
869                 (name(.) = 'input' and not({t_lc} = 'hidden')) or \
870                 name(.) = 'button' or name(.) = 'select' or \
871                 name(.) = 'textarea' or name(.) = 'command' or \
872                 name(.) = 'fieldset' or name(.) = 'optgroup' or \
873                 name(.) = 'option' \
874                 ) ) or ( ( \
875                 (name(.) = 'input' and not({t_lc} = 'hidden')) or \
876                 name(.) = 'button' or name(.) = 'select' or \
877                 name(.) = 'textarea' \
878                 ) \
879                 and {fd} \
880                 )]"
881            )
882        );
883        assert_eq!(
884            h("input:enabled"),
885            format!(
886                "input[(@href and (name(.) = 'a' or name(.) = 'link' or \
887                 name(.) = 'area')) or \
888                 ((name(.) = 'command' or name(.) = 'fieldset' or \
889                 name(.) = 'optgroup') and not(@disabled)) or \
890                 (((name(.) = 'input' and not({t_lc} = 'hidden')) \
891                 or name(.) = 'button' or name(.) = 'select' \
892                 or name(.) = 'textarea' or name(.) = 'keygen') \
893                 and not (@disabled or {fd})) \
894                 or (name(.) = 'option' and not(@disabled or \
895                 ancestor::optgroup[@disabled]))]"
896            )
897        );
898        // Non-overridden dynamic pseudos still never match.
899        assert_eq!(h("a:hover"), "a[0]");
900        assert_eq!(h("a:visited"), "a[0]");
901        assert_eq!(h("a:focus-within"), "a[0]");
902        assert_eq!(h("a:focus-visible"), "a[0]");
903    }
904
905    #[test]
906    fn html_translator_lowercases_names_not_values() {
907        let html = Translator::new(Mode::Html);
908        assert_eq!(html.css_to_xpath("DIV", "").unwrap(), "div");
909        assert_eq!(html.css_to_xpath("[FOO]", "").unwrap(), "*[@foo]");
910        // Names lowercase, values keep their case.
911        assert_eq!(
912            html.css_to_xpath("DIV[Value=\"Mixed Case\"]", "").unwrap(),
913            "div[@value = 'Mixed Case']"
914        );
915        // The element inside local-name() is lowercased too.
916        assert_eq!(
917            html.css_to_xpath("*|DIV", "").unwrap(),
918            "*[local-name() = 'div']"
919        );
920        // xhtml preserves case
921        let xhtml = Translator::new(Mode::Xhtml);
922        assert_eq!(xhtml.css_to_xpath("DIV", "").unwrap(), "DIV");
923    }
924
925    #[test]
926    fn prefix_applied_per_branch() {
927        let t = Translator::new(Mode::Generic);
928        assert_eq!(
929            t.css_to_xpath("a, b", "descendant-or-self::").unwrap(),
930            "descendant-or-self::a | descendant-or-self::b"
931        );
932    }
933}