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
169 changes: 68 additions & 101 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ class SelectorSyntaxError(SelectorError, SyntaxError):
PseudoElement: TypeAlias = Union["FunctionalPseudoElement", str]


def _strip_universal(css: str) -> str:
"""Strip a redundant universal selector from e.g. "*.foo" (but not from
e.g. "* > foo")."""
if len(css) > 1 and css[0] == "*" and css[1] in "#.[:":
return css[1:]
return css


class Selector:
"""
Represents a parsed selector.
Expand Down Expand Up @@ -118,12 +126,7 @@ def canonical(self) -> str:
pseudo_element = f"::{_serialize_ident(self.pseudo_element)}"
else:
pseudo_element = ""
res = f"{self.parsed_tree.canonical()}{pseudo_element}"
# Strip a redundant universal selector from e.g. "*.foo" (but not
# from e.g. "* > foo").
if len(res) > 1 and res[0] == "*" and res[1] in "#.[:":
res = res[1:]
return res
return _strip_universal(f"{self.parsed_tree.canonical()}{pseudo_element}")

def specificity(self) -> tuple[int, int, int]:
"""Return the specificity_ of this selector as a tuple of 3 integers.
Expand Down Expand Up @@ -242,27 +245,26 @@ def specificity(self) -> tuple[int, int, int]:

class Negation:
"""
Represents selector:not(subselector)
Represents selector:not(selector_list)
"""

def __init__(self, selector: Tree, subselector: Tree) -> None:
def __init__(self, selector: Tree, selector_list: Iterable[Tree]) -> None:
self.selector = selector
self.subselector = subselector
self.selector_list = selector_list

def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}:not({self.subselector!r})]"
args_str = ", ".join(repr(s) for s in self.selector_list)
return f"{self.__class__.__name__}[{self.selector!r}:not({args_str})]"

def canonical(self) -> str:
subsel = self.subselector.canonical()
# Strip a redundant universal selector from e.g. "*.foo" (but not
# from e.g. "* > foo").
if len(subsel) > 1 and subsel[0] == "*" and subsel[1] in "#.[:":
subsel = subsel[1:]
return f"{self.selector.canonical()}:not({subsel})"
args_str = ", ".join(
_strip_universal(s.canonical()) for s in self.selector_list
)
return f"{self.selector.canonical()}:not({args_str})"

def specificity(self) -> tuple[int, int, int]:
a1, b1, c1 = self.selector.specificity()
a2, b2, c2 = self.subselector.specificity()
a2, b2, c2 = max(x.specificity() for x in self.selector_list)
return a1 + a2, b1 + b2, c1 + c2


Expand Down Expand Up @@ -314,13 +316,9 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}:is({args_str})]"

def canonical(self) -> str:
selector_arguments = []
for s in self.selector_list:
selarg = s.canonical()
if len(selarg) > 1:
selarg = selarg.lstrip("*")
selector_arguments.append(selarg)
args_str = ", ".join(selector_arguments)
args_str = ", ".join(
_strip_universal(s.canonical()) for s in self.selector_list
)
return f"{self.selector.canonical()}:is({args_str})"

def specificity(self) -> tuple[int, int, int]:
Expand All @@ -344,13 +342,9 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}:where({args_str})]"

def canonical(self) -> str:
selector_arguments = []
for s in self.selector_list:
selarg = s.canonical()
if len(selarg) > 1:
selarg = selarg.lstrip("*")
selector_arguments.append(selarg)
args_str = ", ".join(selector_arguments)
args_str = ", ".join(
_strip_universal(s.canonical()) for s in self.selector_list
)
return f"{self.selector.canonical()}:where({args_str})"

def specificity(self) -> tuple[int, int, int]:
Expand Down Expand Up @@ -568,7 +562,7 @@ def parse_selector_group(stream: TokenStream) -> Iterator[Selector]:


def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]:
result, pseudo_element = parse_simple_selector(stream)
result, pseudo_element = parse_compound_selector(stream)
while 1:
stream.skip_whitespace()
peek = stream.peek()
Expand All @@ -583,17 +577,16 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]:
combinator = cast("str", stream.next().value)
stream.skip_whitespace()
else:
# By exclusion, the last parse_simple_selector() ended
# By exclusion, the last parse_compound_selector() ended
# at peek == ' '
combinator = " "
next_selector, pseudo_element = parse_simple_selector(stream)
next_selector, pseudo_element = parse_compound_selector(stream)
result = CombinedSelector(result, combinator, next_selector)
return result, pseudo_element


