Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/soupsieve/css_parser.py: 74%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""CSS selector parser."""
2from __future__ import annotations
3import re
4from functools import lru_cache
5from . import util
6from . import css_match as cm
7from . import css_types as ct
8from .util import SelectorSyntaxError
9import warnings
10from typing import Match, Any, Callable, Iterator, Iterable, Mapping, cast
11from dataclasses import dataclass
12from collections import UserDict
13import threading
15__all__ = ('SelectorSyntaxError',)
17RE_LOCK = threading.Lock()
18SEL_LOCK = threading.RLock()
20UNICODE_REPLACEMENT_CHAR = 0xFFFD
22# Non-ASCII identifier code point ranges (inclusive): https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point
23NON_ASCII_IDENT_RANGES = (
24 (0x00B7, 0x00B7),
25 (0x00C0, 0x00D6),
26 (0x00D8, 0x00F6),
27 (0x00F8, 0x037D),
28 (0x037F, 0x1FFF),
29 (0x200C, 0x200D),
30 (0x203F, 0x2040),
31 (0x2070, 0x218F),
32 (0x2C00, 0x2FEF),
33 (0x3001, 0xD7FF),
34 (0xF900, 0xFDCF),
35 (0xFDF0, 0xFFFD),
36 (0x10000, 0x10FFFF)
37)
39SELECTOR_LIMIT = 8192
41# Simple pseudo classes that take no parameters
42PSEUDO_SIMPLE = {
43 ":any-link",
44 ":empty",
45 ":first-child",
46 ":first-of-type",
47 ":in-range",
48 ":open",
49 ":out-of-range",
50 ":last-child",
51 ":last-of-type",
52 ":link",
53 ":only-child",
54 ":only-of-type",
55 ":root",
56 ':checked',
57 ':default',
58 ':disabled',
59 ':enabled',
60 ':indeterminate',
61 ':optional',
62 ':placeholder-shown',
63 ':read-only',
64 ':read-write',
65 ':required',
66 ':scope',
67 ':defined',
68 ':muted'
69}
71# Supported, simple pseudo classes that match nothing in the Soup Sieve environment
72PSEUDO_SIMPLE_NO_MATCH = {
73 ':active',
74 ':autofill',
75 ':buffering',
76 ':current',
77 ':focus',
78 ':focus-visible',
79 ':focus-within',
80 ':fullscreen',
81 ':future',
82 ':host',
83 ':hover',
84 ':local-link',
85 ':past',
86 ':paused',
87 ':picture-in-picture',
88 ':playing',
89 ':popover-open',
90 ':seeking',
91 ':stalled',
92 ':target',
93 ':target-within',
94 ':user-invalid',
95 ':volume-locked',
96 ':visited'
97}
99# Complex pseudo classes that take selector lists
100PSEUDO_COMPLEX = {
101 ':contains',
102 ':-soup-contains',
103 ':-soup-contains-own',
104 ':has',
105 ':is',
106 ':matches',
107 ':not',
108 ':where'
109}
111PSEUDO_COMPLEX_NO_MATCH = {
112 ':current',
113 ':host',
114 ':host-context'
115}
117# Complex pseudo classes that take very specific parameters and are handled special
118PSEUDO_SPECIAL = {
119 ':dir',
120 ':lang',
121 ':nth-child',
122 ':nth-last-child',
123 ':nth-last-of-type',
124 ':nth-of-type'
125}
127PSEUDO_SUPPORTED = PSEUDO_SIMPLE | PSEUDO_SIMPLE_NO_MATCH | PSEUDO_COMPLEX | PSEUDO_COMPLEX_NO_MATCH | PSEUDO_SPECIAL
129# Sub-patterns parts
130# Whitespace
131NEWLINE = r'(?>\r\n|[\n\f\r])'
132WS = fr'(?:[ \t]|{NEWLINE})'
133# Comments
134COMMENTS = r'(?:/\*(?:[^*]|\*(?!/))*+\*/)'
135# Whitespace with comments included
136WSC = fr'(?:{WS}|{COMMENTS})'
137# CSS escapes
138CSS_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}+{WS}?+|[^\r\n\f]|$))'
139CSS_STRING_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}+{WS}?+|{NEWLINE}|[^\r\n\f]|$))'
140# CSS identifier code points (browsers treat all non-ASCII code points as identifier code points)
141IDENT_START = r'[A-Za-z_\x80-\U0010ffff]'
142IDENT_CHAR = r'[-0-9A-Za-z_\x80-\U0010ffff]'
143# CSS Identifier
144IDENTIFIER = fr'''
145(?:(?:--|-?+(?:{IDENT_START}|{CSS_ESCAPES}))
146(?:{IDENT_CHAR}|{CSS_ESCAPES})*+)
147'''
148# `nth` content
149NTH = fr'[-+]?+(?:[0-9]++n?+|n)(?:(?<=n){WSC}*+[-+]{WSC}*+[0-9]++)?+'
150# Value: quoted string or identifier
151VALUE = fr'''
152(?:"(?:\\(?:{NEWLINE}|[^\r\n\f])|[^\\"\r\n\f])*+"|
153'(?:\\(?:{NEWLINE}|[^\r\n\f])|[^\\'\r\n\f])*+'|
154{IDENTIFIER})
155'''
156# Attribute value comparison. `!=` is handled special as it is non-standard.
157ATTR = fr'(?:{WSC}*+(?P<cmp>[!~^|*$]?+=){WSC}*+(?P<value>{VALUE})(?:{WSC}*+(?P<case>[is]))?+)?+'
159# Selector patterns
160# IDs (`#id`)
161PAT_ID = fr'\#{IDENTIFIER}'
162# Classes (`.class`)
163PAT_CLASS = fr'\.{IDENTIFIER}'
164# Prefix:Tag (`prefix|tag`)
165PAT_TAG = fr'(?P<tag_ns>(?:{IDENTIFIER}|\*)?+\|)?+(?P<tag_name>{IDENTIFIER}|\*)'
166# Attributes (`[attr]`, `[attr=value]`, etc.)
167PAT_ATTR = fr'\[{WSC}*+(?P<attr_ns>(?:{IDENTIFIER}|\*)?+\|(?!=))?+(?P<attr_name>{IDENTIFIER}){ATTR}{WSC}*+\]'
168# Pseudo class (`:pseudo-class`, `:pseudo-class(`)
169PAT_PSEUDO_CLASS = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*+)?+'
170# Pseudo class special patterns. Matches `:pseudo-class(` for special case pseudo classes.
171PAT_PSEUDO_CLASS_SPECIAL = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*+)'
172# Custom pseudo class (`:--custom-pseudo`)
173PAT_PSEUDO_CLASS_CUSTOM = fr'(?P<name>:(?=--){IDENTIFIER})'
174# Nesting ampersand selector. Matches `&`
175PAT_AMP = r'&'
176# Closing pseudo group (`)`)
177PAT_PSEUDO_CLOSE = fr'{WSC}*+\)'
178# Pseudo element (`::pseudo-element`)
179PAT_PSEUDO_ELEMENT = fr':{PAT_PSEUDO_CLASS}'
180# At rule (`@page`, etc.) (not supported)
181PAT_AT_RULE = fr'@P{IDENTIFIER}'
182# Pseudo class `nth-child` (`:nth-child(an+b [of S]?)`, `:first-child`, etc.)
183PAT_PSEUDO_NTH_CHILD = fr'''
184(?P<pseudo_nth_child>{PAT_PSEUDO_CLASS_SPECIAL}
185(?P<nth_child>{NTH}|even|odd))(?:{WSC}*+\)|(?P<of>{COMMENTS}*+{WS}{WSC}*+of{COMMENTS}*+{WS}{WSC}*+))
186'''
187# Pseudo class `nth-of-type` (`:nth-of-type(an+b)`, `:first-of-type`, etc.)
188PAT_PSEUDO_NTH_TYPE = fr'''
189(?P<pseudo_nth_type>{PAT_PSEUDO_CLASS_SPECIAL}
190(?P<nth_type>{NTH}|even|odd)){WSC}*+\)
191'''
192# Pseudo class language (`:lang("*-de", en)`)
193PAT_PSEUDO_LANG = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<values>{VALUE}(?:{WSC}*+,{WSC}*+{VALUE})*+){WSC}*+\)'
194# Pseudo class direction (`:dir(ltr)`)
195PAT_PSEUDO_DIR = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<dir>ltr|rtl){WSC}*+\)'
196# Combining characters (`>`, `~`, ` `, `+`, `,`)
197PAT_COMBINE = fr'{COMMENTS}*+(?={WS}|[,+>~]){WSC}*+(?P<relation>[,+>~])?+{WSC}*+'
198# Extra: Contains (`:contains(text)`)
199PAT_PSEUDO_CONTAINS = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<values>{VALUE}(?:{WSC}*+,{WSC}*+{VALUE})*+){WSC}*+\)'
201# Regular expressions
202# CSS escape pattern
203RE_CSS_ESC = re.compile(fr'(?:(\\[a-f0-9]{{1,6}}+{WSC}?+)|(\\[^\r\n\f])|(\\$))', re.I)
204RE_CSS_STR_ESC = re.compile(fr'(?:(\\[a-f0-9]{{1,6}}+{WS}?+)|(\\[^\r\n\f])|(\\$)|(\\{NEWLINE}))', re.I)
205# Pattern to break up `nth` specifiers
206RE_NTH = re.compile(fr'(?P<s1>[-+])?+(?P<a>[0-9]++n?+|n)(?:(?<=n){WSC}*+(?P<s2>[-+]){WSC}*+(?P<b>[0-9]++))?+', re.I)
207# Pattern to iterate multiple values.
208RE_VALUES = re.compile(fr'(?:(?P<value>{VALUE})|(?P<split>{WSC}*+,{WSC}*+))', re.X)
209# Whitespace checks
210RE_WS = re.compile(WS)
211RE_WS_BEGIN = re.compile(fr'^{WSC}*+')
212RE_WS_END = re.compile(fr'^(?:[ \t]|(?>\n\r|[\n\f\r])|{COMMENTS})*+')
213RE_CUSTOM = re.compile(fr'^{PAT_PSEUDO_CLASS_CUSTOM}$', re.X)
214RE_PSEUDO_CLASS_SPECIAL = re.compile(PAT_PSEUDO_CLASS_SPECIAL, re.I | re.X | re.U)
215RE_PSEUDO_IGNORE = re.compile(PAT_PSEUDO_CLASS, re.X | re.I | re.U)
217QUOTED = ("'", '"')
219# Constants
220# List split token
221COMMA_COMBINATOR = ','
222# Relation token for descendant
223WS_COMBINATOR = " "
225# Parse flags
226FLG_PSEUDO = 0x01
227FLG_NOT = 0x02
228FLG_RELATIVE = 0x04
229FLG_DEFAULT = 0x08
230FLG_HTML = 0x10
231FLG_INDETERMINATE = 0x20
232FLG_OPEN = 0x40
233FLG_IN_RANGE = 0x80
234FLG_OUT_OF_RANGE = 0x100
235FLG_PLACEHOLDER_SHOWN = 0x200
236FLG_FORGIVE = 0x400
238# Maximum cached patterns to store
239_MAXCACHE = 500
242@lru_cache(maxsize=_MAXCACHE)
243def _cached_css_compile(
244 pattern: str,
245 namespaces: ct.Namespaces | None,
246 custom: ct.CustomSelectors | None,
247 ignore: tuple[str] | None = None,
248 flags: int = 0,
249 max_selectors: int = SELECTOR_LIMIT
250) -> cm.SoupSieve:
251 """Cached CSS compile."""
253 return cm.SoupSieve(
254 pattern,
255 CSSParser(
256 pattern,
257 custom=custom,
258 ignore=ignore,
259 flags=flags,
260 max_selectors=max_selectors
261 ).process_selectors(),
262 namespaces,
263 custom,
264 flags
265 )
268def _purge_cache() -> None:
269 """Purge the cache."""
271 _cached_css_compile.cache_clear()
274def css_unescape(content: str) -> str:
275 """
276 Unescape CSS value.
278 Strings allow for spanning the value on multiple strings by escaping a new line.
279 """
281 def replace(m: Match[str]) -> str:
282 """Replace with the appropriate substitute."""
284 if m.group(1):
285 codepoint = int(m.group(1)[1:], 16)
286 # A surrogate, or a value past Unicode, is `U+FFFD`. Zero already is.
287 if (
288 codepoint == 0
289 or codepoint > 0x10FFFF
290 or 0xD800 <= codepoint <= 0xDFFF
291 ):
292 codepoint = UNICODE_REPLACEMENT_CHAR
293 value = chr(codepoint)
294 elif m.group(2):
295 value = m.group(2)[1:]
296 elif m.group(3):
297 value = '\ufffd'
298 else:
299 value = ''
301 return value
303 quoted = content.startswith(QUOTED)
304 return (RE_CSS_STR_ESC if quoted else RE_CSS_ESC).sub(
305 replace,
306 content[1:-1] if quoted else content
307 )
310def escape(ident: str) -> str:
311 """Escape identifier."""
313 string = []
314 length = len(ident)
315 start_dash = length > 0 and ident[0] == '-'
316 if length == 1 and start_dash:
317 # Need to escape identifier that is a single `-` with no other characters
318 string.append(f'\\{ident}')
319 else:
320 for index, c in enumerate(ident):
321 codepoint = ord(c)
322 if codepoint == 0x00:
323 string.append('\ufffd')
324 elif (0x01 <= codepoint <= 0x1F) or codepoint == 0x7F:
325 string.append(f'\\{codepoint:x} ')
326 elif (index == 0 or (start_dash and index == 1)) and (0x30 <= codepoint <= 0x39):
327 string.append(f'\\{codepoint:x} ')
328 elif (
329 codepoint in (0x2D, 0x5F) or (0x30 <= codepoint <= 0x39) or
330 (0x41 <= codepoint <= 0x5A) or (0x61 <= codepoint <= 0x7A) or
331 (codepoint >= 0x80 and any(start <= codepoint <= end for start, end in NON_ASCII_IDENT_RANGES))
332 ):
333 string.append(c)
334 else:
335 string.append(f'\\{c}')
336 return ''.join(string)
339class SelectorPattern:
340 """Selector pattern."""
342 def __init__(self, name: str, pattern: str) -> None:
343 """Initialize."""
345 self.name = name
346 self.pattern = pattern
347 self._re_pattern: re.Pattern[str] | None = None
349 @property
350 def re_pattern(self) -> re.Pattern[str]:
351 """Retrieve the compiled regular expression pattern."""
353 with RE_LOCK:
354 if self._re_pattern is None:
355 self._re_pattern = re.compile(self.pattern, re.I | re.X | re.U)
356 return self._re_pattern
358 def get_name(self) -> str:
359 """Get name."""
361 return self.name
363 def match(self, selector: str, index: int, flags: int) -> Match[str] | None:
364 """Match the selector."""
366 return self.re_pattern.match(selector, index)
369class SpecialPseudoPattern(SelectorPattern):
370 """Selector pattern."""
372 def __init__(self, patterns: tuple[tuple[str, tuple[str, ...], str, type[SelectorPattern]], ...]) -> None:
373 """Initialize."""
375 self.patterns = {}
376 for p in patterns:
377 name = p[0]
378 pattern = p[3](name, p[2])
379 for pseudo in p[1]:
380 self.patterns[pseudo] = pattern
382 self.matched_name: SelectorPattern | None = None
384 def get_name(self) -> str:
385 """Get name."""
387 return '' if self.matched_name is None else self.matched_name.get_name()
389 def match(self, selector: str, index: int, flags: int) -> Match[str] | None:
390 """Match the selector."""
392 pseudo = None
393 m = RE_PSEUDO_CLASS_SPECIAL.match(selector, index)
394 if m:
395 name = util.lower(css_unescape(m.group('name')))
396 pattern = self.patterns.get(name)
397 if pattern:
398 pseudo = pattern.match(selector, index, flags)
399 if pseudo:
400 self.matched_name = pattern
402 return pseudo
405class _Selector:
406 """
407 Intermediate selector class.
409 This stores selector data for a compound selector as we are acquiring them.
410 Once we are done collecting the data for a compound selector, we freeze
411 the data in an object that can be pickled and hashed.
412 """
414 def __init__(self, **kwargs: Any) -> None:
415 """Initialize."""
417 self.tag: ct.SelectorTag | None = kwargs.get('tag', None)
418 self.ids: list[str] = kwargs.get('ids', [])
419 self.classes: list[str] = kwargs.get('classes', [])
420 self.attributes: list[ct.SelectorAttribute] = kwargs.get('attributes', [])
421 self.nth: list[ct.SelectorNth] = kwargs.get('nth', [])
422 self.selectors: list[ct.SelectorList] = kwargs.get('selectors', [])
423 self.relations: list[_Selector] = kwargs.get('relations', [])
424 self.rel_type: str | None = kwargs.get('rel_type', None)
425 self.contains: list[ct.SelectorContains] = kwargs.get('contains', [])
426 self.lang: list[ct.SelectorLang] = kwargs.get('lang', [])
427 self.flags: int = kwargs.get('flags', 0)
428 self.no_match: bool = kwargs.get('no_match', False)
430 def _freeze(self, relation: ct.SelectorList) -> ct.Selector | ct.SelectorNull:
431 """Freeze self with an already frozen relation."""
433 if self.no_match:
434 return ct.Null
435 else:
436 return ct.Selector(
437 self.tag,
438 tuple(self.ids),
439 tuple(self.classes),
440 tuple(self.attributes),
441 tuple(self.nth),
442 tuple(self.selectors),
443 relation,
444 self.rel_type,
445 tuple(self.contains),
446 tuple(self.lang),
447 self.flags
448 )
450 def freeze(self) -> ct.Selector | ct.SelectorNull:
451 """
452 Freeze self.
454 Relations form a chain where each selector's first relation is the next link and any
455 remaining relations are carried down to follow it. As the far end of the chain must be
456 frozen first, flatten the chain and then freeze it in reverse to avoid recursion.
457 """
459 chain: list[_Selector] = []
460 pending = [self]
461 while pending:
462 sel = pending.pop()
463 chain.append(sel)
464 if sel.no_match:
465 break
466 pending.extend(reversed(sel.relations))
468 relation = ct.SelectorList()
469 for sel in reversed(chain):
470 frozen = sel._freeze(relation)
471 relation = ct.SelectorList([frozen])
472 return frozen
474 def __str__(self) -> str: # pragma: no cover
475 """String representation."""
477 return (
478 f'_Selector(tag={self.tag!r}, ids={self.ids!r}, classes={self.classes!r}, attributes={self.attributes!r}, '
479 f'nth={self.nth!r}, selectors={self.selectors!r}, relations={self.relations!r}, '
480 f'rel_type={self.rel_type!r}, contains={self.contains!r}, lang={self.lang!r}, flags={self.flags!r}, '
481 f'no_match={self.no_match!r})'
482 )
484 __repr__ = __str__
487class _SelectorContext:
488 """
489 Parsing state for a selector list.
491 Context are created for each nesting level to avoid recursion.
492 """
494 def __init__(
495 self,
496 flags: int,
497 index: int,
498 count: int,
499 on_close: Callable[[ct.SelectorList], None] | None = None
500 ) -> None:
501 """Initialize."""
503 self.sel = _Selector()
504 self.selectors: list[_Selector] = []
505 self.relations: list[_Selector] = []
506 self.has_selector = False
507 self.rel_type = ":" + WS_COMBINATOR
508 self.index = index
509 self.count = count
510 # Receives the frozen selector list when an open pseudo-class is closed.
511 self.on_close = on_close
513 # Setup various flags
514 self.is_open = bool(flags & FLG_OPEN)
515 self.is_pseudo = bool(flags & FLG_PSEUDO)
516 self.is_relative = bool(flags & FLG_RELATIVE)
517 self.is_not = bool(flags & FLG_NOT)
518 self.is_html = bool(flags & FLG_HTML)
519 self.is_default = bool(flags & FLG_DEFAULT)
520 self.is_indeterminate = bool(flags & FLG_INDETERMINATE)
521 self.is_in_range = bool(flags & FLG_IN_RANGE)
522 self.is_out_of_range = bool(flags & FLG_OUT_OF_RANGE)
523 self.is_placeholder_shown = bool(flags & FLG_PLACEHOLDER_SHOWN)
524 self.is_forgive = bool(flags & FLG_FORGIVE)
526 # The algorithm for relative selectors require an initial selector in the selector list
527 if self.is_relative:
528 self.selectors.append(_Selector())
530 def debug(self) -> None: # pragma: no cover
531 """Print out useful debug stuff."""
533 for name in (
534 'is_pseudo', 'is_open', 'is_relative', 'is_not', 'is_html', 'is_default', 'is_indeterminate',
535 'is_in_range', 'is_out_of_range', 'is_placeholder_shown', 'is_forgive'
536 ):
537 if getattr(self, name):
538 print(f' {name}: True')
541@dataclass
542class CSSPattern:
543 """A CSS pattern that hasn't been processed by `CSSParser` yet."""
545 selector: str
546 flags: int
549class PseudoSelectorMap(UserDict[str, CSSPattern | ct.SelectorList]):
550 """Pseudo selector map."""
552 def __setitem__(self, key: str, value: CSSPattern | ct.SelectorList) -> None:
553 """Set item."""
555 self.data[key] = value
557 def __getitem__(self, key: str) -> ct.SelectorList:
558 """Get item."""
560 with SEL_LOCK:
561 value = self.data[key]
562 if isinstance(value, CSSPattern):
563 value = CSSParser(value.selector).process_selectors(flags=value.flags)
564 self.data[key] = value
566 return value
569# CSS pattern for `:link` and `:any-link`
570CSS_LINK = CSSPattern('html|*:is(a, area)[href]', FLG_PSEUDO | FLG_HTML)
571# CSS pattern for `:checked`
572CSS_CHECKED = CSSPattern(
573 '''
574 html|*:is(input[type=checkbox], input[type=radio])[checked], html|option[selected]
575 ''',
576 FLG_PSEUDO | FLG_HTML
577)
578# CSS pattern for `:default` (must compile CSS_CHECKED first)
579CSS_DEFAULT = CSSPattern(
580 '''
581 :checked,
583 /*
584 This pattern must be at the end.
585 Special logic is applied to the last selector.
586 */
587 html|form html|*:is(button, input)[type="submit"]
588 ''',
589 FLG_PSEUDO | FLG_HTML | FLG_DEFAULT
590)
591# CSS pattern for `:indeterminate`
592CSS_INDETERMINATE = CSSPattern(
593 '''
594 html|input[type="checkbox"][indeterminate],
595 html|input[type="radio"]:is(:not([name]), [name=""]):not([checked]),
596 html|progress:not([value]),
598 /*
599 This pattern must be at the end.
600 Special logic is applied to the last selector.
601 */
602 html|input[type="radio"][name]:not([name='']):not([checked])
603 ''',
604 FLG_PSEUDO | FLG_HTML | FLG_INDETERMINATE
605)
606# CSS pattern for `:disabled`
607CSS_DISABLED = CSSPattern(
608 '''
609 html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, optgroup, option, fieldset)[disabled],
610 html|optgroup[disabled] > html|option,
611 html|fieldset[disabled] > html|*:is(input:not([type=hidden]), button, select, textarea, fieldset),
612 html|fieldset[disabled] >
613 html|*:not(legend:nth-of-type(1)) html|*:is(input:not([type=hidden]), button, select, textarea, fieldset)
614 ''',
615 FLG_PSEUDO | FLG_HTML
616)
617# CSS pattern for `:enabled`
618CSS_ENABLED = CSSPattern(
619 '''
620 html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, optgroup, option, fieldset):not(:disabled)
621 ''',
622 FLG_PSEUDO | FLG_HTML
623)
624# CSS pattern for `:required`
625CSS_REQUIRED = CSSPattern('html|*:is(input, textarea, select)[required]', FLG_PSEUDO | FLG_HTML)
626# CSS pattern for `:optional`
627CSS_OPTIONAL = CSSPattern('html|*:is(input, textarea, select):not([required])', FLG_PSEUDO | FLG_HTML)
628# CSS pattern for `:placeholder-shown`
629CSS_PLACEHOLDER_SHOWN = CSSPattern(
630 '''
631 html|input:is(
632 :not([type]),
633 [type=""],
634 [type=text],
635 [type=search],
636 [type=url],
637 [type=tel],
638 [type=email],
639 [type=password],
640 [type=number]
641 )[placeholder]:not([placeholder='']):is(:not([value]), [value=""]),
642 html|textarea[placeholder]:not([placeholder=''])
643 ''',
644 FLG_PSEUDO | FLG_HTML | FLG_PLACEHOLDER_SHOWN
645)
646# CSS pattern for `:read-write` (CSS_DISABLED must be compiled first)
647CSS_READ_WRITE = CSSPattern(
648 '''
649 html|*:is(
650 textarea,
651 input:is(
652 :not([type]),
653 [type=""],
654 [type=text],
655 [type=search],
656 [type=url],
657 [type=tel],
658 [type=email],
659 [type=number],
660 [type=password],
661 [type=date],
662 [type=datetime-local],
663 [type=month],
664 [type=time],
665 [type=week]
666 )
667 ):not([readonly], :disabled),
668 html|*:is([contenteditable=""], [contenteditable="true" i])
669 ''',
670 FLG_PSEUDO | FLG_HTML
671)
672# CSS pattern for `:read-only`
673CSS_READ_ONLY = CSSPattern('html|*:not(:read-write)', FLG_PSEUDO | FLG_HTML)
674# CSS pattern for `:in-range`
675CSS_IN_RANGE = CSSPattern(
676 '''
677 html|input:is(
678 [type="date"],
679 [type="month"],
680 [type="week"],
681 [type="time"],
682 [type="datetime-local"],
683 [type="number"],
684 [type="range"]
685 ):is(
686 [min],
687 [max]
688 )
689 ''',
690 FLG_PSEUDO | FLG_HTML | FLG_IN_RANGE
691)
692# CSS pattern for `:out-of-range`
693CSS_OUT_OF_RANGE = CSSPattern(
694 '''
695 html|input:is(
696 [type="date"],
697 [type="month"],
698 [type="week"],
699 [type="time"],
700 [type="datetime-local"],
701 [type="number"],
702 [type="range"]
703 ):is(
704 [min],
705 [max]
706 )
707 ''',
708 FLG_PSEUDO | FLG_HTML | FLG_OUT_OF_RANGE
709)
710# CSS pattern for :open
711CSS_OPEN = CSSPattern('html|*:is(details, dialog)[open]', FLG_PSEUDO | FLG_HTML)
712# CSS pattern for :muted
713CSS_MUTED = CSSPattern('html|*:is(video, audio)[muted]', FLG_PSEUDO | FLG_HTML)
714# CSS pattern default for `:nth-child` "of S" feature
715CSS_NTH_OF_S_DEFAULT = CSSPattern("*|*", FLG_PSEUDO)
718class CSSParser:
719 """Parse CSS selectors."""
721 CSS_TOKENS = (
722 SelectorPattern("pseudo_close", PAT_PSEUDO_CLOSE),
723 SpecialPseudoPattern(
724 (
725 (
726 "pseudo_contains",
727 (':contains', ':-soup-contains', ':-soup-contains-own'),
728 PAT_PSEUDO_CONTAINS,
729 SelectorPattern
730 ),
731 ("pseudo_nth_child", (':nth-child', ':nth-last-child'), PAT_PSEUDO_NTH_CHILD, SelectorPattern),
732 ("pseudo_nth_type", (':nth-of-type', ':nth-last-of-type'), PAT_PSEUDO_NTH_TYPE, SelectorPattern),
733 ("pseudo_lang", (':lang',), PAT_PSEUDO_LANG, SelectorPattern),
734 ("pseudo_dir", (':dir',), PAT_PSEUDO_DIR, SelectorPattern)
735 )
736 ),
737 SelectorPattern("pseudo_class_custom", PAT_PSEUDO_CLASS_CUSTOM),
738 SelectorPattern("pseudo_class", PAT_PSEUDO_CLASS),
739 SelectorPattern("pseudo_element", PAT_PSEUDO_ELEMENT),
740 SelectorPattern("amp", PAT_AMP),
741 SelectorPattern("at_rule", PAT_AT_RULE),
742 SelectorPattern("id", PAT_ID),
743 SelectorPattern("class", PAT_CLASS),
744 SelectorPattern("tag", PAT_TAG),
745 SelectorPattern("attribute", PAT_ATTR),
746 SelectorPattern("combine", PAT_COMBINE)
747 )
749 # Pseudos that expand to selectors
750 PSEUDO_SELECTORS = PseudoSelectorMap(
751 {
752 ':link': CSS_LINK,
753 ':any-link': CSS_LINK,
754 ':checked': CSS_CHECKED,
755 ':default': CSS_DEFAULT,
756 ':indeterminate': CSS_INDETERMINATE,
757 ':disabled': CSS_DISABLED,
758 ':enabled': CSS_ENABLED,
759 ':required': CSS_REQUIRED,
760 ':muted': CSS_MUTED,
761 ':open': CSS_OPEN,
762 ':optional': CSS_OPTIONAL,
763 ':read-only': CSS_READ_ONLY,
764 ':read-write': CSS_READ_WRITE,
765 ':in-range': CSS_IN_RANGE,
766 ':out-of-range': CSS_OUT_OF_RANGE,
767 ':placeholder-shown': CSS_PLACEHOLDER_SHOWN,
768 '<nth-of-s>': CSS_NTH_OF_S_DEFAULT
769 }
770 )
772 def __init__(
773 self,
774 selector: str,
775 *,
776 custom: Mapping[str, ct.SelectorList] | None = None,
777 ignore: Iterable[str] | None = None,
778 flags: int = 0,
779 max_selectors: int = SELECTOR_LIMIT
780 ) -> None:
781 """Initialize."""
783 self.pattern = selector.replace('\x00', '\ufffd')
784 self.flags = flags
785 self.debug = self.flags & util.DEBUG
786 self.nostrict = self.flags & util.NOSTRICT
787 self.custom = custom
788 self.ignore = frozenset([] if ignore is None else ignore)
789 self.count = 0
790 self.maxsel = max_selectors
792 def increment_count(self, increment: int = 1) -> None:
793 """Check the current selector count."""
795 self.count += increment
796 if self.count > self.maxsel:
797 raise ValueError(f'Selector exceeds pseudo-class nesting limit of {self.maxsel}')
799 def parse_attribute_selector(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
800 """Create attribute selector from the returned regex match."""
802 op = m.group('cmp')
803 case = util.lower(m.group('case')) if m.group('case') else None
804 ns = css_unescape(m.group('attr_ns')[:-1]) if m.group('attr_ns') else ''
805 attr = css_unescape(m.group('attr_name'))
806 is_type = False
807 pattern2 = None
808 value = ''
809 inverse = False
811 if case:
812 flags = (re.I if case == 'i' else 0) | re.DOTALL
813 elif util.lower(attr) == 'type':
814 flags = re.I | re.DOTALL
815 is_type = True
816 else:
817 flags = re.DOTALL
819 if op:
820 value = css_unescape(m.group('value'))
822 if not op:
823 # Attribute name
824 pattern = None
825 elif op.startswith('^'):
826 # Value start with
827 # `^=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
828 value = r'(?!)' if not value else re.escape(value)
829 pattern = re.compile(r'^%s.*' % value, flags)
830 elif op.startswith('$'):
831 # Value ends with
832 # `$=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
833 value = r'(?!)' if not value else re.escape(value)
834 pattern = re.compile(r'.*?%s$' % value, flags)
835 elif op.startswith('*'):
836 # Value contains
837 # `*=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
838 value = r'(?!)' if not value else re.escape(value)
839 pattern = re.compile(r'.*?%s.*' % value, flags)
840 elif op.startswith('~'):
841 # Value contains word within space separated list
842 # `*~` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
843 value = r'(?!)' if not value or RE_WS.search(value) else re.escape(value)
844 pattern = re.compile(r'.*?(?:(?<=^)|(?<=[ \t\r\n\f]))%s(?=(?:[ \t\r\n\f]|$)).*' % value, flags)
845 elif op.startswith('|'):
846 # Value starts with word in dash separated list
847 pattern = re.compile(r'^%s(?:-.*)?$' % re.escape(value), flags)
848 elif op.startswith('!'):
849 # Value does not matches
850 pattern = re.compile(r'^(?!%s).*$' % re.escape(value), flags)
851 inverse = True
852 else:
853 # Value matches
854 pattern = re.compile(r'^%s$' % re.escape(value), flags)
856 if is_type and pattern:
857 pattern2 = re.compile(pattern.pattern)
859 # Append the attribute selector
860 sel_attr = ct.SelectorAttribute(attr, ns, pattern, pattern2, inverse)
861 sel.attributes.append(sel_attr)
863 has_selector = True
864 return has_selector
866 def parse_tag_pattern(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
867 """Parse tag pattern from regex match."""
869 prefix = css_unescape(m.group('tag_ns')[:-1]) if m.group('tag_ns') else None
870 tag = css_unescape(m.group('tag_name'))
871 sel.tag = ct.SelectorTag(tag, prefix)
872 has_selector = True
873 return has_selector
875 def parse_pseudo_class_custom(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
876 """
877 Parse custom pseudo class alias.
879 Compile custom selectors as we need them. When compiling a custom selector,
880 set it to `None` in the dictionary so we can avoid an infinite loop.
881 """
883 pseudo = util.lower(css_unescape(m.group('name')))
884 selector = self.custom.get(pseudo) if self.custom is not None else None
885 if selector is None:
886 raise SelectorSyntaxError(
887 f"Undefined custom selector '{pseudo}' found at position {m.end(0)}",
888 self.pattern,
889 m.end(0)
890 )
892 self.increment_count(selector.count)
893 sel.selectors.append(selector)
894 has_selector = True
895 return has_selector
897 def parse_pseudo_class(
898 self,
899 sel: _Selector,
900 m: Match[str],
901 has_selector: bool,
902 is_html: bool,
903 is_relative: bool
904 ) -> tuple[bool, bool, _SelectorContext | None]:
905 """
906 Parse pseudo class.
908 If the pseudo-class opens a selector list, a new context is returned to parse its contents.
909 """
911 context = None
912 complex_pseudo = False
913 pseudo = util.lower(css_unescape(m.group('name')))
914 if m.group('open'):
915 complex_pseudo = True
916 if complex_pseudo and pseudo in PSEUDO_COMPLEX:
917 context = self.parse_pseudo_open(pseudo, m.end(0), sel.selectors.append, is_relative)
918 has_selector = True
919 elif not complex_pseudo and pseudo in PSEUDO_SIMPLE:
920 if pseudo == ':root':
921 sel.flags |= ct.SEL_ROOT
922 elif pseudo == ':defined':
923 sel.flags |= ct.SEL_DEFINED
924 is_html = True
925 elif pseudo == ':scope':
926 sel.flags |= ct.SEL_SCOPE
927 elif pseudo == ':empty':
928 sel.flags |= ct.SEL_EMPTY
929 elif pseudo in self.PSEUDO_SELECTORS:
930 pseudo_selector = self.PSEUDO_SELECTORS[pseudo]
931 self.increment_count(pseudo_selector.count)
932 sel.selectors.append(pseudo_selector)
933 elif pseudo == ':first-child':
934 sel.nth.append(ct.SelectorNth(0, False, 1, False, False, ct.SelectorList()))
935 elif pseudo == ':last-child':
936 sel.nth.append(ct.SelectorNth(0, False, 1, False, True, ct.SelectorList()))
937 elif pseudo == ':first-of-type':
938 sel.nth.append(ct.SelectorNth(0, False, 1, True, False, ct.SelectorList()))
939 elif pseudo == ':last-of-type':
940 sel.nth.append(ct.SelectorNth(0, False, 1, True, True, ct.SelectorList()))
941 elif pseudo == ':only-child':
942 sel.nth.extend(
943 [
944 ct.SelectorNth(0, False, 1, False, False, ct.SelectorList()),
945 ct.SelectorNth(0, False, 1, False, True, ct.SelectorList())
946 ]
947 )
948 elif pseudo == ':only-of-type':
949 sel.nth.extend(
950 [
951 ct.SelectorNth(0, False, 1, True, False, ct.SelectorList()),
952 ct.SelectorNth(0, False, 1, True, True, ct.SelectorList())
953 ]
954 )
955 has_selector = True
956 elif complex_pseudo and pseudo in PSEUDO_COMPLEX_NO_MATCH:
957 context = self.parse_pseudo_open(pseudo, m.end(0))
958 sel.no_match = True
959 has_selector = True
960 elif not complex_pseudo and pseudo in PSEUDO_SIMPLE_NO_MATCH:
961 sel.no_match = True
962 has_selector = True
963 elif pseudo in PSEUDO_SUPPORTED:
964 raise SelectorSyntaxError(
965 f"Invalid syntax for pseudo class '{pseudo}'",
966 self.pattern,
967 m.start(0)
968 )
969 else:
970 raise SelectorSyntaxError(
971 f"'{pseudo}' was detected as a pseudo-class and is either unsupported or invalid. "
972 "If the syntax was not intended to be recognized as a pseudo-class, please escape the colon.",
973 self.pattern,
974 m.start(0)
975 )
977 return has_selector, is_html, context
979 def parse_pseudo_nth(
980 self,
981 sel: _Selector,
982 m: Match[str],
983 has_selector: bool
984 ) -> tuple[bool, _SelectorContext | None]:
985 """
986 Parse `nth` pseudo.
988 If `of S` is specified, a new context is returned to parse `S`.
989 """
991 context = None
992 mdict = m.groupdict()
993 if mdict.get('pseudo_nth_child'):
994 postfix = '_child'
995 else:
996 postfix = '_type'
997 mdict['name'] = util.lower(css_unescape(mdict['name']))
998 content = util.lower(mdict.get('nth' + postfix))
999 if content == 'even':
1000 # 2n
1001 s1 = 2
1002 s2 = 0
1003 var = True
1004 elif content == 'odd':
1005 # 2n+1
1006 s1 = 2
1007 s2 = 1
1008 var = True
1009 else:
1010 nth_parts = cast(Match[str], RE_NTH.match(content))
1011 _s1 = '-' if nth_parts.group('s1') and nth_parts.group('s1') == '-' else ''
1012 _s2 = ''
1013 a = nth_parts.group('a')
1014 var = a.endswith('n')
1015 if a.startswith('n'):
1016 _s1 += '1'
1017 elif var:
1018 _s1 += a[:-1]
1019 else:
1020 _s2 = _s1 + a
1021 _s1 = ''
1022 if nth_parts.group('s2') and nth_parts.group('s2') == '-':
1023 _s2 = '-'
1024 if nth_parts.group('b'):
1025 _s2 += nth_parts.group('b')
1026 s1 = int(_s1, 10) if _s1 else 0
1027 s2 = int(_s2, 10) if _s2 else 0
1029 pseudo_sel = mdict['name']
1030 if postfix == '_child':
1031 last = pseudo_sel == ':nth-last-child'
1033 def append_nth(nth_sel: ct.SelectorList) -> None:
1034 """Append to this selector's `nth` attribute."""
1036 sel.nth.append(ct.SelectorNth(s1, var, s2, False, last, nth_sel))
1038 if m.group('of'):
1039 # Parse the rest of `of S`.
1040 context = self.parse_pseudo_open(pseudo_sel, m.end(0), append_nth)
1041 else:
1042 # Use default `*|*` for `of S`.
1043 nth_sel = self.PSEUDO_SELECTORS['<nth-of-s>']
1044 self.increment_count(nth_sel.count)
1045 append_nth(nth_sel)
1046 else:
1047 if pseudo_sel == ':nth-of-type':
1048 sel.nth.append(ct.SelectorNth(s1, var, s2, True, False, ct.SelectorList()))
1049 elif pseudo_sel == ':nth-last-of-type':
1050 sel.nth.append(ct.SelectorNth(s1, var, s2, True, True, ct.SelectorList()))
1051 has_selector = True
1052 return has_selector, context
1054 def parse_pseudo_open(
1055 self,
1056 name: str,
1057 index: int,
1058 on_close: Callable[[ct.SelectorList], None] | None = None,
1059 is_relative: bool = False
1060 ) -> _SelectorContext:
1061 """Create a new context for nested pseudo-class selectors."""
1063 flags = FLG_PSEUDO | FLG_OPEN
1064 if name == ':not':
1065 flags |= FLG_NOT
1066 elif name == ':has':
1067 if is_relative and not self.nostrict:
1068 raise SelectorSyntaxError(
1069 f":has() was found to be nested under :has() at position {index}",
1070 self.pattern,
1071 index
1072 )
1073 flags |= FLG_RELATIVE
1074 elif name in (':where', ':is'):
1075 flags |= FLG_FORGIVE
1077 return self.create_context(flags, index, on_close)
1079 def create_context(
1080 self,
1081 flags: int,
1082 index: int,
1083 on_close: Callable[[ct.SelectorList], None] | None = None
1084 ) -> _SelectorContext:
1085 """Create a new selector context."""
1087 context = _SelectorContext(flags, index, self.count, on_close)
1088 if self.debug: # pragma: no cover
1089 context.debug()
1090 return context
1092 def parse_has_combinator(
1093 self,
1094 sel: _Selector,
1095 m: Match[str],
1096 has_selector: bool,
1097 selectors: list[_Selector],
1098 rel_type: str,
1099 index: int
1100 ) -> tuple[bool, _Selector, str]:
1101 """Parse combinator tokens."""
1103 combinator = m.group('relation')
1104 if not combinator:
1105 combinator = WS_COMBINATOR
1106 if combinator == COMMA_COMBINATOR:
1107 if not has_selector:
1108 raise SelectorSyntaxError(
1109 f"The combinator '{combinator}' at position {index}, must have a selector before it",
1110 self.pattern,
1111 index
1112 )
1113 sel.rel_type = rel_type
1114 selectors[-1].relations.append(sel)
1115 rel_type = ":" + WS_COMBINATOR
1116 selectors.append(_Selector())
1117 else:
1118 if has_selector and self.nostrict:
1119 # End the current selector and associate the leading combinator with this selector.
1120 sel.rel_type = rel_type
1121 selectors[-1].relations.append(sel)
1122 elif has_selector:
1123 raise SelectorSyntaxError(
1124 f'Using strict rules as proposed by CSS, the complex selectors at postition {index} is not allowed',
1125 self.pattern,
1126 index
1127 )
1128 elif rel_type[1:] != WS_COMBINATOR:
1129 # It's impossible to have two whitespace combinators after each other as the patterns
1130 # will gobble up trailing whitespace. It is also impossible to have a whitespace
1131 # combinator after any other kind for the same reason. But we could have
1132 # multiple non-whitespace combinators. So if the current combinator is not a whitespace,
1133 # then we've hit the multiple combinator case, so we should fail.
1134 raise SelectorSyntaxError(
1135 f'The multiple combinators at position {index}',
1136 self.pattern,
1137 index
1138 )
1140 # Set the leading combinator for the next selector.
1141 rel_type = ':' + combinator
1143 sel = _Selector()
1144 has_selector = False
1145 return has_selector, sel, rel_type
1147 def parse_combinator(
1148 self,
1149 sel: _Selector,
1150 m: Match[str],
1151 has_selector: bool,
1152 selectors: list[_Selector],
1153 relations: list[_Selector],
1154 is_pseudo: bool,
1155 is_forgive: bool,
1156 index: int
1157 ) -> tuple[bool, _Selector]:
1158 """Parse combinator tokens."""
1160 combinator = m.group('relation')
1161 if not combinator:
1162 combinator = WS_COMBINATOR
1163 if not has_selector:
1164 if not is_forgive or combinator != COMMA_COMBINATOR:
1165 raise SelectorSyntaxError(
1166 f"The combinator '{combinator}' at position {index}, must have a selector before it",
1167 self.pattern,
1168 index
1169 )
1171 # If we are in a forgiving pseudo class, just make the selector a "no match"
1172 if combinator == COMMA_COMBINATOR:
1173 self.increment_count()
1174 del relations[:]
1175 else:
1176 if combinator == COMMA_COMBINATOR:
1177 if not sel.tag and not is_pseudo:
1178 # Implied `*`
1179 sel.tag = ct.SelectorTag('*', None)
1180 sel.relations.extend(relations)
1181 selectors.append(sel)
1182 del relations[:]
1183 else:
1184 sel.relations.extend(relations)
1185 sel.rel_type = combinator
1186 del relations[:]
1187 relations.append(sel)
1189 sel = _Selector()
1190 has_selector = False
1192 return has_selector, sel
1194 def parse_class_id(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
1195 """Parse HTML classes and ids."""
1197 selector = m.group(0)
1198 if selector.startswith('.'):
1199 sel.classes.append(css_unescape(selector[1:]))
1200 else:
1201 sel.ids.append(css_unescape(selector[1:]))
1202 has_selector = True
1203 return has_selector
1205 def parse_pseudo_contains(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
1206 """Parse contains."""
1208 pseudo = util.lower(css_unescape(m.group('name')))
1209 if pseudo == ":contains":
1210 warnings.warn( # noqa: B028
1211 "The pseudo class ':contains' is deprecated, ':-soup-contains' should be used moving forward.",
1212 FutureWarning
1213 )
1214 contains_own = pseudo == ":-soup-contains-own"
1215 patterns = [
1216 css_unescape(token.group('value'))
1217 for token in RE_VALUES.finditer(m.group('values'))
1218 if not token.group('split')
1219 ]
1220 sel.contains.append(ct.SelectorContains(patterns, contains_own))
1221 has_selector = True
1222 return has_selector
1224 def parse_pseudo_lang(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
1225 """Parse pseudo language."""
1227 patterns = [
1228 css_unescape(token.group('value'))
1229 for token in RE_VALUES.finditer(m.group('values'))
1230 if not token.group('split')
1231 ]
1232 sel.lang.append(ct.SelectorLang(patterns))
1233 has_selector = True
1235 return has_selector
1237 def parse_pseudo_dir(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
1238 """Parse pseudo direction."""
1240 value = ct.SEL_DIR_LTR if util.lower(m.group('dir')) == 'ltr' else ct.SEL_DIR_RTL
1241 sel.flags |= value
1242 has_selector = True
1243 return has_selector
1245 def parse_selectors(
1246 self,
1247 iselector: Iterator[tuple[str, Match[str]]],
1248 index: int = 0,
1249 flags: int = 0
1250 ) -> ct.SelectorList:
1251 """
1252 Parse selectors.
1254 Pseudo-classes that open a nested selector list push a new context onto a stack,
1255 and closing them pops back to the parent context, so nesting does not recurse.
1256 """
1258 ctx = self.create_context(flags, index)
1259 stack: list[_SelectorContext] = []
1261 for key, m in iselector:
1262 child: _SelectorContext | None = None
1264 if key not in ('combine', 'pseudo_close'):
1265 self.increment_count()
1267 # Handle parts
1268 if self.ignore:
1269 mi = RE_PSEUDO_IGNORE.match(m.group(0))
1270 if mi is not None and mi.group('name').lower() in self.ignore:
1271 raise SelectorSyntaxError(
1272 f"The selector '{mi.group('name')}' at position {m.start(0)}, has been disallowed",
1273 self.pattern,
1274 m.start(0)
1275 )
1276 if key == "at_rule":
1277 raise NotImplementedError(f"At-rules found at position {m.start(0)}")
1278 elif key == "amp":
1279 ctx.sel.flags |= ct.SEL_SCOPE
1280 ctx.has_selector = True
1281 elif key == 'pseudo_class_custom':
1282 ctx.has_selector = self.parse_pseudo_class_custom(ctx.sel, m, ctx.has_selector)
1283 elif key == 'pseudo_class':
1284 ctx.has_selector, ctx.is_html, child = self.parse_pseudo_class(
1285 ctx.sel, m, ctx.has_selector, ctx.is_html, ctx.is_relative
1286 )
1287 elif key == 'pseudo_element':
1288 raise NotImplementedError(f"Pseudo-element found at position {m.start(0)}")
1289 elif key == 'pseudo_contains':
1290 ctx.has_selector = self.parse_pseudo_contains(ctx.sel, m, ctx.has_selector)
1291 elif key in ('pseudo_nth_type', 'pseudo_nth_child'):
1292 ctx.has_selector, child = self.parse_pseudo_nth(ctx.sel, m, ctx.has_selector)
1293 elif key == 'pseudo_lang':
1294 ctx.has_selector = self.parse_pseudo_lang(ctx.sel, m, ctx.has_selector)
1295 elif key == 'pseudo_dir':
1296 ctx.has_selector = self.parse_pseudo_dir(ctx.sel, m, ctx.has_selector)
1297 # Currently only supports HTML
1298 ctx.is_html = True
1299 elif key == 'pseudo_close':
1300 if not ctx.has_selector:
1301 if not ctx.is_forgive:
1302 raise SelectorSyntaxError(
1303 f"Expected a selector at position {m.start(0)}",
1304 self.pattern,
1305 m.start(0)
1306 )
1307 ctx.sel.no_match = True
1308 if not ctx.is_open:
1309 raise SelectorSyntaxError(
1310 f"Unmatched pseudo-class close at position {m.start(0)}",
1311 self.pattern,
1312 m.start(0)
1313 )
1315 # Hand the completed selector list to the parent and resume parsing the parent.
1316 selectors = self.finalize_selectors(ctx)
1317 if ctx.on_close is not None:
1318 ctx.on_close(selectors)
1319 ctx = stack.pop()
1320 continue
1321 elif key == 'combine':
1322 if ctx.is_relative:
1323 ctx.has_selector, ctx.sel, ctx.rel_type = self.parse_has_combinator(
1324 ctx.sel, m, ctx.has_selector, ctx.selectors, ctx.rel_type, ctx.index
1325 )
1326 else:
1327 ctx.has_selector, ctx.sel = self.parse_combinator(
1328 ctx.sel, m, ctx.has_selector, ctx.selectors, ctx.relations,
1329 ctx.is_pseudo, ctx.is_forgive, ctx.index
1330 )
1331 elif key == 'attribute':
1332 ctx.has_selector = self.parse_attribute_selector(ctx.sel, m, ctx.has_selector)
1333 elif key == 'tag':
1334 if ctx.has_selector:
1335 raise SelectorSyntaxError(
1336 f"Tag name found at position {m.start(0)} instead of at the start",
1337 self.pattern,
1338 m.start(0)
1339 )
1340 ctx.has_selector = self.parse_tag_pattern(ctx.sel, m, ctx.has_selector)
1341 elif key in ('class', 'id'):
1342 ctx.has_selector = self.parse_class_id(ctx.sel, m, ctx.has_selector)
1344 ctx.index = m.end(0)
1346 # A pseudo-class opened a nested selector list, parse it before resuming this one.
1347 if child is not None:
1348 stack.append(ctx)
1349 ctx = child
1351 # Handle selectors that are not closed
1352 if ctx.is_open:
1353 raise SelectorSyntaxError(
1354 f"Unclosed pseudo-class at position {ctx.index}",
1355 self.pattern,
1356 ctx.index
1357 )
1359 return self.finalize_selectors(ctx)
1361 def finalize_selectors(self, ctx: _SelectorContext) -> ct.SelectorList:
1362 """Complete the selector list of a context and freeze it."""
1364 sel = ctx.sel
1365 selectors = ctx.selectors
1366 relations = ctx.relations
1367 has_selector = ctx.has_selector
1369 # Cleanup completed selector piece
1370 if has_selector:
1371 if not sel.tag and not ctx.is_pseudo:
1372 # Implied `*`
1373 sel.tag = ct.SelectorTag('*', None)
1374 if ctx.is_relative:
1375 sel.rel_type = ctx.rel_type
1376 selectors[-1].relations.append(sel)
1377 else:
1378 sel.relations.extend(relations)
1379 del relations[:]
1380 selectors.append(sel)
1382 # Forgive empty slots in pseudo-classes that have lists (and are forgiving)
1383 elif ctx.is_forgive and (not selectors or not relations):
1384 # Handle normal pseudo-classes with empty slots like `:is()` etc.
1385 self.increment_count()
1386 sel.no_match = True
1387 del relations[:]
1388 selectors.append(sel)
1389 has_selector = True
1391 if not has_selector:
1392 # We will always need to finish a selector when `:has()` is used as it leads with combining.
1393 # May apply to others as well.
1394 raise SelectorSyntaxError(
1395 f'Expected a selector at position {ctx.index}',
1396 self.pattern,
1397 ctx.index
1398 )
1400 # Some patterns require additional logic, such as default. We try to make these the
1401 # last pattern, and append the appropriate flag to that selector which communicates
1402 # to the matcher what additional logic is required.
1403 if ctx.is_default:
1404 selectors[-1].flags = ct.SEL_DEFAULT
1405 if ctx.is_indeterminate:
1406 selectors[-1].flags = ct.SEL_INDETERMINATE
1407 if ctx.is_in_range:
1408 selectors[-1].flags = ct.SEL_IN_RANGE
1409 if ctx.is_out_of_range:
1410 selectors[-1].flags = ct.SEL_OUT_OF_RANGE
1411 if ctx.is_placeholder_shown:
1412 selectors[-1].flags = ct.SEL_PLACEHOLDER_SHOWN
1414 # Return selector list
1415 return ct.SelectorList([s.freeze() for s in selectors], ctx.is_not, ctx.is_html, self.count - ctx.count)
1417 def selector_iter(self, pattern: str) -> Iterator[tuple[str, Match[str]]]:
1418 """Iterate selector tokens."""
1420 # Ignore whitespace and comments at start and end of pattern
1421 m = RE_WS_BEGIN.search(pattern)
1422 index = m.end(0) if m else 0
1423 m = RE_WS_END.search(pattern[::-1])
1424 offset = m.end(0) if m else 0
1425 end = len(pattern) - (1 + offset)
1427 if self.debug: # pragma: no cover
1428 print(f'## PARSING: {pattern!r}')
1429 while index <= end:
1430 m = None
1431 for v in self.CSS_TOKENS:
1432 m = v.match(pattern, index, self.flags)
1433 if m:
1434 name = v.get_name()
1435 if self.debug: # pragma: no cover
1436 print(f"TOKEN: '{name}' --> {m.group(0)!r} at position {m.start(0)}")
1437 index = m.end(0)
1438 yield name, m
1439 break
1440 if m is None:
1441 c = pattern[index]
1442 # If the character represents the start of one of the known selector types,
1443 # throw an exception mentioning that the known selector type is in error;
1444 # otherwise, report the invalid character.
1445 if c == '[':
1446 msg = f"Malformed attribute selector at position {index}"
1447 elif c == '.':
1448 msg = f"Malformed class selector at position {index}"
1449 elif c == '#':
1450 msg = f"Malformed id selector at position {index}"
1451 elif c == ':':
1452 msg = f"Malformed pseudo-class selector at position {index}"
1453 else:
1454 msg = f"Invalid character {c!r} position {index}"
1455 raise SelectorSyntaxError(msg, self.pattern, index)
1456 if self.debug: # pragma: no cover
1457 print('## END PARSING')
1459 def process_selectors(self, index: int = 0, flags: int = 0) -> ct.SelectorList:
1460 """Process selectors."""
1462 return self.parse_selectors(self.selector_iter(self.pattern), index, flags)