Skip to content

v0.3.0

Choose a tag to compare

@sjp sjp released this 03 Sep 03:59
· 34 commits to master since this release

[0.3.0] - 2026-09-03

Added

  • The form-state pseudo-classes a static translation can answer, under
    Mode::Html and Mode::Xhtml: :read-write/:read-only (HTML's
    mutability over input and textarea, plus contenteditable
    subtrees — the two partition every element), :default (checked
    checkbox or radio, selected option, and a form's default button) and
    :placeholder-shown. They previously failed to parse. :valid,
    :in-range and :indeterminate still do, and all four join the
    never-match set under Mode::Generic.
  • Error now implements Display and std::error::Error, so it composes with
    ?, anyhow, thiserror and friends. Display is a one-line form that does
    not need the selector (invalid CSS selector at byte 4: ...).
  • ParseErrorKind, a crate-owned #[non_exhaustive] enum describing why a
    selector failed to parse, replacing the pre-rendered String. Payloads that
    echo the selector are sanitized: control characters replaced, text elided past
    40 bytes.
  • Error::message(&self, selector), the full multi-line form with the caret
    gutter, borrowing the error instead of consuming it.
  • Translator::with_default_namespace_prefix(), which puts unprefixed type
    selectors in a default namespace the way a stylesheet's @namespace url(…)
    does, and Translator::default_namespace_prefix(), which reads it back. The
    crate still never sees a namespace URL, so the default namespace is named by
    the prefix the output should carry: Translator::new(Mode::Xhtml) .with_default_namespace_prefix("h") translates body > p to h:body/h:p
    instead of leaving it in the null namespace, which in an XHTML, SVG or Atom
    document matches nothing. The semantics are CSS Namespaces 3's: the prefix
    reaches type selectors and the implicit universal of a compound that has none
    (.c becomes h:*[…]), but never attribute selectors, and never the
    featureless subject of an :is()/:where()/:not() argument; |e and *|e
    keep their meanings. Translators built without one are unaffected, so no
    existing output moves.
  • Translator::mode(), and Debug/PartialEq/Eq/Hash on Translator.
  • FromStr, Display and Mode::as_str() on Mode, for callers that read a
    mode from a CLI flag or a config file: "xhtml".parse() is Mode::Xhtml, the
    three names are matched ASCII case-insensitively, and nothing else is
    accepted. The failure is a new unit-struct ParseModeError, which implements
    Display and std::error::Error. Default on Mode (Mode::Generic) and
    on Translator (Translator::new(Mode::Generic), no default namespace).
  • DESCENDANT_OR_SELF and WHOLE_DOCUMENT constants for the two prefix
    values worth naming.
  • MAX_NESTING_DEPTH, MAX_NTH_OF_DEPTH and MAX_NTH_OF_BYTES, so the limits
    the error messages quote can be read from the API rather than copied.
  • Execution-based tests that run every translated expression against real
    documents with sxd-xpath, An+B property tests with proptest, a
    cargo-fuzz target, and a weekly cargo-mutants workflow.
  • The string-pinning tests moved out of src/lib.rs into per-family suites
    under tests/, so they exercise only the public API. Each suite reports
    every failing selector by name instead of aborting on the first mismatch.
  • Packaging metadata: keywords, categories, documentation, and an
    include list. The README is now the crate-level documentation.
  • CI runs cargo semver-checks check-release on every change, and the MSRV job
    runs the test suite rather than only checking that it compiles. The lints the
    crate has adopted beyond the defaults are declared in a [lints] table, so
    the level is the same locally and in CI.
  • An "Approximations" section in the README recording where the output is
    deliberately not what Selectors Level 4 asks for: :lang() as a prefix
    match rather than RFC 4647 extended filtering, Level 3 :empty,
    attribute-only :checked, Mode::Html lowercasing foreign content,
    form feed in class token lists, and quoted non-ASCII names. The empty
    language range :lang("") is listed under "Not supported". No behaviour
    changed.
  • Namespace prefixes are now held to the XML NCName production instead of
    the ASCII-only test used for local names, so a non-ASCII prefix translates
    rather than erroring: nsé|div becomes nsé:div, [nsé|href] becomes
    *[@nsé:href]. The rule for local names is unchanged — one that cannot
    be a node test still folds into local-name(), which is why being
    conservative there costs nothing and being conservative about a prefix,
    which has no such fallback, cost fidelity. A prefix that is not a name at
    all (\31 ns|div) is still an Unsupported error, and no selector that
    translated before translates differently. The accepted set is XML 1.0's
    original Name tables, the ones XPath 1.0 cites, which are a subset of
    the Fifth Edition set some engines use: the output parses under either
    reading, checked through both sxd-xpath and libxml2.

Changed

  • Breaking: Error is #[non_exhaustive] and its variants are struct-like:
    Error::Parse(String, u32) is now
    Error::Parse { kind: ParseErrorKind, offset: usize }, and
    Error::Unsupported(String) is now
    Error::Unsupported { construct: String, offset: Option<usize> }.
  • Breaking: Error::Unsupported carries the offending byte position when it
    is known, and Error::message then renders the same caret gutter an
    Error::Parse gets — so "col || td" points at the ||, and a selector
    nested past MAX_NESTING_DEPTH points at the first parenthesis over the
    limit. Display gains the position too (unsupported CSS construct at byte 4: ...). The position is None for the constructs rejected during translation,
    whose parsed components carry no source offsets.
  • The & nesting selector is now Error::Unsupported { construct: "the & nesting selector", .. } with the byte offset of the &, instead of whatever
    the parse tripped over next — "&" and "a:is(&)" reported "the selector is
    empty", and "a > &" "a combinator with nothing after it", all pointing at a
    & the caret line showed. A pre-parse scan finds it, exactly as it finds
    ||. Unsupported is the correct variant: & is valid CSS in a nesting
    context, and this crate — which never sees an enclosing rule — not supporting
    it is what that variant means. A caller matching on Error::Parse for these
    selectors sees Error::Unsupported now.
  • A misplaced :scope and a :host() now carry the byte offset of the
    construct, so Error::message points a caret at them and Display names the
    position (unsupported CSS construct at byte 5: the :scope pseudo-class inside a functional pseudo-class). Both were offset: None before. Where a
    :scope is supported — the leftmost compound of its group, and no deeper
    than the top level — is a lexical fact, as is the presence of a :host(), so
    the pre-parse scan that already finds || and & decides them too and knows
    where they are. The scan's findings are consulted only after the parse has
    succeeded, so a selector that is also invalid CSS keeps the parse error it
    reported before. The constructs left with no position are the ones that
    depend on what the compound resolved to (an of-type pseudo-class without a
    type, a namespace prefix that needs quoting, an An+B of S list over the
    limits), which the parsed components Servo hands back cannot be located from.
  • Breaking: removed the VERSION constant; use env!("CARGO_PKG_VERSION")
    in your own crate, or read the dependency's version from Cargo metadata.
  • A compound whose conditions include a never-matching 0 renders as just
    that 0, and a condition collected twice renders once: a:hover[x] is
    a[0] rather than a[0 and @x], and a[href]:any-link is a[@href]
    rather than a[@href and @href]. Both are simplifications of one
    conjunction, so the selected node-set is unchanged; standalone predicates
    (the + combinator's [1]) keep their own brackets.
  • A repeated branch in an :is()/:where()/:not()/of S argument list
    renders once, the same rule the compound's conjunction already applied to a
    repeated condition: :is(a, a) is *[self::a] rather than
    *[self::a or self::a]. X or X selects what X does, so the node-set is
    unchanged; a list that folds down to one branch is no longer an or-group, so
    it also loses the parentheses it would have been given when conjoined —
    e[foo]:is(a, a) is e[@foo and self::a].
  • The An+B modulo offset is spaced like every other operator the crate
    emits: a:nth-last-child(2n) is
    a[(count(following-sibling::*) + 1) mod 2 = 0], not ... +1) .... The
    expression is unchanged for an XPath engine; only the string differs.
  • Class selectors no longer emit the redundant @class and guard:
    div.warning is
    div[contains(concat(' ', normalize-space(@class), ' '), ' warning ')].
  • Element-name tests inside HTML pseudo-classes are settled during translation
    rather than left to the XPath engine, so option:checked is
    option[@selected] and a name outside a pseudo-class's element set, such as
    a:enabled, collapses to a[0].
  • Those element names are matched by local name, so they see XHTML's namespaced
    elements and work with *|input and h|input subjects alike.
  • Mode::Html compares HTML's legacy case-insensitive attribute values (type,
    rel, lang, ... the list HTML fixes) without regard to case, so
    [type=CHECKBOX] matches <input type="checkbox">. Mode::Xhtml keeps those
    values case-sensitive.
  • Dependencies are caret ranges (selectors = "0.40") rather than exact pins,
    so they unify with other crates in a dependency graph.
  • Breaking: Translator is no longer Copy, since it can now own a default
    namespace prefix, and its methods take &self rather than self. It is still
    Clone; method calls do not have to change, but code that relied on an
    implicit copy — passing a translator by value and then using it again — needs
    a borrow or a .clone().
  • :lang() under Mode::Html and Mode::Xhtml matches a multi-subtag range by
    RFC 4647 extended filtering rather than by a dash-terminated prefix, which is
    what Selectors 4 asks for: :lang(de-DE) now also matches de-Latn-DE, and
    :lang(zh-TW) matches zh-Hant-TW. The range's first subtag must still equal
    the tag's first, and each later one must appear as a whole subtag after the
    previous match, so :lang(de-DE) still does not match de-DEUTSCH or
    dede-DE. The emitted XPath grows a contains(…, substring-after(…)) step per
    subtag past the first; single-subtag ranges (en, en-*, *) are unchanged,
    as is Mode::Generic, which delegates to XPath's own prefix-matching lang().
    RFC 4647's rule that a subtag may not be skipped past a singleton is not
    modelled — see the README's Approximations.
  • The functional-pseudo-class nesting limit is 32 levels rather than 64, so
    the recursion it bounds fits the 1 MiB stack a library does not get to choose
    — a Windows main thread, a wasm32 module, a thread pool's worker — in an
    unoptimized build, where a level costs about 16 KB. At 64 such a build aborted
    the process at 57 levels, six short of the depth the limit promised to accept,
    which is the failure the limit exists to prevent. Selectors nested 33 to 64
    deep now return Error::Unsupported rather than translating; nothing
    hand-written comes close, and the length of a selector or an argument chain is
    still unlimited.