def parse_simple_selector(
def parse_compound_selector(
stream: TokenStream,
inside_negation: bool = False,
inside_selector_list: bool = False,
) -> tuple[Tree, PseudoElement | None]:
stream.skip_whitespace()
Expand All @@ -617,11 +610,7 @@ def parse_simple_selector(
pseudo_element: PseudoElement | None = None
while 1:
peek = stream.peek()
if (
peek.type in ("S", "EOF")
or peek.is_delim(",", "+", ">", "~")
or (inside_negation and peek == ("DELIM", ")"))
):
if peek.type in ("S", "EOF") or peek.is_delim(",", "+", ">", "~", ")"):
break
if pseudo_element:
raise SelectorSyntaxError(
Expand All @@ -634,7 +623,7 @@ def parse_simple_selector(
result = Class(result, stream.next_ident())
elif peek == ("DELIM", "|"):
# The explicit "no namespace" syntax, e.g. |div: only valid at
# the very start of a simple selector.
# the very start of a compound selector.
if len(stream.used) != selector_start:
raise SelectorSyntaxError(f"Expected selector, got {peek}")
stream.next()
Expand Down Expand Up @@ -663,11 +652,11 @@ def parse_simple_selector(
result = Pseudo(result, ident)
if result.ident == "scope":
# :scope is only supported at the start of a selector,
# i.e. never in :is()/:where()/:matches() arguments
# (where a preceding comma separates arguments, not
# selectors), and otherwise only when the tokens
# preceding its compound selector are the start of the
# input or a comma.
# i.e. never in :not()/:is()/:where()/:matches()
# arguments (where a preceding comma separates
# arguments, not selectors), and otherwise only when the
# tokens preceding its compound selector are the start
# of the input or a comma.
preceding = stream.used[:selector_start]
while preceding and preceding[-1].type == "S":
preceding = preceding[:-1]
Expand All @@ -681,51 +670,16 @@ def parse_simple_selector(
stream.next()
stream.skip_whitespace()
if ident.lower() == "not":
if inside_selector_list:
raise SelectorSyntaxError(
":not() is not supported inside :is(), :where() and :matches()"
)
if inside_negation:
raise SelectorSyntaxError("Got nested :not()")
argument, argument_pseudo_element = parse_simple_selector(
stream, inside_negation=True
)
while 1:
# Whitespace before the closing parenthesis is not a
# descendant combinator.
stream.skip_whitespace()
peek = stream.peek()
if argument_pseudo_element:
raise SelectorSyntaxError(
f"Got pseudo-element ::{argument_pseudo_element} inside :not() at {peek.pos}"
)
if peek == ("DELIM", ")"):
stream.next()
break
if peek.is_delim("+", ">", "~"):
argument_combinator = cast("str", stream.next().value)
stream.skip_whitespace()
elif peek.type == "EOF" or peek.is_delim(","):
# A selector list is not supported in :not().
raise SelectorSyntaxError(f"Expected ')', got {peek}")
else:
argument_combinator = " "
next_selector, argument_pseudo_element = parse_simple_selector(
stream, inside_negation=True
)
argument = CombinedSelector(
argument, argument_combinator, next_selector
)
result = Negation(result, argument)
result = Negation(result, parse_selector_list_arguments(stream))
elif ident.lower() == "has":
combinator, arguments = parse_relative_selector(stream)
result = Relation(result, combinator, arguments)

elif ident.lower() in ("matches", "is"):
selectors = parse_simple_selector_arguments(stream)
selectors = parse_selector_list_arguments(stream)
result = Matching(result, selectors)
elif ident.lower() == "where":
selectors = parse_simple_selector_arguments(stream)
selectors = parse_selector_list_arguments(stream)
result = SpecificityAdjustment(result, selectors)
else:
result = Function(result, ident, parse_arguments(stream))
Expand Down Expand Up @@ -784,30 +738,43 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]:
# Reparse the collected tokens instead of their concatenated source
# text, so that escaped identifiers are preserved.
subselector_tokens.append(EOFToken(next_.pos))
result, _ = parse_simple_selector(TokenStream(subselector_tokens))
result, _ = parse_compound_selector(TokenStream(subselector_tokens))
return combinator, Selector(result)


def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]:
def parse_selector_list_arguments(stream: TokenStream) -> list[Tree]:
"""Parse the selector list of a :not(), :is(), :where() or :matches()
argument, i.e. a comma-separated list of complex selectors."""
arguments = []
while 1:
result, pseudo_element = parse_simple_selector(
stream, inside_negation=True, inside_selector_list=True
result, pseudo_element = parse_compound_selector(
stream, inside_selector_list=True
)
if pseudo_element:
raise SelectorSyntaxError(
f"Got pseudo-element ::{pseudo_element} inside function"
)
stream.skip_whitespace()
next_ = stream.next()
if next_ == ("DELIM", ","):
while 1:
if pseudo_element:
raise SelectorSyntaxError(
f"Got pseudo-element ::{pseudo_element} inside function"
)
# Whitespace before a comma or the closing parenthesis is not a
# descendant combinator.
stream.skip_whitespace()
arguments.append(result)
elif next_ == ("DELIM", ")"):
arguments.append(result)
peek = stream.peek()
if peek.is_delim(",", ")"):
break
if peek.type == "EOF":
raise SelectorSyntaxError(f"Expected ')', got {peek}")
if peek.is_delim("+", ">", "~"):
combinator = cast("str", stream.next().value)
stream.skip_whitespace()
else:
combinator = " "
next_selector, pseudo_element = parse_compound_selector(
stream, inside_selector_list=True
)
result = CombinedSelector(result, combinator, next_selector)
arguments.append(result)
if stream.next().is_delim(")"):
break
else:
raise SelectorSyntaxError(f"Expected an argument, got {next_}")
return arguments


Expand Down
42 changes: 22 additions & 20 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,9 @@ def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr:

def xpath_negation(self, negation: Negation) -> XPathExpr:
xpath = self.xpath(negation.selector)
condition = self._xpath_match_condition(negation.subselector)
condition = self._xpath_selector_list_condition(negation.selector_list)
if condition is None:
# The argument matches every element, so :not() matches none.
# An argument matches every element, so :not() matches none.
return xpath.add_condition("0")
return xpath.add_condition(f"not({condition})")

Expand Down Expand Up @@ -369,27 +369,29 @@ def _xpath_add_selector_list_condition(
) -> XPathExpr:
"""Add a condition matching any selector of the list
(for :is() and :where())."""
condition = self._xpath_selector_list_condition(selector_list)
if condition is None:
# A selector of the list matches any element, so the whole
# selector list does too: it adds no condition.
return xpath
return xpath.add_condition(condition)

def _xpath_selector_list_condition(
self, selector_list: Iterable[Tree]
) -> str | None:
"""Return a condition that holds for the elements matching any
selector of the list, or None if that is every element."""
condition = ""
for e in (self.xpath(selector) for selector in selector_list):
if e.path:
# Only a combined selector (e.g. "a b") translates to a path,
# which cannot be embedded into a predicate of the outer
# expression. The parser rejects combinators in these arguments,
# so this is only reachable through a hand-built Matching or
# SpecificityAdjustment node.
raise ExpressionError(
"Combined selectors are not supported inside "
":is(), :where() and :matches()"
)
e.add_name_test()
if not e.condition:
# This argument matches any element, so the whole selector
# list does too: it adds no condition.
return xpath
for selector in selector_list:
argument_condition = self._xpath_match_condition(selector)
if argument_condition is None:
return None
condition = (
f"({condition}) or ({e.condition})" if condition else e.condition
f"({condition}) or ({argument_condition})"
if condition
else argument_condition
)
return xpath.add_condition(condition)
return condition

def xpath_function(self, function: Function) -> XPathExpr:
"""Translate a functional pseudo-class."""
Expand Down
14 changes: 6 additions & 8 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,17 @@ be implemented):

* The ``:scope`` pseudo-class. Limitation: it can only be used at a start of a
selector.
* The ``:is()`` and ``:where()`` pseudo-classes. Limitation: their arguments
are a comma-separated list of *compound selectors*; combinators are not
allowed (e.g. ``:is(a b)`` or ``:is(a > b)``). ``:not()`` and ``:scope`` are
also rejected inside them, while ``:has()`` is supported (e.g.
``:is(:has(> a))``).
* A *selector list* of *complex selectors* as the argument of the ``:is()``,
``:where()`` and ``:not()`` pseudo-classes, e.g.
``:is(a.important > b, :has(> a))`` or ``:not(a > b, :not(.c))``.
Limitations: ``:scope`` is rejected inside them, and their selector list is
not forgiving, i.e. an unsupported argument makes the whole selector invalid
instead of being ignored.
* The ``:has()`` pseudo-class. Limitation: it takes a single argument, made of
an optional leading combinator (``>``, ``+`` or ``~``) followed by one
*compound selector* built only from type, class and universal selectors
(e.g. ``:has(> a.important)``). Anything else is unsupported, e.g. an ID
(``:has(#id)``), or a selector list (``:has(a, b)``).
* The ``:not()`` pseudo-class with a *complex selector* argument, e.g.
``:not(a.important[rel] > b)``. Limitation: it takes a single argument, so a
selector list is unsupported (e.g. ``:not(a, b)``).

These are non-standard extensions:

Expand Down
Loading
Loading