Repository navigation
Selectors
Parsec's selector engine lives in parsec::css (crates/parsec/src/css.rs). It parses selectors from Selectors Level 4 once, into a SelectorList, and matches them right to left against a document.
On this page: The API · Syntax · Simple selectors · Attribute selectors · Logical pseudo-classes · Tree-structural pseudo-classes · Link, form and editing pseudo-classes · Parsed, but never match · What's refused · XML elements · The nesting limit · Cost
The rule for what matches is simple to state: what a browser would match on the same page if nobody were looking at it. No pointer is hovering, nothing has focus, no link has been visited, and there's no URL. Everything a static page can decide from its markup should match; what it can't decide never matches. Where Parsec doesn't match something it could, that's a gap with an issue, listed below.
This page lists every selector Parsec parses, from the parser and from crates/parsec/tests/css.rs, and puts Lexbor's behaviour beside it, read from Lexbor's selectors.c at commit 145872a.
pub fn parse(selector: &str) -> Result<SelectorList, SelectorError>
Parses a comma-separated list of complex selectors. An element matches the list if it matches any of them. Errors are listed under What's refused. A SelectorList is Debug and Clone, holds no reference to any document, and can be kept and reused on as many documents as you like.
Tip
Parsing is the expensive part, so parse once and keep it.
pub fn select(&self, doc: &Document, scope: NodeId) -> Vec<NodeId>
Every element under scope that matches, in document order.
-
scopeitself is never in the result, only its descendants. -
scopecan be any node: the document, an element, a fragment root, or a template's contents node. - Combinators look past
scope, asquerySelectorAlldoes:div lirun on a<ul>finds thelis inside it when adivis above the<ul>. To pin a selector to the scope, start it with:scope. -
:scopeisscopewhen it's an element. When it's a document or fragment node,:scopemeans the same as:root. - It doesn't look inside template contents, which aren't under their template. Run it on
template_contents(id), or calladopt_template_contentsfirst. - It visits every node under
scopeand returns them all. There's no way to stop at the first match yet (#18).
pub fn matches(&self, doc: &Document, el: NodeId, scope: Option<NodeId>) -> bool
Whether el matches, with scope as :scope. None makes :scope the root element. It's false for anything that isn't an element.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectorError {
pub reason: String,
}
Why a selector didn't parse, in words: "pseudo-elements can't be selected", ":lang() isn't supported", "an attribute selector without its ]". It implements Display (the reason) and std::error::Error. The wording is for people; don't match on it.
use parsec::css::SelectorList;
let doc = parsec::parse(
"<div><ul><li class=a>1</li><li class=b>2</li></ul></div>",
false,
);
let ul = SelectorList::parse("ul").unwrap().select(&doc, doc.root())[0];
// Combinators look past the scope...
let past = SelectorList::parse("div li").unwrap();
assert_eq!(past.select(&doc, ul).len(), 2);
// ...unless you anchor to it.
let direct = SelectorList::parse(":scope > li.b").unwrap();
let found = direct.select(&doc, ul);
assert_eq!(doc.text(found[0]), "2");
// The scope itself is never a result.
assert!(SelectorList::parse("ul").unwrap().select(&doc, ul).is_empty());
// matches() tests one element.
assert!(direct.matches(&doc, found[0], Some(ul)));
assert!(!direct.matches(&doc, found[0], None));
let err = SelectorList::parse("a::before").unwrap_err();
assert_eq!(err.reason, "pseudo-elements can't be selected");- Lists:
a, b. An empty list, an empty entry (a,,b), a leading comma (, a) and a trailing one (a,) are errors. - Combinators: descendant (whitespace), child
>, next-sibling+, subsequent-sibling~. The column combinator||is refused (#17). - Whitespace is space, tab, line feed, carriage return and form feed.
- Comments
/* ... */are skipped, but treated as whitespace, soa/**/.breads asa .brather thana.b. - Escapes work in names and strings:
#a\:b,.w-1\/2,#\39 lives. A hex escape takes up to six digits and one following whitespace character; zero, surrogates and values past U+10FFFF become U+FFFD. A backslash before a newline is an error in a name; in a string it continues the line. - Strings in attribute values take
"or'. A newline inside one is an error; one left open at the end of the selector is accepted. - Case: pseudo-class names,
odd,even,of,nand the attribute flags are ASCII case-insensitive (:NTH-CHILD(ODD)is fine).
| Selector | Matches | Lexbor |
|---|---|---|
E |
Elements named E. HTML elements compare in any case; SVG and MathML elements compare exactly (clipPath, not clippath), as in a browser. See XML elements for Ns::Xml. |
Matches |
* |
Every element. | Matches |
*|E, *|*
|
E (or anything) in any namespace. |
Matches |
|E, |*
|
Elements in no namespace. Every element the HTML parser makes has one, so these never match. | Matches |
ns|E |
Refused: there's no @namespace to declare ns with (#17). |
Not checked |
#id |
Elements whose id attribute is exactly id; without case in a Quirks::Quirks document. |
Matches |
.c |
Elements whose class, split on ASCII whitespace, contains c; without case in a Quirks::Quirks document. |
Matches |
use parsec::css::SelectorList;
let count = |html: &str, css: &str| {
let doc = parsec::parse(html, false);
SelectorList::parse(css).unwrap().select(&doc, doc.root()).len()
};
let page = "<!DOCTYPE html><div id=X class='Wide page'><svg><clipPath/></svg></div>";
assert_eq!(count(page, "DIV"), 1);
assert_eq!(count(page, "clipPath"), 1);
assert_eq!(count(page, "clippath"), 0);
assert_eq!(count(page, ".Wide"), 1);
assert_eq!(count(page, ".wide"), 0);
assert_eq!(count(page, "#x"), 0);
// Without a doctype the page is in quirks mode: ids and classes ignore case.
let quirky = "<div id=X class=Y></div>";
assert_eq!(count(quirky, "#x"), 1);
assert_eq!(count(quirky, ".y"), 1);| Selector | Matches |
|---|---|
[a] |
Has an attribute a. |
[a=v] |
Value is exactly v. |
[a~=v] |
Value, split on whitespace, contains v. Never matches if v is empty or contains whitespace. |
[a|=v] |
Value is v, or starts with v-. |
[a^=v] |
Value starts with v. Never matches if v is empty. |
[a$=v] |
Value ends with v. Never matches if v is empty. |
[a*=v] |
Value contains v. Never matches if v is empty. |
[a=v i] |
Compares the value without ASCII case. |
[a=v s] |
Compares the value with case, even for the attributes HTML lists as case-insensitive. |
[*|a] |
An attribute with local name a in any namespace, or none: xlink:href matches [*|href]. |
[|a] |
An attribute a in no namespace, the same as [a]. |
Lexbor has all of these.
The value is a quoted string or an identifier: [data-id="17"] and [lang=en] work, [data-id=17] is an error because 17 isn't an identifier, as in CSS. Any flag other than i or s is an error, and so is a namespace prefix on the name ([xlink|href]).
How names and values compare:
- On HTML elements the name compares without case (
[HREF]findshref). On SVG and MathML elements it compares with the case the spec gives the attribute, so[viewBox]matches and[viewbox]doesn't, as in a browser. - A plain name means an attribute in no namespace. SVG's
xlink:hrefis in the XLink namespace, so[href]doesn't find it and[*|href]does. - Values compare with case, except on HTML elements for the 46 attributes the HTML Standard lists as case-insensitive in selectors,
typeandlangamong them. Thesflag turns that off;iturns it on for any attribute.
All 46 case-insensitive attributes
accept, accept-charset, align, alink, axis, bgcolor, charset, checked, clear, codetype, color, compact, declare, defer, dir, direction, disabled, enctype, face, frame, hreflang, http-equiv, lang, language, link, media, method, multiple, nohref, noresize, noshade, nowrap, readonly, rel, rev, rules, scope, scrolling, selected, shape, target, text, type, valign, valuetype and vlink.
use parsec::css::SelectorList;
let doc = parsec::parse(
r##"<input type="TEXT"><a href="/p/1" hreflang="en-GB">a</a>
<svg viewBox="0 0 1 1"><use xlink:href="#i"/><a href="b"></a></svg>"##,
false,
);
let n = |css: &str| SelectorList::parse(css).unwrap().select(&doc, doc.root()).len();
assert_eq!(n("input[type=text]"), 1); // type is on HTML's list
assert_eq!(n("input[type=text s]"), 0);
assert_eq!(n("[hreflang|=en]"), 1);
assert_eq!(n("a[href^='/']"), 1);
assert_eq!(n("[viewBox]"), 1);
assert_eq!(n("[viewbox]"), 0);
assert_eq!(n("[href]"), 2); // the HTML a and the SVG a
assert_eq!(n("[*|href]"), 3); // plus use's xlink:href| Selector | Matches | Lexbor |
|---|---|---|
:not(list) |
Elements that match none of the selectors in the list. The list takes complex selectors and isn't forgiving: one bad entry makes the whole selector an error. | Matches |
:is(list) |
Elements that match any of them. The list is forgiving, as the spec says: entries that don't parse are dropped, so :is(h1, ::nonsense, ) is :is(h1), and :is() is valid and matches nothing. |
Matches |
:where(list) |
The same as :is(). Parsec doesn't compute specificity yet (#18), so the two don't differ. |
Matches |
:matches(list) |
An old name for :is(), accepted. |
Not checked |
:has(relative list) |
Elements with something related to them that matches. Each entry may start with >, + or ~; with none, it means a descendant. The list isn't forgiving. |
Matches |
Unlike the spec and browsers, Parsec accepts :has() nested inside :has().
use parsec::css::SelectorList;
let doc = parsec::parse(
r#"<ul><li class=first><a href="/1">A</a></li>
<li class=sold><a href="/2">B</a><span class=price>2</span></li>
<li><a href="https://x.example/3">C</a></li></ul>"#,
false,
);
let n = |css: &str| SelectorList::parse(css).unwrap().select(&doc, doc.root()).len();
assert_eq!(n("li:has(.price)"), 1);
assert_eq!(n("li:has(> a[href^=http])"), 1);
assert_eq!(n("li:has(+ li.sold)"), 1);
assert_eq!(n("li:not(.sold, .first)"), 1);
assert_eq!(n(":is(li.sold, span)"), 2);
assert_eq!(n(":is(li, ::nonsense)"), 3);Only elements count as siblings; text and comments between them don't.
| Selector | Matches | Lexbor |
|---|---|---|
:root |
The element whose parent is the document node: <html>. Never matches in a fragment. |
Matches |
:scope |
The element a query runs from. See select for the rules. |
Parsed, never matches; support is an open pull request, lexbor#257 |
:empty |
Elements with no children but comments, processing instructions and empty text. A space counts as content. | Matches |
:first-child, :last-child, :only-child
|
Position among element siblings. | Matches |
:first-of-type, :last-of-type, :only-of-type
|
Position among siblings with the same namespace and name. | Matches |
:nth-child(An+B), :nth-last-child(An+B)
|
The An+B-th child, counting from the start or end. | Matches |
:nth-child(An+B of S), :nth-last-child(An+B of S)
|
Counting only siblings that match the selector list S. |
Matches |
:nth-of-type(An+B), :nth-last-of-type(An+B)
|
Counting siblings of the same type. of S is an error here, as in the spec. |
Matches |
An+B accepts odd, even, an integer, and An+B forms such as n, -n+2, 2n + 1 and 3n-1. A and B must fit in an i32. Parsec is looser than CSS about spaces: 2 n and + 5 are accepted where a browser would refuse them. :nth-child(0) is valid and matches nothing.
use parsec::css::SelectorList;
let doc = parsec::parse(
"<ul><li>1</li><li class=x>2</li><li>3</li><li class=x>4</li></ul><p></p><p> </p><p><!--c--></p>",
false,
);
let texts = |css: &str| -> Vec<String> {
SelectorList::parse(css).unwrap().select(&doc, doc.root())
.into_iter().map(|n| doc.text(n)).collect()
};
assert_eq!(texts("li:nth-child(odd)"), ["1", "3"]);
assert_eq!(texts("li:nth-child(-n+2)"), ["1", "2"]);
assert_eq!(texts("li:nth-last-child(1)"), ["4"]);
assert_eq!(texts("li:nth-child(2 of .x)"), ["4"]);
assert_eq!(texts("p:empty").len(), 2);
assert_eq!(texts(":root").len(), 1);These follow the HTML Standard's definitions, read from attributes, because the attributes are all a static page has. Only HTML elements can match them.
The link, form and editing pseudo-classes, with the exact rules
| Selector | Matches | Lexbor |
|---|---|---|
:any-link, :link
|
<a> and <area> with an href. With nothing visited, every link is unvisited, so :link is the same as :any-link. |
Matches |
:checked |
<input type=checkbox> and <input type=radio> (type in any case) with a checked attribute, and <option> with a selected attribute. An option that's selected only because it's first in its <select> doesn't match. |
Matches |
:disabled |
The spec's "actually disabled": a button, input, select, textarea or fieldset with disabled, or inside a <fieldset disabled> but not inside that fieldset's first <legend>; an optgroup with disabled; an option with disabled or inside an <optgroup disabled>. |
Matches |
:enabled |
A button, input, select, textarea, optgroup, option or fieldset that isn't disabled. |
Matches |
:required |
An input, select or textarea with required. |
Matches |
:optional |
An input, select or textarea without required. |
Matches |
:read-write |
An input whose type is text-like (text, search, url, tel, email, password, date, month, week, time, datetime-local, number, or missing) and that is neither readonly nor disabled; a textarea that is neither; any other element inside a contenteditable that isn't "false". |
Matches |
:read-only |
Every element that isn't :read-write. |
Matches |
:placeholder-shown |
An input with placeholder and an empty or missing value; a textarea with placeholder and no text. |
Matches |
use parsec::css::SelectorList;
let doc = parsec::parse(
r#"<form><fieldset disabled><legend><input name=in-legend></legend><input name=off></fieldset>
<input type=checkbox checked name=box><input type=TEXT required readonly name=ro>
<textarea placeholder=say></textarea></form>"#,
false,
);
let names = |css: &str| -> Vec<String> {
SelectorList::parse(css).unwrap().select(&doc, doc.root()).into_iter()
.filter_map(|n| doc.attrs(n).find(|a| a.1 == "name").map(|a| a.2.to_string()))
.collect()
};
assert_eq!(names("input:disabled"), ["off"]);
assert_eq!(names(":checked"), ["box"]);
assert_eq!(names(":required"), ["ro"]);
assert_eq!(names("input:read-only"), ["off", "box", "ro"]);
assert_eq!(SelectorList::parse(":placeholder-shown").unwrap().select(&doc, doc.root()).len(), 1);These parse, so a stylesheet's selectors don't fail on them, and always give false. Each one is about a state a page nobody's using can't be in.
| Selector | Why it never matches | Lexbor |
|---|---|---|
:hover, :active, :focus
|
Nothing is hovered, pressed or focused. | Matches elements that have an attribute of the same name: active, focus, hover
|
:focus-visible, :focus-within
|
Nothing has focus. | Never matches |
:visited |
Nothing has been visited. | Never matches |
:target, :target-within
|
There's no URL, so no fragment to target. Matching given a URL is planned (#16). | Never matches |
:local-link |
There's no document URL to compare links with. Planned with a URL (#16). | Never matches |
:user-valid, :user-invalid
|
They depend on the user having interacted with the control. |
:user-invalid never matches |
Everything not listed above is an error from SelectorList::parse. Some of these Lexbor parses and never matches; Parsec refuses them for now, so a query can't silently find nothing.
Note
The pseudo-classes marked planned below are refused today. They aren't shipped until they move up into the tables above.
Everything that's refused, and what Lexbor does with it
| Selector | Status | Lexbor |
|---|---|---|
Pseudo-elements: ::before, ::text, ::attr(), ::slotted() and every other ::x
|
Refused; selectors find elements, not parts of them. | Parsed, never matches |
Legacy single-colon pseudo-elements: :before, :after, :first-line, :first-letter
|
Refused as unknown pseudo-classes. | Not checked |
:blank, :defined
|
Planned, #13. |
:blank matches elements Lexbor counts as empty (selectors.c); :defined not checked |
:lang(), :dir()
|
Planned, #14. | Parsed, never matches |
:default, :indeterminate, :valid, :invalid, :in-range, :out-of-range
|
Planned, #15. | Parsed, never matches |
:nth-col(), :nth-last-col(), the column combinator ||, namespace prefixes such as svg|rect
|
Planned, #17. |
|| and the column pseudo-classes parsed, never match |
:current, :past, :future, :fullscreen, :host, :open, :modal, :popover-open and other pseudo-classes not on this page |
Refused. |
:current, :past, :future, :fullscreen parsed, never match |
:lexbor-contains() |
Refused; it's Lexbor's own extension. Filter on text instead. |
Matches |
[a!=b], an unknown attribute flag, an unquoted value that isn't an identifier |
Refused. | Not checked |
Lexbor's column is from its matcher, selectors.c; "Not checked" means we haven't looked. One open Lexbor matching bug is worth knowing if you're comparing results: ~ after :is() or :where() only looks at the next sibling (lexbor#436).
use parsec::css::SelectorList;
for bad in ["", "a,", "a::before", ":before", "svg|rect", "a || b", ":lang(en)", ":nope", "[a!=b]", "[a=b x]"] {
assert!(SelectorList::parse(bad).is_err(), "{bad:?} parsed");
}
for never in [":hover", ":visited", ":target", ":focus-within"] {
let doc = parsec::parse("<a href=x>x</a>", false);
assert!(SelectorList::parse(never).unwrap().select(&doc, doc.root()).is_empty());
}Elements created with Ns::Xml (Parsec's HTML parser never makes them) match as Netweir's XML support needs:
- A type selector names the local part, in any case:
creatormatchesdc:creatorandCreator. - An attribute selector finds the attribute by its local name in any case, preferring one written exactly as asked:
[id=lo]looks atidbeforeID, and only that one attribute is compared. Values compare with case unless you addi. -
#idand.classcompare exactly, using the attribute named exactlyidorclass.
Parsing and matching both recurse once for each functional pseudo-class inside another (:not, :is, :where, :matches, :has and the :nth-* family, including an of S list). To keep a hostile selector from overflowing the stack, nesting stops at 32 levels. The 33rd is an error: selectors nest more than 32 deep. Inside a forgiving :is() or :where(), the part that's too deep is dropped like any other bad entry, so the selector parses and that branch matches nothing.
use parsec::css::SelectorList;
let ok = format!("{}a{}", ":not(".repeat(32), ")".repeat(32));
assert!(SelectorList::parse(&ok).is_ok());
let deep = format!("{}a{}", ":not(".repeat(33), ")".repeat(33));
assert_eq!(SelectorList::parse(&deep).unwrap_err().reason, "selectors nest more than 32 deep");Matching runs right to left: for each element, the rightmost compound first, then the combinators outward. A few things cost more than they look:
-
selectwalks every node under the scope. - A descendant combinator tries each ancestor in turn, and long chains such as
div div div pon a deep page retry the same ancestors; there's no caching between attempts. -
:has()searches the subtree (or the following siblings and their subtrees) of every element it's tested on. - An attribute selector reads every attribute of every element it's tested on.
Netweir's own measurements found [class] and long descendant chains the slowest against Lexbor. A selector benchmark in this repository is planned (#3); Benchmarks has what's been measured so far.
- The document tree, for reading the nodes a query returns.
- Moving from Lexbor, for the differences in one list.
- Limits and hostile input, for the other limits Parsec keeps.
Parsec is Apache-2.0 and not yet released; these pages describe main. Found something wrong here? Open an issue or ask in Discussions.
Repository · Issues · Discussions · Roadmap · Security policy · Licence