Skip to main content

Crate css_to_xpath

Crate css_to_xpath 

Source
Expand description

§css-to-xpath

Crates.io Docs.rs CI License: MIT

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

cargo add css-to-xpath
[dependencies]
css-to-xpath = "0.3"

§Quick start

use css_to_xpath::{css_to_xpath, Mode};

// mode: Mode::Generic | Mode::Html | Mode::Xhtml; prefix: prepended to the result.
assert_eq!(
    css_to_xpath("div.warning > a", "", Mode::Generic).unwrap(),
    "div[contains(concat(' ', normalize-space(@class), ' '), ' warning ')]/a"
);

assert_eq!(
    css_to_xpath("li:nth-child(odd)", "", Mode::Generic).unwrap(),
    "li[count(preceding-sibling::*) mod 2 = 0]"
);

For repeated translations, build a Translator once and reuse it:

use css_to_xpath::{Mode, Translator};

let translator = Translator::new(Mode::Generic);
let xpath = translator.css_to_xpath("e:has(> .foo)", "").unwrap();
assert_eq!(
    xpath,
    "e[child::*[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"
);

§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 — an a or area with an href.
    • :checked — a checked input of type checkbox or radio, or a selected option.
    • :disabled/:enabled — the two halves of HTML’s “actually disabled” over button, input, select, textarea, optgroup, option and fieldset: the disabled attribute, an option under a disabled optgroup, and a disabled fieldset ancestor — with the carve-out that the fieldset’s first legend keeps its contents enabled. The two partition that element set. (HTML also lists form-associated custom elements, which no static translation can recognise.)
    • :required/:optional — the required attribute over select, textarea and the input types it applies to.
    • :read-write/:read-only — HTML’s mutability: an input of a type readonly applies to (an invalid or missing type is text, which it does), or a textarea, that is neither readonly nor disabled — plus any element inside a contenteditable subtree, control or not. :read-only is Selectors 4’s complement of the whole expression, so unlike :disabled/:enabled the two partition every element.
    • :default — a checked checkbox or radio input, a selected option, and a form’s default button: the first submit button in it, where a button with no type is one.
    • :placeholder-shown — an input or textarea carrying a non-empty placeholder its type allows, with no value in the markup.
    • :lang() — nearest @lang ancestor, case-folded, matched by RFC 4647 extended filtering (so :lang(de-DE) matches de-Latn-DE).
  • Mode::Xhtml — the same HTML pseudo-class semantics as Mode::Html, but preserves case (XHTML is XML, so both names and those attribute values are case-sensitive) and reads xml:lang as well as lang for :lang(), preferring xml:lang when both are on the nearest ancestor (HTML’s language determination). The element names inside these pseudo-classes — the fieldset ancestor, the parent optgroup, the a/area of :link — are matched by local name, so they see XHTML’s namespaced elements and work with *|input and h|input subjects 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:checked is option[@selected], and a name outside the pseudo-class’s element set (a:enabled) leaves a[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 css_to_xpath::{Mode, ParseModeError};

assert_eq!("xhtml".parse(), Ok(Mode::Xhtml));
// ASCII case-insensitive, and nothing else is accepted.
assert_eq!("HTML".parse(), Ok(Mode::Html));
assert_eq!("xml".parse::<Mode>(), Err(ParseModeError));
assert_eq!(Mode::Generic.to_string(), "generic");

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 css_to_xpath::{css_to_xpath, Mode};

assert_eq!(
    css_to_xpath("a, b", "descendant-or-self::", Mode::Generic).unwrap(),
    "descendant-or-self::a | descendant-or-self::b"
);

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 css_to_xpath::{css_to_xpath, Mode};

assert_eq!(
    css_to_xpath(":scope > a", "descendant-or-self::", Mode::Generic).unwrap(),
    "self::*/a"
);

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 4 i/s case-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 4 An+B of S syntax.
  • :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(). Under Mode::Generic a range translates to XPath’s lang(), except the wildcard :lang(*) — “any known language”, which lang() cannot express — which walks @xml:lang instead. Mode::Xhtml reads @xml:lang for every range. Both rely on the xml prefix, 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. :empty follows Level 3 rather than Level 4, and :lang() stops short of Level 4 in the ways listed under Approximations.
  • The Mode::Html/Mode::Xhtml form 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 css_to_xpath::{css_to_xpath, Mode};

assert_eq!(css_to_xpath("body > p", "", Mode::Generic).unwrap(), "body/p");
assert_eq!(
    css_to_xpath(":is(body > p)", "", Mode::Generic).unwrap(),
    "*[self::p and parent::*[self::body]]"
);

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 css_to_xpath::{css_to_xpath, Mode};

assert_eq!(
    css_to_xpath("*|body > *|p", "", Mode::Generic).unwrap(),
    "*[local-name() = 'body']/*[local-name() = 'p']"
);

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 css_to_xpath::{Mode, Translator};

let t = Translator::new(Mode::Xhtml).with_default_namespace_prefix("h");
assert_eq!(t.css_to_xpath("body > p", "").unwrap(), "h:body/h:p");
assert_eq!(
    t.css_to_xpath("p:is(a, b)", "").unwrap(),
    "h:p[self::h:a or self::h:b]"
);
// The escape hatches are unaffected: this is still "no namespace".
assert_eq!(t.css_to_xpath("|p", "").unwrap(), "p");

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-range and :indeterminate — these error instead of silently matching nothing, so typos stay loud. :indeterminate is among them because only its progress arm 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 :host is 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\[v translates to svg:*[local-name() = 'di[v'], so the prefix still resolves through the caller’s namespace map.
  • :scope outside 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 :scope rather 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 as MAX_NESTING_DEPTH.
  • :nth-child(… of S) / :nth-last-child(… of S) nested more than 8 levels deep, or a single of S list translating to more than 1 MiB. XPath 1.0 has no variables, so S has to be written out twice — once to filter the siblings being counted, once to constrain the element being matched — and a nested of S lands 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 as MAX_NTH_OF_DEPTH and MAX_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 under Mode::Generic is a prefix match. Under Mode::Html and Mode::Xhtml a 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) matches de-DE, de-DE-1996 and de-Latn-DE. The one rule not modelled is that a subtag may not be skipped past a singleton (a one-character subtag, such as the x opening a private-use section): measuring the length of every skipped subtag is not expressible in XPath 1.0, so :lang(de-DE) also matches de-x-de, which Level 4 says it should not. Under Mode::Generic the test is XPath’s own lang(), which is the Level 3 / [lang|=…] prefix match — de-DE there matches de-DE and de-DE-1996 but not de-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.
  • :empty follows Level 3, so white space counts. e:empty is e[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.
  • :checked reads attributes only. An <option> can be selected with no selected attribute — 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-shown answers for the initial value. A document records the value a control starts with, so an input the user has since typed into still counts as showing its placeholder. This is :checked reading @checked one step further out: the markup is all a static translation has.
  • :default takes the form owner to be the nearest ancestor form. 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 a form="id" attribute instead — to a form it is not inside, or to none — is not followed, so in markup that uses form= the arm can name the wrong button. The checked-input and selected-option arms are exact.
  • Editability is read from contenteditable alone. :read-write resolves the nearest ancestor-or-self that sets a contenteditable state (inherit, an invalid value, or no attribute leaves the element inheriting), which is the whole story in markup. A document put into designMode from script is editable with nothing in the tree to say so.
  • Mode::Html lowercases foreign content too. svg|linearGradient becomes svg: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, so Mode::Html is aimed at libxml2-style trees.
  • Class matching splits on XML white space, by choice. .foo is contains(concat(' ', normalize-space(@class), ' '), ' foo '), and normalize-space counts space, tab, CR and LF, while CSS — and so HTML’s space-separated tokens — also splits on the form feed U+000C. A class attribute 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 in translate(@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 a class attribute 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 real NCName production instead: nsé|div translates to nsé:div. The accepted set is XML 1.0’s original Name tables — 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 css_to_xpath::{css_to_xpath, Mode};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let xpath = css_to_xpath("div > p", "", Mode::Generic)?;
    println!("{xpath}");
    Ok(())
}

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 css_to_xpath::{css_to_xpath, Mode};

let selector = "col || td";
if let Err(e) = css_to_xpath(selector, "", Mode::Generic) {
    eprintln!("{}", e.message(selector));
}
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.rs and api.rs. The Cases checker in tests/cases/mod.rs drives 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 in src/ 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 with sxd-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 in tests/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.xml is 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) generate An+B, An+B of S and sibling counts with proptest and check the selected positions against the definition of An+B.

  • Differential (tests/differential.rs, tests/differential_html.rs) checks the translation against a second implementation rather than against an expectation someone wrote down. The selectors crate this one parses with also ships a matcher, so the reference in tests/reference/mod.rs implements its Element trait over the same fixture tree the XPath is evaluated on, and the two answers must agree. A proptest grammar generates the selectors — compounds, the four combinators, the nth family including of 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-type counts 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 evaluates Mode::Xhtml and asserts that Mode::Html translates 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 by local-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:

./fuzz/seed-corpus.sh
cargo +nightly fuzz run translate -- -max_total_time=60 -max_len=4096

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.

Structs§

ParseModeError
The error Mode’s FromStr impl returns: the string named no mode.
Translator
A reusable translator for one Mode, optionally carrying a default namespace prefix.

Enums§

Error
Why a CSS selector could not be translated.
Mode
The translator flavour: which pseudo-class overrides, name-casing rules, and :lang() language source to apply.
ParseErrorKind
What is wrong with a selector that is not valid CSS.

Constants§

DESCENDANT_OR_SELF
A prefix that searches the context node and its whole subtree: "a" becomes descendant-or-self::a.
MAX_NESTING_DEPTH
The maximum functional-pseudo-class nesting depth accepted, measured as parenthesis nesting in the source selector. Both Servo’s parser and this crate’s translator recurse once per nesting level (as does dropping the resulting selector tree), so an unbounded depth would overflow the stack — a hard abort, not a panic, so the caller cannot catch it.
MAX_NTH_OF_BYTES
The maximum size of one of S translation, a last line of defence behind MAX_NTH_OF_DEPTH: the doubling is bounded by the depth limit, but the argument it doubles is bounded only by the length of the selector, so cap the product too. Checked per nesting level, which caps the largest string ever built at roughly twice this.
MAX_NTH_OF_DEPTH
The maximum An+B of S nesting depth accepted.
WHOLE_DOCUMENT
A prefix that searches the whole document, wherever the expression is evaluated from: "a" becomes //a.

Functions§

css_to_xpath
Translate a CSS selector to an XPath 1.0 expression.