Skip to content

Commit 5dce1fb

Browse files
committed
Fix specificity of Selectors Level 4 functional pseudo-classes
The functional selector-list pseudo-classes :is(), :has(), :matches() and :any() were each counted as a single class and their arguments were not counted at all, so their specificity was always (0,0,1,0). Per the CSS Selectors specification they contribute the specificity of their argument instead, and :where() always contributes zero. Track the open selector-list pseudo-classes on a stack and, while inside one whose arguments count, fold their tokens into the specificity as usual; skip them inside any enclosing :where(). The function token itself no longer counts as a class.
1 parent 2f400e3 commit 5dce1fb

3 files changed

Lines changed: 54 additions & 2 deletions

File tree

cssutils/css/selector.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ class New(cssutils.util._BaseClass):
6969
_PREFIX: str | None = None
7070
specificity: list[int] = dataclasses.field(default_factory=lambda: [0] * 4)
7171
"mutable, finally a tuple!"
72+
pseudo_specificity: list[bool] = dataclasses.field(default_factory=list)
73+
"stack of flags: whether the arguments of the currently open selector-list "
74+
"pseudo-class (:is(), :has(), … but not :where()) contribute to specificity"
7275
wellformed: bool = True
7376

7477
def append(self, seq, val, typ=None, token=None): # noqa: C901
@@ -137,10 +140,32 @@ def append(self, seq, val, typ=None, token=None): # noqa: C901
137140
val = (namespaceURI, val)
138141

139142
# specificity
140-
if not context or context == 'negation':
143+
# Count in the current sequence, in :not(), and inside those
144+
# selector-list pseudo-classes whose arguments contribute to
145+
# specificity (:is(), :has(), :matches(), :any() -- but not :where(),
146+
# which always contributes zero per the Selectors spec).
147+
count = (
148+
not context
149+
or context == 'negation'
150+
or (
151+
context == 'pseudo-class-has'
152+
and self.pseudo_specificity
153+
and all(self.pseudo_specificity)
154+
)
155+
)
156+
if count:
141157
if 'id' == typ:
142158
self.specificity[1] += 1
143-
elif 'class' == typ or '[' == val or 'pseudo-class' == typ:
159+
elif (
160+
'class' == typ
161+
or '[' == val
162+
or (
163+
'pseudo-class' == typ
164+
and val.lower() not in Constants.selector_pseudos
165+
)
166+
):
167+
# A functional selector-list pseudo-class (:is(), :where(), …)
168+
# contributes only through its argument, not as a class itself.
144169
self.specificity[2] += 1
145170
elif typ in (
146171
'type-selector',
@@ -241,6 +266,10 @@ def _pseudo(self, expected, seq, token, tokenizer=None):
241266
# "pseudo-" "class" or "element"
242267
if val.lower() in Constants.selector_pseudos:
243268
ctx = 'pseudo-class-has'
269+
# :where() contributes zero specificity; the others
270+
# (:is(), :has(), :matches(), :any()) take the
271+
# specificity of their argument.
272+
self.pseudo_specificity.append(val.lower() != ':where(')
244273
elif val.lower() in Constants.selector_pseudo_elements:
245274
# CSS4 pseudo-elements accepting a full selector argument
246275
# (e.g. ::slotted(), ::cue()).
@@ -425,6 +454,7 @@ def _char(self, expected, seq, token, tokenizer=None): # noqa: C901
425454
# :has(selector) end
426455
self.append(seq, val, 'function-end', token=token)
427456
self.context.pop() # pseudo-class-has is done
457+
self.pseudo_specificity.pop()
428458
context = self.context[-1]
429459
if 'pseudo-element' == context:
430460
# inside ::slotted(:is(...)) — outer pseudo-element still open
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Corrected specificity of the Selectors Level 4 functional pseudo-classes. ``:is()``, ``:has()``, ``:matches()`` and ``:any()`` now contribute the specificity of their argument, and ``:where()`` correctly contributes zero, per the CSS Selectors spec.

tests/test_selector.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,27 @@ def test_specificity(self):
447447
'tr:last-child': (0, 0, 1, 1),
448448
':nth-child(2)': (0, 0, 1, 0),
449449
'.table > :last-child > tr:last-child > *': (0, 0, 3, 1),
450+
# Selectors Level 4 functional pseudo-classes: :is()/:has()/
451+
# :matches()/:any() take the specificity of their argument, while
452+
# :where() always contributes zero. The pseudo-class function
453+
# itself does not count as a class.
454+
':where(#a)': (0, 0, 0, 0),
455+
':where(.foo:hover)': (0, 0, 0, 0),
456+
':where(a b c)': (0, 0, 0, 0),
457+
'div:where(#a)': (0, 0, 0, 1),
458+
':is(#a)': (0, 1, 0, 0),
459+
':is(.x .y #z)': (0, 1, 2, 0),
460+
':is(div.foo:hover)': (0, 0, 2, 1),
461+
':matches(.a)': (0, 0, 1, 0),
462+
':any(#b)': (0, 1, 0, 0),
463+
':has(option:checked)': (0, 0, 1, 1),
464+
'div:is(.a):where(#b)': (0, 0, 1, 1),
465+
# Nested functional pseudo-classes: :where() zeroes everything it
466+
# contains, even a nested :is(), while :is() takes the (zero)
467+
# specificity of a :where() argument.
468+
':is(:where(#a))': (0, 0, 0, 0),
469+
':where(:is(#a))': (0, 0, 0, 0),
470+
':is(:is(#a))': (0, 1, 0, 0),
450471
# classes and attributes
451472
'.a': (0, 0, 1, 0),
452473
'*.a': (0, 0, 1, 0),

0 commit comments

Comments
 (0)