Repository navigation
Conversation
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
force-pushed
the
robobun/31756292/not-de-morgan
branch
from
September 18, 2026 16:41
a067aee to
81c8bc1
Compare
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.
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
:not()with a compound selector matches too little.:not(div.foo)must match every element that is not adiv.foo. It matches only elements that are neither adivnor.foo, the same as:not(div):not(.foo).:not()the error is the other way.:not(:not(div, span))must match whatdiv, spanmatches. It matches whatdivandspanmatch at the same time, which is nothing.Ast::add_selector(src/selectors_vm/ast.rs). APredicateis 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
Predicateeach, with De Morgan's laws.:not(a.b)has two:!aand!b.:not(:not(a, b))has two:aandb.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.: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
: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-targetsreports 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 throughrewrite_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), one1a306dand on this branch. The parser of theselectorscrate sets that limit.In Bun (
HTMLRewriter), with this branch in place ofe1a306d: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
sbthere, because the inner:not()was ignored. The per-level flip of 2.8.0 turns it intospan && !.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 one1a306d, 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
e1a306dhere. Its 2.8.0 changelog says nested:not()is supported "with simple selectors".