diff --git a/cssutils/css/selector.py b/cssutils/css/selector.py index d1d508bb..526f2ff6 100644 --- a/cssutils/css/selector.py +++ b/cssutils/css/selector.py @@ -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 @@ -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', @@ -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()). @@ -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 diff --git a/newsfragments/79.bugfix.rst b/newsfragments/79.bugfix.rst new file mode 100644 index 00000000..e7c7d410 --- /dev/null +++ b/newsfragments/79.bugfix.rst @@ -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. diff --git a/tests/test_selector.py b/tests/test_selector.py index da1f40fc..52d91c03 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -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),