1"""
2The soupsieve-shaped CSS matching surface of :mod:`turbohtml.query`, over the native selector engine.
3
4`soupsieve <https://facelessuser.github.io/soupsieve/>`__ is BeautifulSoup's CSS selector engine. A bs4 stack reaches
5it through ``soupsieve.compile(selector)`` (returning a reusable matcher) and the module-level
6``select``/``select_one``/``iselect``/``match``/``filter``/``closest`` helpers. turbohtml already matches the same CSS
7through :meth:`~turbohtml.Node.select`/:meth:`~turbohtml.Node.matches`/:meth:`~turbohtml.Node.closest`; this
8surface wraps those node methods in the soupsieve call shapes so porting a soupsieve (or bs4 ``Tag.select``) codebase is
9a mechanical import swap rather than a rewrite. It is re-exported from :mod:`turbohtml.query` next to the pyquery-style
10:class:`~turbohtml.query.Query`, the one namespace for searching a tree.
11
12It is a pure-Python facade -- no second engine. :func:`compile` validates the selector once up front (soupsieve
13compiles eagerly), raising :class:`turbohtml.SelectorSyntaxError` for a malformed selector, and the returned
14:class:`Matcher` re-runs the native engine per call. The selector entry points on the C core take only the selector
15string, so the soupsieve ``namespaces`` and ``flags`` arguments are bundled into one immutable :class:`Matching` config
16that travels
17with the matcher for API parity; they do not alter match *results* (mirroring soupsieve, whose ``flags`` are advisory
18and whose namespace discrimination turbohtml does not yet apply -- see the module reference for the limitation).
19"""
20
21from __future__ import annotations
22
23from dataclasses import dataclass
24from typing import TYPE_CHECKING, Final, cast
25
26from turbohtml._html import (
27 Document,
28 Element,
29 _css_escape_identifier,
30 _matches_many,
31 _query_children,
32 _select_limited,
33 parse,
34)
35from turbohtml._internal._selectors import SelectorSyntaxError
36
37if TYPE_CHECKING:
38 from collections.abc import Iterable, Iterator, Mapping
39
40#: The soupsieve ``DEBUG`` flag value, re-exported so a port can pass it without importing soupsieve. turbohtml exposes
41#: no compiled-selector dump, so it is accepted and carried but has no effect.
42DEBUG: Final = 0x1
43
44
45def escape_identifier(ident: str) -> str:
46 """
47 Escape a string so it is a valid CSS identifier, like ``soupsieve.escape`` / ``CSS.escape``.
48
49 Useful for building a selector around a class or id read from data: ``f"#{escape_identifier(raw_id)}"`` is safe even
50 when ``raw_id`` starts with a digit or holds ``.``/``#``/spaces.
51
52 :param ident: the raw identifier text.
53 :returns: the identifier with CSS-significant characters backslash- or hex-escaped per the CSSOM rules.
54 """
55 return _css_escape_identifier(ident)
56
57
58@dataclass(frozen=True)
59class Matching:
60 """
61 The soupsieve ``namespaces`` and ``flags`` arguments, bundled into one immutable, thread-safe config.
62
63 Pass it as the ``options`` of :func:`compile` (or any module-level helper) to mirror a soupsieve call. Both fields
64 are carried for API parity and do not change which elements match: turbohtml selects by an element's local name, so
65 a prefixed type selector (``svg|rect``) matches every ``rect`` regardless of the ``namespaces`` mapping, and
66 ``flags`` is advisory as in soupsieve. ``Matching()`` reproduces soupsieve's default HTML matching.
67
68 :param namespaces: prefix-to-URI map for prefixed type selectors, accepted for soupsieve parity; the native engine
69 does not discriminate by namespace URI (see the module reference).
70 :param flags: the soupsieve flag bitmask (only :data:`DEBUG` is defined); accepted and carried but inert.
71 """
72
73 namespaces: Mapping[str, str] | None = None
74 flags: int = 0
75
76 @classmethod
77 def soupsieve(cls, namespaces: Mapping[str, str] | None = None, flags: int = 0) -> Matching:
78 """
79 Build a config straight from soupsieve's ``(namespaces, flags)`` call convention.
80
81 Lets a port keep the original argument list -- ``Matching.soupsieve(namespaces=ns, flags=DEBUG)`` -- rather
82 than renaming keywords at every call site.
83
84 :param namespaces: the soupsieve ``namespaces`` mapping.
85 :param flags: the soupsieve ``flags`` bitmask.
86 :returns: the equivalent :class:`Matching`.
87 """
88 return cls(namespaces=namespaces, flags=flags)
89
90
91_DEFAULT: Final = Matching()
92# A throwaway element to validate selector syntax at compile time; matching against it parses the selector, raising on
93# a malformed one, without needing the caller's node.
94_PROBE: Final = cast("Element", parse("<x></x>").root)
95
96
97class Matcher:
98 """
99 A selector compiled against turbohtml's engine, with the soupsieve matcher methods.
100
101 Build one with :func:`compile` and reuse it; it is immutable and thread-safe. Each method runs the native selector
102 engine over the node passed in, so one :class:`Matcher` serves many trees.
103
104 :param selector: the CSS selector; validated immediately, raising :class:`turbohtml.SelectorSyntaxError` when
105 malformed.
106 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
107 """
108
109 def __init__(self, selector: str, options: Matching | None = None, /) -> None:
110 """Validate the selector (a bad one raises :class:`turbohtml.SelectorSyntaxError`) and store it."""
111 _PROBE.matches(selector)
112 self._selector = selector
113 self._options = options if options is not None else _DEFAULT
114
115 @property
116 def pattern(self) -> str:
117 """The selector string, named as soupsieve's ``SoupSieve.pattern``."""
118 return self._selector
119
120 @property
121 def namespaces(self) -> Mapping[str, str] | None:
122 """The configured namespace map, named as soupsieve's ``SoupSieve.namespaces``."""
123 return self._options.namespaces
124
125 @property
126 def flags(self) -> int:
127 """The configured flag bitmask, named as soupsieve's ``SoupSieve.flags``."""
128 return self._options.flags
129
130 def match(self, node: Element) -> bool:
131 """
132 Test whether one element matches the selector.
133
134 :param node: the element to test.
135 :returns: whether it matches.
136 """
137 return node.matches(self._selector)
138
139 def select(self, node: Element | Document, limit: int = 0) -> list[Element]:
140 """
141 Collect the descendants of an element that match the selector, in document order.
142
143 :param node: the element or document whose descendants are searched.
144 :param limit: the most matches to return, or ``0`` for all.
145 :returns: the matching descendants.
146 """
147 return _select_limited(node, self._selector, limit)
148
149 def select_one(self, node: Element | Document) -> Element | None:
150 """
151 Return the first descendant of an element that matches the selector.
152
153 :param node: the element or document whose descendants are searched.
154 :returns: the first match in document order, or ``None``.
155 """
156 return node.select_one(self._selector)
157
158 def iselect(self, node: Element | Document, limit: int = 0) -> Iterator[Element]:
159 """
160 Iterate the descendants of an element that match the selector, in document order.
161
162 :param node: the element or document whose descendants are searched.
163 :param limit: the most matches to yield, or ``0`` for all.
164 :returns: an iterator over the matching descendants.
165 """
166 yield from self.select(node, limit)
167
168 def filter(self, iterable: Element | Document | Iterable[Element]) -> list[Element]:
169 """
170 Keep the members of an iterable that match the selector.
171
172 :param iterable: elements to test, or one element or document whose direct element children are tested
173 (soupsieve's rule for a single node).
174 :returns: the members that match.
175 """
176 if isinstance(iterable, (Element, Document)):
177 return _matches_many(_query_children([iterable]), self._selector)
178 return _matches_many(list(iterable), self._selector)
179
180 def closest(self, node: Element) -> Element | None:
181 """
182 Return the nearest element at or above one that matches the selector.
183
184 :param node: the element to walk up from.
185 :returns: the nearest self-or-ancestor that matches, or ``None``.
186 """
187 return node.closest(self._selector)
188
189
190def compile(selector: str, options: Matching | None = None, /) -> Matcher: # ruff:ignore[builtin-variable-shadowing] # the soupsieve entry-point name
191 """
192 Compile a CSS selector into a reusable :class:`Matcher`, like ``soupsieve.compile``.
193
194 The selector is validated up front, so a malformed one raises :class:`turbohtml.SelectorSyntaxError` here, not at
195 the first match.
196
197 :param selector: the CSS selector.
198 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
199 :returns: a matcher bound to the selector.
200 """
201 return Matcher(selector, options)
202
203
204def css(selector: str, options: Matching | None = None, /) -> Matcher:
205 """
206 Compile a CSS selector into a :class:`Matcher`; a readable alias of :func:`compile`.
207
208 :param selector: the CSS selector.
209 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
210 :returns: a matcher bound to the selector.
211 """
212 return Matcher(selector, options)
213
214
215def match(selector: str, node: Element, options: Matching | None = None, /) -> bool:
216 """
217 Test whether an element matches a selector, compiling it for this one call.
218
219 :param selector: the CSS selector.
220 :param node: the element to test.
221 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
222 :returns: whether the element matches.
223 """
224 return Matcher(selector, options).match(node)
225
226
227def select(
228 selector: str, node: Element | Document, options: Matching | None = None, /, *, limit: int = 0
229) -> list[Element]:
230 """
231 Collect the descendants of an element matching a selector, compiling it for this one call.
232
233 :param selector: the CSS selector.
234 :param node: the element or document whose descendants are searched.
235 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
236 :param limit: the most matches to return, or ``0`` for all.
237 :returns: the matching descendants in document order.
238 """
239 return Matcher(selector, options).select(node, limit)
240
241
242def select_one(selector: str, node: Element | Document, options: Matching | None = None, /) -> Element | None:
243 """
244 Return the first descendant of an element matching a selector, compiling it for this one call.
245
246 :param selector: the CSS selector.
247 :param node: the element or document whose descendants are searched.
248 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
249 :returns: the first match in document order, or ``None``.
250 """
251 return Matcher(selector, options).select_one(node)
252
253
254def iselect(
255 selector: str, node: Element | Document, options: Matching | None = None, /, *, limit: int = 0
256) -> Iterator[Element]:
257 """
258 Iterate the descendants of an element matching a selector, compiling it for this one call.
259
260 :param selector: the CSS selector.
261 :param node: the element or document whose descendants are searched.
262 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
263 :param limit: the most matches to yield, or ``0`` for all.
264 :returns: an iterator over the matching descendants.
265 """
266 return Matcher(selector, options).iselect(node, limit)
267
268
269def filter( # ruff:ignore[builtin-variable-shadowing] # the soupsieve entry-point name
270 selector: str, iterable: Element | Document | Iterable[Element], options: Matching | None = None, /
271) -> list[Element]:
272 """
273 Keep the members of an iterable matching a selector, compiling it for this one call.
274
275 :param selector: the CSS selector.
276 :param iterable: elements to test, or one element or document whose direct element children are tested.
277 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
278 :returns: the members that match.
279 """
280 return Matcher(selector, options).filter(iterable)
281
282
283def closest(selector: str, node: Element, options: Matching | None = None, /) -> Element | None:
284 """
285 Return the nearest self-or-ancestor of an element matching a selector, compiling it for this one call.
286
287 :param selector: the CSS selector.
288 :param node: the element to walk up from.
289 :param options: the :class:`Matching` config, or ``None`` for soupsieve's defaults.
290 :returns: the nearest matching element, or ``None``.
291 """
292 return Matcher(selector, options).closest(node)
293
294
295__all__ = [
296 "DEBUG",
297 "Matcher",
298 "Matching",
299 "SelectorSyntaxError",
300 "closest",
301 "compile",
302 "css",
303 "escape_identifier",
304 "filter",
305 "iselect",
306 "match",
307 "select",
308 "select_one",
309]