css-to-xpath
Translate CSS selectors to XPath 1.0 expressions.
css-to-xpath parses CSS with Servo's
own selectors and cssparser crates. It uses this to construct XPath 1.0 expressions so that they can be evaluated using XML libraries such as libxml2.
Installation
[]
= "0.3"
Quick start
use ;
// mode: Mode::Generic | Mode::Html | Mode::Xhtml; prefix: prepended to the result.
assert_eq!;
assert_eq!;
For repeated translations, build a Translator once and reuse it:
use ;
let translator = new;
let xpath = translator.css_to_xpath.unwrap;
assert_eq!;
Translator flavours
Translator::new takes one of three Mode variants:
Mode::Generic— plain CSS/XPath semantics, case-sensitive names, no HTML-specific pseudo-classes.Mode::Html— lowercases element and attribute names (as HTML parsing does), compares HTML's legacy case-insensitive attribute values (type,rel,lang,checked, … — the list HTML fixes) without regard to case, so[type=CHECKBOX]matches<input type="checkbox">, and gives dynamic-seeming pseudo-classes their static HTML meaning where one exists. Each is limited to the elements HTML defines it over, so nothing else matches::link/:any-link— anaorareawith anhref.:checked— a checkedinputof typecheckboxorradio, or a selectedoption.:disabled/:enabled— the two halves of HTML's "actually disabled" overbutton,input,select,textarea,optgroup,optionandfieldset: thedisabledattribute, anoptionunder a disabledoptgroup, and a disabledfieldsetancestor — with the carve-out that the fieldset's firstlegendkeeps its contents enabled. The two partition that element set. (HTML also lists form-associated custom elements, which no static translation can recognise.):required/:optional— therequiredattribute overselect,textareaand theinputtypes it applies to.:read-write/:read-only— HTML's mutability: aninputof a typereadonlyapplies to (an invalid or missingtypeistext, which it does), or atextarea, that is neitherreadonlynor disabled — plus any element inside acontenteditablesubtree, control or not.:read-onlyis Selectors 4's complement of the whole expression, so unlike:disabled/:enabledthe two partition every element.:default— a checked checkbox or radioinput, a selectedoption, and a form's default button: the first submit button in it, where abuttonwith notypeis one.:placeholder-shown— aninputortextareacarrying a non-emptyplaceholderits type allows, with no value in the markup.:lang()— nearest@langancestor, case-folded, matched by RFC 4647 extended filtering (so:lang(de-DE)matchesde-Latn-DE).
Mode::Xhtml— the same HTML pseudo-class semantics asMode::Html, but preserves case (XHTML is XML, so both names and those attribute values are case-sensitive) and readsxml:langas well aslangfor:lang(), preferringxml:langwhen both are on the nearest ancestor (HTML's language determination). The element names inside these pseudo-classes — thefieldsetancestor, the parentoptgroup, thea/areaof:link— are matched by local name, so they see XHTML's namespaced elements and work with*|inputandh|inputsubjects alike. Only the names you write follow the namespace rule below. When the compound names its element, those local-name tests are settled during translation rather than by the XPath engine:option:checkedisoption[@selected], and a name outside the pseudo-class's element set (a:enabled) leavesa[0].
Pseudo-classes with no static equivalent (:hover, :visited,
:focus, …) always translate to an unmatchable [0] rather than
erroring, in every flavour. :dir() is one of them: it selects on
resolved directionality, which needs the bidi algorithm, and the
nearest-@dir-ancestor approximation was rejected because it gets
dir="auto", bdi, and HTML's invalid-value-means-inherit rule wrong.
Its argument is parsed — exactly one identifier, as Selectors 4 spells
it — but never interpreted, so :dir(ltr), :dir(rtl) and
:dir(anything) all translate alike.
Such a [0] absorbs the rest of its compound's condition, so
a:hover[x] is a[0] and not a[0 and @x], and a condition a compound
collects twice is written once (a[href]:any-link is a[@href]). The
same repeat rule applies to an :is()/:where()/:not()/of S
argument list, whose branches are OR-ed: :is(a, a) is *[self::a].
Nothing beyond those rules is folded: the output is a faithful
translation of the selector, not a minimised expression.
Mode is an ordinary enum, so a caller that has one picks it at compile
time. A caller that reads one at run time — from a CLI flag, a config
file — gets FromStr and Display for the three lowercase names, so
the three-arm match need not be written again in every crate:
use ;
assert_eq!;
// ASCII case-insensitive, and nothing else is accepted.
assert_eq!;
assert_eq!;
assert_eq!;
Mode::default() is Mode::Generic, and Translator::default() is
Translator::new(Mode::Generic) with no default namespace.
The prefix argument
prefix is prepended to each translated selector-group branch — pass
"descendant-or-self::" to search an entire subtree, or "" for a bare
expression:
use ;
assert_eq!;
A selector group anchored on :scope ignores prefix and instead
anchors on the self:: axis, since :scope names the context node the
XPath is evaluated from:
use ;
assert_eq!;
prefix is prepended verbatim and is not validated, so it has to end in
something a node test can follow: an axis or a step separator. The two
that come up are exported as constants — DESCENDANT_OR_SELF
("descendant-or-self::", the context node's subtree) and
WHOLE_DOCUMENT ("//", the whole document wherever the expression is
evaluated from). A prefix ending anywhere else silently produces a
different expression: "/html/body " yields /html/body div, which
XPath reads as a division, not a path.
Supported selectors
- Type, universal (
*), and namespace selectors (ns|e,*|e,|e). - ID (
#id) and class (.class) selectors. - Attribute selectors —
[attr],=,~=,|=,^=,$=,*=— with the Level 4i/scase-sensitivity flags. - Combinators: descendant (
), child (>), next-sibling (+), and subsequent-sibling (~), including selector lists (a, b). - The full nth-family:
:nth-child(),:nth-last-child(),:nth-of-type(),:nth-last-of-type(),:first-child,:last-child,:first-of-type,:last-of-type,:only-child,:only-of-type, and the Level 4An+B of Ssyntax. :is()/:matches()(legacy alias) /:where()/:not()/:has(), including complex (combinator-bearing) arguments and relative-selector leading combinators inside:has(). An empty:is()/:where()argument list is valid and matches nothing (:is()translates to*[0]), as the forgiving-selector-list grammar requires; the rest of forgiveness is not adopted, so an argument that fails to parse is an error rather than a silently dropped one.:scope,:root,:empty,:lang(). UnderMode::Generica range translates to XPath'slang(), except the wildcard:lang(*)— "any known language", whichlang()cannot express — which walks@xml:langinstead.Mode::Xhtmlreads@xml:langfor every range. Both rely on thexmlprefix, which XML binds implicitly and so needs no entry in the caller's namespace map; processors that do not pre-bind it need it registered.:emptyfollows Level 3 rather than Level 4, and:lang()stops short of Level 4 in the ways listed under Approximations.- The
Mode::Html/Mode::Xhtmlform and link pseudo-classes listed above.
Namespaces
A CSS namespace prefix is passed straight through to the XPath, so
svg|g becomes svg:g and the caller's namespace map decides what
svg binds to — this crate never sees namespace URLs, and a prefix that
is not a valid XPath name is an error rather than a guess. "Valid XPath
name" is the XML NCName production, so a non-ASCII prefix is fine:
nsé|div becomes nsé:div.
An unprefixed type name becomes an unprefixed XPath name test, which
matches the null namespace only. That is the rule everywhere the name can
appear — at the top level, on the right of a combinator, and inside
:is(), :where(), :not(), :has() and An+B of S, where it becomes
the equivalent self:: test:
use ;
assert_eq!;
assert_eq!;
So in a document with a default namespace — XHTML, SVG, Atom, … — a
bare p matches nothing, exactly as it would in an XPath expression
written by hand. Ask for the name in any namespace with *|e, which
translates to a local-name() test and is likewise the same wherever it
is written:
use ;
assert_eq!;
The other forms follow from the same rule: |e is "no namespace", which
is what a bare e already means, and a name needing quoting cannot be a
node test at all, so it folds into name() = '…' and namespace-uri() = ''
— the qualified-name comparison alone would also match the name in a
default namespace. Attribute names work the same way, except that an
unprefixed one has no namespace by definition, so [foo] and [|foo]
are the same test and [*|foo] is the any-namespace one.
A default namespace
Writing xhtml| on every step of every selector, or paying for a
local-name() test where a name test would do, is the price of that
rule in a document that has a default namespace. CSS answers this with
@namespace url(…), and so does
[Translator::with_default_namespace_prefix]: unprefixed type selectors
are qualified with the prefix it is given, which the caller's namespace
map binds exactly as a written one.
use ;
let t = new.with_default_namespace_prefix;
assert_eq!;
assert_eq!;
// The escape hatches are unaffected: this is still "no namespace".
assert_eq!;
The semantics are CSS Namespaces 3's. The prefix qualifies type
selectors and the implicit universal selector of a compound that has
none — .c becomes h:*[…], * becomes h:* — but never attribute
selectors, since an unprefixed attribute name has no namespace by
definition. |e still means "no namespace" and *|e still means "any
namespace", so both escape hatches remain. Per Selectors Level 4 the
subject of an :is() / :where() / :not() argument is featureless
unless it has a type or universal selector of its own, so :is(p) picks
the default namespace up and :is(.c) does not.
The prefix is checked when the translation reaches it, exactly as a written one is: one that is not a usable XPath name is an error rather than a guess. An empty prefix means no default namespace, which is where a translator starts.
Not supported
These error rather than approximate, since XPath 1.0 has no way to express them faithfully:
- Pseudo-elements (
::before,::slotted(),::part()). - The Level 4 column combinator (
||) and:nth-col()/:nth-last-col(). - Non-standard extensions:
[attr!=value],:contains(). - Pseudo-classes outside the never-match allow-list, such as
:valid,:in-rangeand:indeterminate— these error instead of silently matching nothing, so typos stay loud.:indeterminateis among them because only itsprogressarm is in the tree: a checkbox's indeterminate flag is set through the DOM and never appears in markup, and "no other radio with this name in this form" would need a predicate to refer to the element being matched from inside a nested one, which XPath 1.0 cannot do. - of-type pseudos (
:first-of-type,:nth-of-type(), …) on any wildcard subject (*,*|*,|*,ns|*) or implicit-type compound: XPath 1.0 cannot compare a sibling's name against the matched element's own. - Nested
:has(), and:host()— shadow-DOM host selection has nothing to match against in a plain document tree. A bare:hostis not a pseudo-class this crate's parser accepts, so it fails to parse rather than reaching translation. - The
&nesting selector, which has no meaning without the enclosing rule a selector-to-XPath function never sees. Like||, it is caught before parsing and named, since a parser with nesting disabled cannot begin a compound with it and blames whatever follows instead. - Namespace prefixes that are not XML
NCNames (\31 ns|div): such a prefix cannot appear in a node test, and XPath 1.0 cannot resolve one without the namespace URI, which this crate never sees. A local name that cannot be a node test is fine —svg|di\[vtranslates tosvg:*[local-name() = 'di[v'], so the prefix still resolves through the caller's namespace map. :scopeoutside the leftmost compound, or inside a functional pseudo-class argument. Both are lexical facts, so — like||and&— the scan of the source text finds them and the error points a caret at the offending:scoperather than leaving the caller to find it.- The empty language range
:lang(""), which Level 4 defines as matching only elements whose language is not tagged. It is rejected with the other malformed ranges (en-,--x,en*) rather than given that meaning; the ones that are supported are described below. - Functional pseudo-classes (
:is(),:not(),:where(),:has(),:nth-child(… of S)) nested more than 32 levels deep. Parsing and translating both recurse once per level, so the depth is capped to turn a pathological selector into an error instead of a stack overflow. The cap is sized to fit a 1 MiB stack — a Windows main thread, a wasm32 module, a thread pool's worker — in an unoptimized build, the most expensive combination. Nothing hand-written comes close; only the nesting depth is limited, not the length of a selector or of an argument chain. The value is exported asMAX_NESTING_DEPTH. :nth-child(… of S)/:nth-last-child(… of S)nested more than 8 levels deep, or a singleof Slist translating to more than 1 MiB. XPath 1.0 has no variables, soShas to be written out twice — once to filter the siblings being counted, once to constrain the element being matched — and a nestedof Slands in both copies, so the output doubles per level. The duplication is inherent to the target language, so only a limit can keep a ~500-byte selector from asking for gigabytes. The two values are exported asMAX_NTH_OF_DEPTHandMAX_NTH_OF_BYTES.
Approximations
These translate to something useful but not to exactly what Selectors Level 4 asks for, because XPath 1.0 — or a static translation of any kind — cannot reach the spec's answer. They are listed here so the contract stays honest.
:lang()does not honour RFC 4647's singleton rule, and underMode::Genericis a prefix match. UnderMode::HtmlandMode::Xhtmla multi-subtag range is matched by extended filtering, as Level 4 asks: the first range subtag must equal the tag's first, and each later one must appear as a whole subtag after it, so:lang(de-DE)matchesde-DE,de-DE-1996andde-Latn-DE. The one rule not modelled is that a subtag may not be skipped past a singleton (a one-character subtag, such as thexopening a private-use section): measuring the length of every skipped subtag is not expressible in XPath 1.0, so:lang(de-DE)also matchesde-x-de, which Level 4 says it should not. UnderMode::Genericthe test is XPath's ownlang(), which is the Level 3 /[lang|=…]prefix match —de-DEthere matchesde-DEandde-DE-1996but notde-Latn-DE. In every mode a written wildcard is allowed only as the whole range (*) or as the final subtag (en-*); an interior one (de-*-DE) errors rather than matching in one mode and erroring in another. Single-subtag ranges —en,en-*,*, the common case — are exact everywhere.:emptyfollows Level 3, so white space counts.e:emptyise[not(*) and not(string-length())], and<p> </p>is therefore not empty. Level 4 ignores document white space; browsers still ship the Level 3 behaviour, and so does this crate.:checkedreads attributes only. An<option>can be selected with noselectedattribute — the first option of a single-select with none marked — and only one radio per group can really be checked. Neither fact is visible to a translation that has only the document tree to work with.:placeholder-shownanswers for the initial value. A document records the value a control starts with, so aninputthe user has since typed into still counts as showing its placeholder. This is:checkedreading@checkedone step further out: the markup is all a static translation has.:defaulttakes the form owner to be the nearest ancestorform. That is what it is for every control written inside its form, which the default-button arm then finds by tree order. A control associated by aform="id"attribute instead — to a form it is not inside, or to none — is not followed, so in markup that usesform=the arm can name the wrong button. The checked-inputand selected-optionarms are exact.- Editability is read from
contenteditablealone.:read-writeresolves the nearest ancestor-or-self that sets acontenteditablestate (inherit, an invalid value, or no attribute leaves the element inheriting), which is the whole story in markup. A document put intodesignModefrom script is editable with nothing in the tree to say so. Mode::Htmllowercases foreign content too.svg|linearGradientbecomessvg:lineargradient, which is right for libxml2's HTML parser, since it lowercases every name it sees. An HTML5 parser (html5ever, a browser) restores the camelCase SVG and MathML names instead, soMode::Htmlis aimed at libxml2-style trees.- Class matching splits on XML white space, by choice.
.fooiscontains(concat(' ', normalize-space(@class), ' '), ' foo '), andnormalize-spacecounts space, tab, CR and LF, while CSS — and so HTML's space-separated tokens — also splits on the form feed U+000C. Aclassattribute that separates two tokens with a form feed keeps them joined here, as does[attr~=value], which shares the translation. The target language could express it: wrapping the attribute intranslate(@class, '<FF>', ' ')before normalising closes the gap. But XPath 1.0 string literals have no escape syntax, so the U+000C would have to sit raw in the output of the most common construct there is — an invisible control character in strings callers compare, cache and embed — to serve aclassattribute almost nobody writes. The value side is exact either way:[attr~=value]folds to a never-matching[0]when the value itself contains any CSS white space, form feed included. - Non-ASCII local names are quoted. A local name is written into
the node test directly only if it is ASCII letters, digits,
_,.or-, soéfolds into the conservative*[name() = 'é' and namespace-uri() = '']. That is a fallback, not a rejection, and it means the same thing, so the ASCII-only rule costs nothing but expression length. A namespace prefix has no such fallback and so is held to the realNCNameproduction instead:nsé|divtranslates tonsé:div. The accepted set is XML 1.0's originalNametables — the ones XPath 1.0 cites, and a subset of the Fifth Edition set later engines use — so the output parses whichever definition the evaluator implements.
Error handling
Error implements Display and std::error::Error, so it propagates
through ? into Box<dyn Error>, anyhow::Error, or a thiserror
#[from] field with no wrapper of its own:
use ;
Display is a one-line summary that needs nothing but the error, so an
error that has travelled a few layers can still be printed:
invalid CSS selector at byte 6: a combinator with nothing after it
unsupported CSS construct at byte 4: the `||` column combinator
unsupported CSS construct at byte 5: the `:scope` pseudo-class inside a functional pseudo-class
unsupported CSS construct: an of-type pseudo-class on the universal selector `*`
A caller that still holds the selector can render the fuller diagnostic
with Error::message, which quotes the selector and, whenever the error
knows a position, points a caret at it:
use ;
let selector = "col || td";
if let Err = css_to_xpath
The CSS selector "col || td" uses the `||` column combinator, which this translator does not support
|
| col || td
| ^
Unable to parse the CSS selector "div > ": a combinator with nothing after it
|
| div >
| ^
The two variants say whose rules were broken. Error::Parse { kind, offset } is a selector CSS itself rejects, with kind a
ParseErrorKind of this crate's own — never a Debug rendering of a
dependency's internal error — and offset the byte position the caret
points at. Error::Unsupported { construct, offset } is a valid selector
this crate declines to approximate. Both are #[non_exhaustive].
Only Error::Parse always knows a position. An Error::Unsupported
knows one — and so renders a caret — for the constructs found by the
pre-parse scan of the source text: the || combinator, the & nesting
selector, nesting past MAX_NESTING_DEPTH, :host(), and a :scope
outside the leftmost compound or inside a functional argument. Its
offset is None for the rest, which are rejected during translation,
where the parsed selector Servo hands back carries no source offsets to
map a component to. Those are the constructs whose supportability
depends on what the compound resolved to rather than on the source text
— an of-type pseudo-class needs a type to count siblings by, a namespace
prefix needs to survive as an XPath name — so locating them would take a
second, approximate model of where the compounds are, which could put
the caret under the wrong one of several identical constructs.
One class of malformed input is not an error: css-syntax-3 closes an
open block, function or string implicitly at end of input, so a truncated
selector translates as though it had been closed. a[b is a[@b],
a[b="x is a[@b = 'x'], :is(a is *[self::a], and a /* comment
is a. Nothing here departs from the spec, but a caller whose selector
can arrive truncated — a cut-off config value, a length-limited form
field — gets a plausible XPath rather than a complaint, and should check
the input's length itself if that matters.
Testing
Five layers, all run by cargo test:
-
Output pinning (
tests/) pins the exact XPath string each selector translates to — the output contract — through the public API only, in per-family suites:selectors.rs,names.rs,attributes.rs,nth.rs,functional_pseudos.rs,scope.rs,lang.rs,html_mode.rs,limits.rs,errors.rsandapi.rs. TheCaseschecker intests/cases/mod.rsdrives them: it names the selector behind a mismatch and reports every mismatch in a family instead of aborting at the first. The unit tests left insrc/cover internal helpers the public API does not reach directly. -
Syntactic validity (
tests/xpath_validity.rs) re-translates every selector in the shared corpus (tests/corpus/selectors.txt) in all three modes, with and without a prefix, and parses the result withsxd-xpath. An unbalanced bracket or a precedence mistake fails here even if the pinned string matches. The corpus is recorded rather than maintained: every selector the pinning suites pin and every selector the semantic suite evaluates is asserted to be a line of that file, and the failure lists the lines to add — so a selector added to a suite cannot quietly escape this oracle or the fuzzer's seeds. -
Semantics (
tests/semantics.rs) evaluates the translated XPath against the fixture documents intests/fixtures/and compares the selected element ids against what the CSS selector should match. The expectations come from the CSS semantics and the document, not from the translator's own output.tests/fixtures/html.xmlis libxml2's HTML parse tree written out as XML — lowercased names, no namespaces — so a pure-Rust XML parser can stand in for it. -
Properties (
tests/nth_property.rs) generateAn+B,An+B of Sand sibling counts withproptestand check the selected positions against the definition ofAn+B. -
Differential (
tests/differential.rs,tests/differential_html.rs) checks the translation against a second implementation rather than against an expectation someone wrote down. Theselectorscrate this one parses with also ships a matcher, so the reference intests/reference/mod.rsimplements itsElementtrait over the same fixture tree the XPath is evaluated on, and the two answers must agree. Aproptestgrammar generates the selectors — compounds, the four combinators, the nth family includingof S,:is()/:where()/:not()/:has(),:root,:empty— while the attribute and of-type shapes, being small finite cross-products, are exhausted rather than sampled.The first suite is
Mode::Generic, and only shapes the translation renders exactly: the one divergence (*|e:first-of-typecounts siblings by local name, since XPath 1.0 cannot compare a sibling's namespace against the subject's) is pinned as a test of its own. The second adds the HTML pseudo-classes whose answer is in the document tree —:disabled/:enabled,:required/:optional,:read-write/:read-only,:default,:checked,:placeholder-shown,:link/:any-link— over an XHTML fixture that holds both answers for every corner of them, with the reference written from the HTML standard's own definitions ("actually disabled", the attribute tables' Applies to rows, the form owner and default button algorithms) rather than from this crate's code. It evaluatesMode::Xhtmland asserts thatMode::Htmltranslates every generated selector to the same string, which is what makes the answer both modes'.:lang()is left out on purpose: extended filtering is the one HTML translation that knowingly diverges from the spec (see Approximations), so a reference for it would encode the divergence rather than check it. The divergence it does have — the overrides identify elements bylocal-name(), so an element of that name in another namespace is treated as the HTML one — is pinned as a test.
Fuzzing lives in fuzz/ and needs
cargo-fuzz and a nightly
toolchain:
The target runs all three modes on a thread with the 1 MiB stack the
nesting limit is sized for, and asserts four properties: no input
panics, output length stays proportionate to input, every successful
translation parses as XPath (the validity oracle above, against inputs
nobody wrote), and translation is deterministic and prefix-independent —
the prefixed output is the bare one with the prefix inserted at the
start of each branch, :scope-anchored branches excepted. CI runs a
two-minute pass on every change; cargo-mutants runs weekly.
Minimum supported Rust version
Rust 1.88, edition 2024 — set by the floor of the cssparser/selectors
dependency versions this crate requires. CI checks that floor rather than
assuming it: one job builds and runs the suite on 1.88, and another runs it
with every direct dependency resolved to the bottom of its range.
Changelog
Release notes, including every change to the XPath a selector translates to, are in CHANGELOG.md. The procedure for cutting a release is RELEASING.md.
License
Licensed under the MIT license.