Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions cssutils/css/selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ class New(cssutils.util._BaseClass):
_PREFIX: str | None = None
specificity: list[int] = dataclasses.field(default_factory=lambda: [0] * 4)
"mutable, finally a tuple!"
pseudo_specificity: list[bool] = dataclasses.field(default_factory=list)
"stack of flags: whether the arguments of the currently open selector-list "
"pseudo-class (:is(), :has(), … but not :where()) contribute to specificity"
wellformed: bool = True

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

# specificity
if not context or context == 'negation':
# Count in the current sequence, in :not(), and inside those
# selector-list pseudo-classes whose arguments contribute to
# specificity (:is(), :has(), :matches(), :any() -- but not :where(),
# which always contributes zero per the Selectors spec).
count = (
not context
or context == 'negation'
or (
context == 'pseudo-class-has'
and self.pseudo_specificity
and all(self.pseudo_specificity)
)
)
if count:
if 'id' == typ:
self.specificity[1] += 1
elif 'class' == typ or '[' == val or 'pseudo-class' == typ:
elif (
'class' == typ
or '[' == val
or (
'pseudo-class' == typ
and val.lower() not in Constants.selector_pseudos
)
):
# A functional selector-list pseudo-class (:is(), :where(), …)
# contributes only through its argument, not as a class itself.
self.specificity[2] += 1
elif typ in (
'type-selector',
Expand Down Expand Up @@ -241,6 +266,10 @@ def _pseudo(self, expected, seq, token, tokenizer=None):
# "pseudo-" "class" or "element"
if val.lower() in Constants.selector_pseudos:
ctx = 'pseudo-class-has'
# :where() contributes zero specificity; the others
# (:is(), :has(), :matches(), :any()) take the
# specificity of their argument.
self.pseudo_specificity.append(val.lower() != ':where(')
elif val.lower() in Constants.selector_pseudo_elements:
# CSS4 pseudo-elements accepting a full selector argument
# (e.g. ::slotted(), ::cue()).
Expand Down Expand Up @@ -425,6 +454,7 @@ def _char(self, expected, seq, token, tokenizer=None): # noqa: C901
# :has(selector) end
self.append(seq, val, 'function-end', token=token)
self.context.pop() # pseudo-class-has is done
self.pseudo_specificity.pop()
context = self.context[-1]
if 'pseudo-element' == context:
# inside ::slotted(:is(...)) — outer pseudo-element still open
Expand Down
1 change: 1 addition & 0 deletions newsfragments/79.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
21 changes: 21 additions & 0 deletions tests/test_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,27 @@ def test_specificity(self):
'tr:last-child': (0, 0, 1, 1),
':nth-child(2)': (0, 0, 1, 0),
'.table > :last-child > tr:last-child > *': (0, 0, 3, 1),
# Selectors Level 4 functional pseudo-classes: :is()/:has()/
# :matches()/:any() take the specificity of their argument, while
# :where() always contributes zero. The pseudo-class function
# itself does not count as a class.
':where(#a)': (0, 0, 0, 0),
':where(.foo:hover)': (0, 0, 0, 0),
':where(a b c)': (0, 0, 0, 0),
'div:where(#a)': (0, 0, 0, 1),
':is(#a)': (0, 1, 0, 0),
':is(.x .y #z)': (0, 1, 2, 0),
':is(div.foo:hover)': (0, 0, 2, 1),
':matches(.a)': (0, 0, 1, 0),
':any(#b)': (0, 1, 0, 0),
':has(option:checked)': (0, 0, 1, 1),
'div:is(.a):where(#b)': (0, 0, 1, 1),
# Nested functional pseudo-classes: :where() zeroes everything it
# contains, even a nested :is(), while :is() takes the (zero)
# specificity of a :where() argument.
':is(:where(#a))': (0, 0, 0, 0),
':where(:is(#a))': (0, 0, 0, 0),
':is(:is(#a))': (0, 1, 0, 0),
# classes and attributes
'.a': (0, 0, 1, 0),
'*.a': (0, 0, 1, 0),
Expand Down