Deprecated

  • Error::into_message, which consumes the error. Use Error::message.

Fixed

  • The README listed :dir() under "Not supported", claiming it errors. It has
    always parsed and translated to a never-matching [0], like :hover and
    :visited, because resolved directionality needs the bidi algorithm; the
    README and the Mode docs now say so, and record that the argument is not
    interpreted (:dir(rtl) and :dir(foo) translate alike). No behaviour
    changed.
  • The README's "Testing" section described the string-pinning tests as unit
    tests under src/, which they stopped being when they moved into the
    per-family suites under tests/; it now names those suites and the Cases
    checker that drives them, and quotes the fuzz target's actual 1 MiB stack
    rather than the 2 MiB it used before the nesting limit was resized.
  • The README called the form feed that CSS splits class tokens on, and
    normalize-space does not, something the target language cannot express.
    It can: a translate() mapping U+000C to a space, wrapped around the
    attribute before normalize-space, closes the gap. The bullet now records
    the trade that was actually made and kept — XPath 1.0 string literals have
    no escape syntax, so the fix would put a raw control character in the output
    of the most common construct the crate emits, to serve a class attribute
    almost nobody writes — and a tests/semantics.rs case pins the divergence
    so the decision cannot be reversed by accident. No behaviour changed.
  • The README did not mention that css-syntax-3 auto-closes an open block,
    function or string at end of input, so a truncated selector translates as
    its closed form (a[b is a[@b]) rather than erroring. "Error handling"
    now says so. No behaviour changed.