Skip to content

selectors_vm: apply De Morgan's laws to :not() - #9

Open
robobun wants to merge 2 commits into
bunfrom
robobun/31756292/not-de-morgan
Open

robobun wants to merge 2 commits into
bunfrom
robobun/31756292/not-de-morgan

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • :not() with a compound selector matches too little. :not(div.foo) must match every element that is not a div.foo. It matches only elements that are neither a div nor .foo, the same as :not(div):not(.foo).
  • Under a second :not() the error is the other way. :not(:not(div, span)) must match what div, span matches. It matches what div and span match at the same time, which is nothing.
  • The cause is Ast::add_selector (src/selectors_vm/ast.rs). A Predicate is a conjunction of simple expressions, each with a negation flag. The builder negates every simple selector of the :not() argument and puts them all in one predicate. That is !a && !b. It is right for a list (:not(a, b)), and wrong for a compound selector (:not(a.b) is !a || !b). Each nesting level swaps the two cases.

Fix

  • A selector now expands to alternatives, one Predicate each, with De Morgan's laws. :not(a.b) has two: !a and !b. :not(:not(a, b)) has two: a and b.
  • Every alternative gets an AST node with the same match id. A selector list (a, b) already works this way, and the VM collects match ids in a set per element, so a handler runs once for an element that matches several alternatives.
  • A selector that does not need OR expands to one alternative. It builds the same AST as before, with no clone. The existing AST tests pass unchanged.
  • The expansion multiplies: :not(a.b):not(c.d) has 4 alternatives, and every alternative of a compound selector repeats the rest of the selector below it in the AST. The parser counts the expressions that all the alternatives hold together, and refuses more than 4096 (SelectorError::UnsupportedSyntax) when there is more than one alternative. The count comes from the same traversal that builds the alternatives (trait Expansion), so the two cannot disagree.

Background

  • lol-html compiles each compound selector to one predicate and each combinator to an edge in the AST. The VM runs every root node's predicate for each start tag.
  • The parser already refuses combinators inside :not(), so each selector of a :not() list is one compound selector.
  • :not(|p) keeps matching what :not(p) matches. The negated no-namespace prefix used to be one more term of the conjunction, and it is true for every element. The css3-modsel-120 and 123 fixtures expect that, so the expansion skips the prefix under a negation.
Notes

Checked with cargo test --features=_integration_test: 212 lib tests (5 new), 3 integration tests, 38 and 2 doc tests. cargo clippy --all-targets reports nothing in the changed files.

New tests:

  • selectors_vm::ast::tests: the AST shape for :not(div.foo), :not(div, span), :not(:not(div, span)), :not(:not(div):not(span)), :not(:not(div.foo)), a combinator with alternatives on both sides, and the size limit (expansion_size_limit).
  • selectors_vm::tests::vm_tests::negation_of_compound_selectors_and_selector_lists: 16 selectors through rewrite_str, including a :not() inside the compound selector of a :not(), and one element that matches two alternatives of the same selector.

Why the limit is on expressions and not on alternatives (second commit): with a limit of 256 alternatives only, eight :not(aN.bN) followed by 2,000 descendant compound selectors (a 6 KB selector) built 316 MB of AST in 540 ms, because each of the 256 alternatives has its own copy of the 2,000 nodes below it. The same selector without the :not()s takes 4 ms and 4 MB. With the limit of 4096 expressions the parser refuses it in under 1 ms, and the worst selector that it accepts costs about 2 ms and 4 MB. A selector with one alternative builds what it always has, so it has no limit: :not(.a) 1,000 times, or :not(a, b) followed by 3,000 descendants, still parse.

The nesting depth that the stack allows does not change: :not( nested 2,959 deep is the last one that works with an 8 MiB stack (release build), on e1a306d and on this branch. The parser of the selectors crate sets that limit.

In Bun (HTMLRewriter), with this branch in place of e1a306d:

":not(div.foo)"             before: main, sb, p, b      after: main, db, sf, sb, p, b
":not(:not(div, span))"     before: (nothing)           after: df, db, sf, sb
":not(:not(div):not(span))" before: (nothing)           after: df, db, sf, sb
"span:not(.foo:not(div))"   before: (nothing)           after: sb

The last shape matters for the move from 2.7.2 to 3.0.1 in Bun (oven-sh/bun#43266). 2.7.2 matched sb there, because the inner :not() was ignored. The per-level flip of 2.8.0 turns it into span && !.foo && div. A selector fuzz against a CSS reference (run by the Bun maintainers on that PR) found 108 selectors that are right on 2.7.2 and wrong on e1a306d, all of this shape. I do not have that corpus. Three selectors of the shape are in the new test, and they match what CSS says with this change.

Upstream (cloudflare/lol-html 3.0.1) has the same behaviour as e1a306d here. Its 2.8.0 changelog says nested :not() is supported "with simple selectors".

A predicate is a conjunction of simple expressions, each with a negation
flag. The AST builder negated every simple selector of a `:not()`
argument and put them all in one predicate. That is right for a selector
list, `:not(a, b)` is `!a && !b`, but not for a compound selector:
`:not(a.b)` is `!a || !b`, and it was compiled as `!a && !b`. Under a
second `:not()` the two swap, so `:not(:not(a, b))` matched what `a.b`
matches, not what `a, b` matches.

A selector now expands to alternatives, one predicate each. Every
alternative gets an AST node with the same match id, the same way every
selector of a selector list does, so a handler still runs once for an
element that matches several of them. A selector with no `:not()` that
needs OR expands to one alternative and builds the same AST as before.

The expansion multiplies (`:not(a.b):not(c.d)` has 4 alternatives), so
the parser refuses a selector that expands to more than 256 with
`SelectorError::UnsupportedSyntax`. The parser counts with the same
traversal that builds the alternatives.

`:not(|p)` keeps matching what `:not(p)` matches, which the css3-modsel
120 and 123 fixtures expect.
@robobun
robobun force-pushed the robobun/31756292/not-de-morgan branch from a067aee to 81c8bc1 Compare September 18, 2026 16:41
robobun added a commit to oven-sh/bun that referenced this pull request Sep 18, 2026
The v3.0.1 base negates every simple selector of a :not() argument into
one conjunction. `:not(a.b)` matches what `:not(a):not(b)` matches,
`:not(:not(a, b))` matches nothing, and a :not() inside the compound
selector of a :not() (`span:not(.a:not(div))`) matches less than it did
on v2.7.2. The fork commit expands a selector to one predicate per
alternative, so all of these match what CSS says.
…rnatives

Every alternative of a compound selector repeats the rest of the
selector below it in the AST. With only the 256 alternatives limit,
eight `:not(a.b)` followed by 2,000 descendant compound selectors (a
6 KB selector) built 316 MB of AST in 540 ms. The same selector without
the `:not()`s takes 4 ms and 4 MB.

The parser now counts the expressions that all the alternatives hold
together, with the same traversal that builds them, and refuses a
selector with more than one alternative and more than 4096 expressions.
The worst selector it accepts costs about 2 ms and 4 MB. A selector
with one alternative builds what it always has, so it has no limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant