Repository navigation
Releases: sjp/css-to-xpath
Releases · sjp/css-to-xpath
Release list
v0.5.2
Fixed
- An element named
*— written with an escape, as\2a— is no longer
translated as the universal selector when a namespace constraint is written
in front of it. An escape only ever produces an<ident>(css-syntax-3
§4.3.7), so\2ais a type selector whose name happens to be the character
*; only the delimiter*is the universal selector (Selectors 4
§5.1–5.2). The translator told the two apart by comparing the
name to"*", which read*|\2aas*|*(every element in the document,
where the selector asks for elements named*in any namespace) and|\2a
as|*(every element in no namespace). They are now*[local-name() = '*']
and*[name() = '*' and namespace-uri() = ''], matching what the unprefixed
\2aand the prefixedns|\2aalready produced. For the same reason
*|\2a:first-of-type— refused as "an of-type pseudo-class on the universal
selector*" — now translates, counting siblings by the name as any other
type selector does. The universal selector itself is unchanged in every
namespace form (*,|*,*|*,ns|*), and\2ain the prefix
position is still "a namespace prefix that is not an XPath name (*)".
v0.5.1
Added
- The empty language range
:lang("")now has its Selectors 4 meaning — "the
element's language is not known" — where it was rejected as a malformed
range. It is the complement of:lang(*), so it translates to that range's
test negated:e:lang("")is
e[not(ancestor-or-self::*[@lang][1][string-length(@lang) > 0])]under
Mode::Html, and the same shape over@xml:langunderMode::Genericand
over both attributes underMode::Xhtml. An empty attribute counts as no
language, as it does for:lang(*): an element underlang=""matches
:lang("")rather than inheriting the tag above it. XPath's ownlang()
cannot express this, soMode::Genericwalks the language attribute
directly, as it already did for:lang(*).
Changed
- A
:lang()language range that no language tag could match — an empty
subtag (en-,--x) or a*glued to one rather than standing as a whole
subtag (en*,*en) — is now refused by the translator, which names it:
the :lang() language range "en-" (an empty subtag, which a language range cannot have). These were rejected by the argument grammar before, as
`lang` is not a supported pseudo-class or pseudo-element— a message
that named neither the range nor what was wrong with it. So they are now
Error::Unsupported(with no offset) where they wereError::Parse(with a
caret onlang); the set of selectors that error is unchanged apart from
:lang(""), which now translates. The grammar still decides what it alone
can: whether the tokens assemble into ranges at all, which is where
:lang(),:lang(5),:lang(en fr)and a stray comma are still refused. - Every message payload echoed from the selector is bounded by one rule: a
:lang()range is now sanitized and elided past 40 bytes, as a token or
pseudo-class name in a parse error already was. This is visible only in the
:lang()errors above and in the existing "a wildcard outside the final
subtag" one, whose range came through raw however long it was.
Fixed
- A
:lang()range built from more than one token is no longer refused when
it follows another range in the list::lang(en, *-CH)and
:lang(en, de-*)now parse, as:lang(*-CH, en)and:lang(de-*, en)
already did. The argument grammar reads whitespace as a range terminator,
and the flag recording it was not cleared once the next range had started,
so the space after a comma went on separating the tokens of every range
after it — making the multi-token ranges (*-CH, which the tokenizer
splits into*and-CH) look like two ranges with no comma between them.
Whitespace inside a range is still an error::lang(en, * -CH)and
:lang(en fr)are refused as before.
v0.5.0
Changed
- A compound that can never match inside a complex functional-pseudo-class
argument now ends the chain, instead of leaving the reversed-axis test that
carried the compounds to its left in the output:e:is(f > g:is())is
e[0]rather thane[0 and parent::*[self::f]], and
li:nth-child(2 of f > g:is())isli[0]rather than an expression naming
the dead chain twice, once inside acount()over every preceding sibling.
Both selected the same (empty) node set before, so no caller gets a
different answer. This is the foldXPathExpr::conditionalready applied to
a single compound's own conjunction (a:hover[x]isa[0]), reaching the
conjunction thatTranslator::argument_conditionassembles across
compounds; it applies through:is(),:where(),:not()and the nth
of Slists, while:has()builds a path and was never affected. Only the
compounds to the left of the dead one go —e:is(f > g:is() > h)keeps
itsself::hand ise[self::h and parent::*[0]]— and a0that is
already alone inside its axis bracket stays put, soe:is(f:is() > g)
remainse[self::g and parent::*[0]]and still shows which compound is the
impossible one.
v0.4.0
Added
:lang()underMode::HtmlandMode::Xhtmlnow accepts a*subtag in any
position, which is what Selectors 4's RFC 4647 extended filtering allows:
:lang(*-CH)— "any language as written in Switzerland" — was rejected as "a
wildcard outside the final subtag", and now matchesde-CHandfr-Latn-CH.
A leading*stands for the tag's first subtag, so the walk starts one
subtag in rather than anchoring withstarts-with, and:lang(*-CH)
therefore does not match the tagchitself. A*anywhere else moves past
nothing — every range subtag after the first is already searched for through
the whole remaining tag — so it drops out of the translation::lang(de-*-DE)
emits exactly what:lang(de-DE)emits, as:lang(en-*)already emitted what
:lang(en)does.Mode::Genericstill takes only*and a finalen-*,
since it hands the range to XPath'slang(), a prefix match with nowhere to
put the rest; its error now says so.
Changed
- An
optionoroptgroupinside a disabledselectis now:disabledrather
than:enabled, and anoptionis now disabled by the nearestoptgroup
above it rather than only by a parent one. Both are rules HTML added to
"actually disabled" after this translation was written: anoptgroupor
option"whose nearest ancestorselectis disabled" is actually disabled,
and anoptionis disabled by the nearest ancestor that settles it — the
walk ends at aselect,hr,datalistoroption, so anoptionin a
<datalist>inside a disabledselectstays:enabled, and anoption
nested below itsoptgrouprather than directly under it is disabled. This
changes what:disabled,:enabled,option:disabled,option:enabled,
optgroup:disabledandoptgroup:enabledselect over a document with a
disabledselectin it, which is the ordinary way a whole control is
switched off, as well as the expressions they translate to. One corner is
approximated, as thelegendcounting already is: HTML's walk also gives up
once it has passed a secondoptgroup, which XPath cannot count, so an
optiontwooptgroups deep — non-conforming markup — inside a disabled
selectis:disabledhere. - The error for a namespace prefix that is not an XML
NCNamenow names the
rule rather than an operation the prefix cannot have:\31 ns|divgives
a namespace prefix that is not an XPath name (`1ns`)where it
previously gave "a namespace prefix that needs quoting". Quoting is what a
local name that cannot be a node test gets — it folds into aname()or
local-name()comparison — so the old wording named the very fallback the
prefix is being refused for. The README now records why that fallback stops
at the prefix: it is an exact rewrite of a node test, whereas comparing a
wholeprefix:nameagainstname()(which some other translators emit
here) would match on how the document spells its prefix, where every
prefix this crate emits is resolved by what the caller bound it to — and
nothing the caller binds could help, since an XPath expression cannot name
such a prefix at all. Which selectors translate is unchanged. - The
Mode::Genericerror for a wildcard it cannot place now names the reason
rather than only the placement:the :lang() language range "*-CH" (a wildcard outside the final subtag, which XPath's lang() cannot express).
The range is no longer an error everywhere, so the message has to say which
translator it is beyond. - The caret in a parse error's message now points at the token the message
names, rather than at the position the parse stopped on.[a=b c]reports
unexpected `c`at byte 5, thec, where it previously reported byte 4,
the space in front of it;:nth-child(foo)reportsunexpected `foo`at
byte 11 rather than byte 14, the)past it; anda::part(b)reports
`part` is not a supported pseudo-class or pseudo-elementat byte 3, the
name, rather than byte 8, the argument.Error::Parse'soffsetis
therefore usable as a span: where the message echoes a piece of the selector,
the offset is where that piece was written. Positions already on what they
named —div.-5, reported on the-5rather than on the.— do not move. - A selector group whose first token cannot start a compound is now reported by
that token rather than asParseErrorKind::EmptySelector:#1abcgives
unexpected `#1abc`where it previously gave "the selector is empty", which
sent the reader looking for a missing selector instead of at the
digit-leading identifier that is there. It is also the messagep#1abchas
always given, so the same mistake no longer reports two ways depending on
whether a type selector precedes it.EmptySelectornow means only what it
says:""," ","a, , b". - The README listed
:link/:any-linkas matching anaorareawith an
hrefwithout saying that thelinkelement, which also carries anhref,
is outside that set — the difference a reader hits first, since other
translators (selectr) do match it. It now says so, and the exclusion is
quoted from HTML's own wording ("allaelements that have anhref
attribute, and allareaelements that have anhrefattribute, must match
one of :link and :visited") where the translation makes it, so the decision
is not re-opened from memory. Both HTML fixtures grew a<link href>in a
head, pinning at the document level whatlink:link→link[0]already
pinned as a string. No behaviour changed. - The README and the code left the
:disabled/:enabledelement set open to
being read as type-sensitive, since it sits beside:required,
:read-writeand:placeholder-shown, where aninput's type does decide.
It is not: HTML'sdisabledattribute has no "Applies to" list of type
states, so<input type="hidden" disabled>is:disabled, and neither half
of the pair emits a@typetest at all. Other translators (selectr) drop
Hidden from both halves, a carve-out the spec does not make. The README now
says the set is by element alone, the constant holding it says why, and
tests/html_mode.rsasserts the absence of the@typetest rather than
leaving it to be noticed. Both HTML fixtures grew a disabled hiddeninput
and the differential fixture a disabled Hidden/RANGE pair, so a document
pins it too. No behaviour changed. [attr$=value]now spaces the subtraction in the expression it translates
to:a[href$="pdf"]givessubstring(@href, string-length(@href) - 2) = 'pdf'where it previously gavestring-length(@href)-2. The two parse
identically as XPath, so only the string differs — but the string is the
contract. This was the crate's last unspaced binary operator; every other one
it emits already carried a space on both sides. selectr 0.7-0 made the same
change, so the two remain comparable string-for-string.
v0.3.0
[0.3.0] - 2026-09-03
Added
- The form-state pseudo-classes a static translation can answer, under
Mode::HtmlandMode::Xhtml::read-write/:read-only(HTML's
mutability overinputandtextarea, pluscontenteditable
subtrees — the two partition every element),:default(checked
checkbox or radio, selectedoption, and a form's default button) and
:placeholder-shown. They previously failed to parse.:valid,
:in-rangeand:indeterminatestill do, and all four join the
never-match set underMode::Generic. Errornow implementsDisplayandstd::error::Error, so it composes with
?,anyhow,thiserrorand friends.Displayis 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-renderedString. 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, andTranslator::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")translatesbody > ptoh: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
(.cbecomesh:*[…]), but never attribute selectors, and never the
featureless subject of an:is()/:where()/:not()argument;|eand*|e
keep their meanings. Translators built without one are unaffected, so no
existing output moves.Translator::mode(), andDebug/PartialEq/Eq/HashonTranslator.FromStr,DisplayandMode::as_str()onMode, for callers that read a
mode from a CLI flag or a config file:"xhtml".parse()isMode::Xhtml, the
three names are matched ASCII case-insensitively, and nothing else is
accepted. The failure is a new unit-structParseModeError, which implements
Displayandstd::error::Error.DefaultonMode(Mode::Generic) and
onTranslator(Translator::new(Mode::Generic), no default namespace).DESCENDANT_OR_SELFandWHOLE_DOCUMENTconstants for the twoprefix
values worth naming.MAX_NESTING_DEPTH,MAX_NTH_OF_DEPTHandMAX_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 withsxd-xpath, An+B property tests withproptest, a
cargo-fuzztarget, and a weeklycargo-mutantsworkflow. - The string-pinning tests moved out of
src/lib.rsinto per-family suites
undertests/, 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
includelist. The README is now the crate-level documentation. - CI runs
cargo semver-checks check-releaseon 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::Htmllowercasing 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
NCNameproduction instead of
the ASCII-only test used for local names, so a non-ASCII prefix translates
rather than erroring:nsé|divbecomesnsé:div,[nsé|href]becomes
*[@nsé:href]. The rule for local names is unchanged — one that cannot
be a node test still folds intolocal-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 anUnsupportederror, and no selector that
translated before translates differently. The accepted set is XML 1.0's
originalNametables, 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:
Erroris#[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::Unsupportedcarries the offending byte position when it
is known, andError::messagethen renders the same caret gutter an
Error::Parsegets — so"col || td"points at the||, and a selector
nested pastMAX_NESTING_DEPTHpoints at the first parenthesis over the
limit.Displaygains the position too (unsupported CSS construct at byte 4: ...). The position isNonefor the constructs rejected during translation,
whose parsed components carry no source offsets. - The
&nesting selector is nowError::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
||.Unsupportedis 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 onError::Parsefor these
selectors seesError::Unsupportednow. - A misplaced
:scopeand a:host()now carry the byte offset of the
construct, soError::messagepoints a caret at them andDisplaynames the
position (unsupported CSS construct at byte 5: the :scope pseudo-class inside a functional pseudo-class). Both wereoffset: Nonebefore. Where a
:scopeis 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, anAn+B of Slist over the
limits), which the parsed components Servo hands back cannot be located from. - Breaking: removed the
VERSIONconstant; useenv!("CARGO_PKG_VERSION")
in your own crate, or read the dependency's version from Cargo metadata. - A compound whose conditions include a never-matching
0renders as just
that0, and a condition collected twice renders once:a:hover[x]is
a[0]rather thana[0 and @x], anda[href]:any-linkisa[@href]
rather thana[@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 Sargument 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 Xselects whatXdoes, 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)ise[@foo and self::a]. - The
An+Bmodulo 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 andguard:
div.warningis
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, sooption:checkedis
option[@selected]and a name outside a pseudo-class's element set, such as
a:enabled, collapses toa[0]. - Those element names are matched by local name, so they see XHTML's namespaced
elements and work with*|inputandh|inputsubjects alike. Mode::Htmlcompares 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::Xhtmlkeeps 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. - **Break...