Skip to content

Selectors

James Conway edited this page Nov 4, 2026 · 2 revisions

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.

The API

SelectorList::parse

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.

SelectorList::select

pub fn select(&self, doc: &Document, scope: NodeId) -> Vec<NodeId>

Every element under scope that matches, in document order.

  • scope itself is never in the result, only its descendants.
  • scope can be any node: the document, an element, a fragment root, or a template's contents node.
  • Combinators look past scope, as querySelectorAll does: div li run on a <ul> finds the lis inside it when a div is above the <ul>. To pin a selector to the scope, start it with :scope.
  • :scope is scope when it's an element. When it's a document or fragment node, :scope means the same as :root.
  • It doesn't look inside template contents, which aren't under their template. Run it on template_contents(id), or call adopt_template_contents first.
  • It visits every node under scope and returns them all. There's no way to stop at the first match yet (#18).

SelectorList::matches

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.

SelectorError

#[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");

Syntax

  • 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, so a/**/.b reads as a .b rather than a.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, n and the attribute flags are ASCII case-insensitive (:NTH-CHILD(ODD) is fine).

Simple selectors

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);

Attribute selectors

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] finds href). 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:href is 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, type and lang among them. The s flag turns that off; i turns 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

Logical pseudo-classes

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);

Tree-structural pseudo-classes

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);

Link, form and editing pseudo-classes

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);

Parsed, but never match

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

What's refused

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());
}

XML elements

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: creator matches dc:creator and Creator.
  • An attribute selector finds the attribute by its local name in any case, preferring one written exactly as asked: [id=lo] looks at id before ID, and only that one attribute is compared. Values compare with case unless you add i.
  • #id and .class compare exactly, using the attribute named exactly id or class.

The nesting limit

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");

Cost

Matching runs right to left: for each element, the rightmost compound first, then the combinators outward. A few things cost more than they look:

  • select walks every node under the scope.
  • A descendant combinator tries each ancestor in turn, and long chains such as div div div p on 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.

See also

Clone this wiki locally