Skip to content

Commit e8a95f0

Browse files
authored
Merge pull request #75 from jaraco/copilot/implement-selectors-level-4
Implement Selectors Level 4: support complex selectors inside :has(), :is(), :where(), and selector-accepting pseudo-elements
2 parents 20bc2dc + 2e7a0c4 commit e8a95f0

3 files changed

Lines changed: 79 additions & 5 deletions

File tree

cssutils/css/selector.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ class Constants:
4646

4747
combinator = ' combinator'
4848

49+
# Selectors Level 4 pseudo-classes that accept a full selector list
50+
# as their argument (rather than a simple expression like an+b).
51+
selector_pseudos = frozenset([':has(', ':is(', ':where(', ':matches(', ':any('])
52+
53+
# CSS4 pseudo-elements whose argument is a selector (not an expression).
54+
# These push a 'pseudo-element' context and accept simple_selector_sequence.
55+
selector_pseudo_elements = frozenset(['::slotted(', '::cue(', '::cue-region('])
56+
4957

5058
@dataclasses.dataclass
5159
class New(cssutils.util._BaseClass):
@@ -154,7 +162,12 @@ def _COMMENT(self, expected, seq, token, tokenizer=None):
154162
def _S(self, expected, seq, token, tokenizer=None):
155163
# S
156164
context = self.context[-1]
157-
if context.startswith('pseudo-'):
165+
if context == 'pseudo-class-has' and 'combinator' in expected:
166+
# space is a descendant combinator inside :has(), :is(), etc.
167+
self.append(seq, Constants.S, 'descendant', token=token)
168+
return Constants.simple_selector_sequence + Constants.combinator
169+
170+
elif context.startswith('pseudo-'):
158171
if seq and seq[-1].value not in '+-':
159172
# e.g. x:func(a + b)
160173
self.append(seq, Constants.S, 'S', token=token)
@@ -226,8 +239,19 @@ def _pseudo(self, expected, seq, token, tokenizer=None):
226239
if val.endswith('('):
227240
# function
228241
# "pseudo-" "class" or "element"
229-
self.context.append(typ)
230-
return Constants.expressionstart
242+
if val.lower() in Constants.selector_pseudos:
243+
ctx = 'pseudo-class-has'
244+
elif val.lower() in Constants.selector_pseudo_elements:
245+
# CSS4 pseudo-elements accepting a full selector argument
246+
# (e.g. ::slotted(), ::cue()).
247+
ctx = 'pseudo-element'
248+
else:
249+
self.context.append(typ)
250+
return Constants.expressionstart
251+
# Selectors Level 4: both pseudo-class and pseudo-element
252+
# forms that accept a full selector list as argument
253+
self.context.append(ctx)
254+
return Constants.simple_selector_sequence
231255
elif 'negation' == context:
232256
return Constants.negationend
233257
elif 'pseudo-element' == typ:
@@ -312,6 +336,11 @@ def _ident(self, expected, seq, token, tokenizer=None):
312336
self.append(seq, val, 'negation-type-selector', token=token)
313337
return Constants.negationend
314338

339+
# context: pseudo-class-has (selector-accepting pseudo like :has())
340+
elif context == 'pseudo-class-has':
341+
self.append(seq, val, 'type-selector', token=token)
342+
return Constants.simple_selector_sequence2 + Constants.combinator
343+
315344
# context: pseudo
316345
elif context.startswith('pseudo-'):
317346
# :func(...)
@@ -391,6 +420,18 @@ def _char(self, expected, seq, token, tokenizer=None): # noqa: C901
391420
context = self.context[-1]
392421
return Constants.simple_selector_sequence + Constants.combinator
393422

423+
# context: pseudo-class-has (:has(), :is(), :where(), etc.)
424+
if ')' == val and context == 'pseudo-class-has':
425+
# :has(selector) end
426+
self.append(seq, val, 'function-end', token=token)
427+
self.context.pop() # pseudo-class-has is done
428+
context = self.context[-1]
429+
if 'pseudo-element' == context:
430+
# inside ::slotted(:is(...)) — outer pseudo-element still open
431+
return Constants.expression
432+
else:
433+
return Constants.simple_selector_sequence + Constants.combinator
434+
394435
# context: pseudo (at least one expression)
395436
if val in '+-' and context.startswith('pseudo-'):
396437
# :func(+ -)"
@@ -404,11 +445,16 @@ def _char(self, expected, seq, token, tokenizer=None): # noqa: C901
404445
if (
405446
')' == val
406447
and context.startswith('pseudo-')
407-
and Constants.expression == expected
448+
and (
449+
Constants.expression == expected
450+
or (context == 'pseudo-element' and 'combinator' in expected)
451+
)
408452
):
409-
# :func(expression)"
453+
# :func(expression) or ::slotted(selector) end
410454
self.append(seq, val, 'function-end', token=token)
411455
self.context.pop() # pseudo is done
456+
# context still holds the pre-pop value: check what *type* of pseudo
457+
# we just closed to decide what may follow.
412458
if 'pseudo-element' == context:
413459
return Constants.combinator
414460
else:

newsfragments/66.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added support for Selectors Level 4 pseudo-classes that accept full selector lists as arguments (:has(), :is(), :where(), :matches(), :any()).

tests/test_selector.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,33 @@ def test_selectorText(self):
298298
':not([a])': None,
299299
':not(:first-letter)': None,
300300
':not(::first-letter)': None,
301+
# Selectors Level 4: :has() with complex selectors
302+
'fieldset:has(option)': None,
303+
'fieldset:has(option:checked)': None,
304+
'fieldset:has(option[value="foo"]:checked)': None,
305+
'fieldset:has(option[value="foo"])': None,
306+
':has(.bar)': None,
307+
':has(#id)': None,
308+
':has(div span)': None,
309+
# Selectors Level 4: :is() and :where()
310+
':is(div)': None,
311+
':is(.foo)': None,
312+
':is(div.foo:hover)': None,
313+
':where(div)': None,
314+
':where(.foo:hover)': None,
315+
# pseudo-element selector functions (CSS4: ::slotted, ::cue, etc.)
316+
'x::slotted(div)': None,
317+
'x::slotted(.foo)': None,
318+
'x::slotted(.foo:hover)': None,
319+
# ::slotted with nested selector-accepting pseudo (covers
320+
# pseudo-class-has closing when outer context is pseudo-element)
321+
'x::slotted(:is(div))': None,
322+
'x::slotted(:is(.foo))': None,
323+
':is(::slotted(.foo))': None,
324+
# nested selector-accepting pseudo-elements
325+
'x::slotted(::slotted(.foo))': None,
326+
# pseudo-element function with expression argument
327+
'x::func(a)': None,
301328
# escapes
302329
r'\74\72 td': 'trtd',
303330
r'\74\72 td': 'tr td',

0 commit comments

Comments
 (0)