diff --git a/cssselect/parser.py b/cssselect/parser.py index 6d06ea4..f770038 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -266,37 +266,46 @@ def specificity(self) -> tuple[int, int, int]: return a1 + a2, b1 + b2, c1 + c2 +def _combinator_prefix(combinator: Token) -> str: + # The descendant combinator is implicit in :has() arguments. + if combinator.value == " ": + return "" + return f"{combinator.value} " + + class Relation: """ - Represents selector:has(subselector) + Represents selector:has(relative_selector_list) + + .. attribute:: arguments + + The relative selector list, as a list of ``(combinator, selector)`` + pairs. *combinator* is the leading combinator token of the argument, + which is a space token when the argument does not spell one out. + """ - def __init__(self, selector: Tree, combinator: Token, subselector: Selector): + def __init__(self, selector: Tree, arguments: Sequence[tuple[Token, Selector]]): self.selector = selector - self.combinator = combinator - self.subselector = subselector - - def _combinator_prefix(self) -> str: - # The descendant combinator is implicit in :has() arguments. - if self.combinator.value == " ": - return "" - return f"{self.combinator.value} " + self.arguments = arguments def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}[{self.selector!r}" - f":has({self._combinator_prefix()}{self.subselector!r})]" + args_str = ", ".join( + f"{_combinator_prefix(combinator)}{subselector!r}" + for combinator, subselector in self.arguments ) + return f"{self.__class__.__name__}[{self.selector!r}:has({args_str})]" def canonical(self) -> str: - subsel = self.subselector.canonical() - if len(subsel) > 1: - subsel = subsel.lstrip("*") - return f"{self.selector.canonical()}:has({self._combinator_prefix()}{subsel})" + args_str = ", ".join( + f"{_combinator_prefix(combinator)}{subselector.canonical()}" + for combinator, subselector in self.arguments + ) + return f"{self.selector.canonical()}:has({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(subselector.specificity() for _, subselector in self.arguments) return a1 + a2, b1 + b2, c1 + c2 @@ -617,11 +626,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( @@ -718,9 +723,7 @@ def parse_simple_selector( ) result = Negation(result, argument) elif ident.lower() == "has": - combinator, arguments = parse_relative_selector(stream) - result = Relation(result, combinator, arguments) - + result = Relation(result, parse_relative_selector(stream)) elif ident.lower() in ("matches", "is"): selectors = parse_simple_selector_arguments(stream) result = Matching(result, selectors) @@ -752,47 +755,51 @@ def parse_arguments(stream: TokenStream) -> list[Token]: # noqa: RET503 raise SelectorSyntaxError(f"Expected an argument, got {next_}") -def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: - stream.skip_whitespace() - subselector_tokens: list[Token] = [] - next_ = stream.next() - - if next_ in [("DELIM", "+"), ("DELIM", ">"), ("DELIM", "~")]: - combinator = next_ - stream.skip_whitespace() - next_ = stream.next() - else: - combinator = Token("DELIM", " ", pos=0) - - seen_whitespace = False +def parse_relative_selector(stream: TokenStream) -> list[tuple[Token, Selector]]: + """Parse the relative selector list of a :has() argument, i.e. a + comma-separated list of complex selectors, each optionally preceded by a + combinator.""" + arguments = [] while 1: - if next_.type == "S": - # Whitespace is valid before the closing parenthesis; anywhere - # else it would be a descendant combinator, which is not - # supported in :has() arguments. - seen_whitespace = True - elif next_.type == "IDENT" or next_ in [("DELIM", "."), ("DELIM", "*")]: - if seen_whitespace: - raise SelectorSyntaxError(f"Expected an argument, got {next_}") - subselector_tokens.append(next_) - elif next_ == ("DELIM", ")"): - break + stream.skip_whitespace() + peek = stream.peek() + if peek.is_delim("+", ">", "~"): + combinator = stream.next() + stream.skip_whitespace() else: - raise SelectorSyntaxError(f"Expected an argument, got {next_}") - next_ = stream.next() - - # 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)) - return combinator, Selector(result) + combinator = Token("DELIM", " ", pos=peek.pos) + result, pseudo_element = parse_simple_selector(stream) + 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() + peek = stream.peek() + if peek.is_delim(",", ")"): + break + if peek.type == "EOF": + raise SelectorSyntaxError(f"Expected ')', got {peek}") + if peek.is_delim("+", ">", "~"): + argument_combinator = cast("str", stream.next().value) + stream.skip_whitespace() + else: + argument_combinator = " " + next_selector, pseudo_element = parse_simple_selector(stream) + result = CombinedSelector(result, argument_combinator, next_selector) + arguments.append((combinator, Selector(result))) + if stream.next().is_delim(")"): + break + return arguments def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments = [] while 1: result, pseudo_element = parse_simple_selector( - stream, inside_negation=True, inside_selector_list=True + stream, inside_selector_list=True ) if pseudo_element: raise SelectorSyntaxError( diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 8ef5d7c..3f0039a 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -341,18 +341,18 @@ def _xpath_match_condition(self, selector: Tree) -> str | None: return sub_xpath.condition or None def xpath_relation(self, relation: Relation) -> XPathExpr: - xpath = self.xpath(relation.selector) - combinator = relation.combinator - subselector = relation.subselector - right = self.xpath(subselector.parsed_tree) - method = cast( - "Callable[[XPathExpr, XPathExpr], XPathExpr]", - getattr( - self, - f"xpath_relation_{self.combinator_mapping[cast('str', combinator.value)]}_combinator", - ), - ) - return method(xpath, right) + condition = "" + for combinator, subselector in relation.arguments: + method = cast( + "Callable[[XPathExpr], str]", + getattr( + self, + f"xpath_relation_{self.combinator_mapping[cast('str', combinator.value)]}_combinator", + ), + ) + argument = method(self.xpath(subselector.parsed_tree)) + condition = f"({condition}) or ({argument})" if condition else argument + return self.xpath(relation.selector).add_condition(condition) def xpath_matching(self, matching: Matching) -> XPathExpr: return self._xpath_add_selector_list_condition( @@ -507,31 +507,28 @@ def xpath_indirect_adjacent_combinator( # into `path`/`element`) so that `element` stays a plain element name: # later steps such as :first-of-type or :not() read and rewrite it. - def xpath_relation_descendant_combinator( - self, left: XPathExpr, right: XPathExpr - ) -> XPathExpr: - """right is a child, grand-child or further descendant of left; select left""" - return left.add_condition(f"descendant::{right}") - - def xpath_relation_child_combinator( - self, left: XPathExpr, right: XPathExpr - ) -> XPathExpr: - """right is an immediate child of left; select left""" - return left.add_condition(f"./{right}") - - def xpath_relation_direct_adjacent_combinator( - self, left: XPathExpr, right: XPathExpr - ) -> XPathExpr: - """right is a sibling immediately after left; select left""" - right.add_name_test() - right.add_condition("position() = 1") - return left.add_condition(f"following-sibling::{right}") + def xpath_relation_descendant_combinator(self, right: XPathExpr) -> str: + """right is a child, grand-child or further descendant of the element""" + return f"descendant::{right}" + + def xpath_relation_child_combinator(self, right: XPathExpr) -> str: + """right is an immediate child of the element""" + return f"./{right}" + + def xpath_relation_direct_adjacent_combinator(self, right: XPathExpr) -> str: + """right is a sibling immediately after the element""" + # Test the first step of right against that sibling itself, so that + # any further step of right is walked from there. + if right.path: + head, sep, tail = right.path.partition("/") + right.path = f"self::{head}{sep}{tail}" + else: + right.element = f"self::{right.element}" + return f"following-sibling::*[1]/{right}" - def xpath_relation_indirect_adjacent_combinator( - self, left: XPathExpr, right: XPathExpr - ) -> XPathExpr: - """right is a sibling after left, immediately or not; select left""" - return left.add_condition(f"following-sibling::{right}") + def xpath_relation_indirect_adjacent_combinator(self, right: XPathExpr) -> str: + """right is a sibling after the element, immediately or not""" + return f"following-sibling::{right}" # Function: dispatch by function/pseudo-class name diff --git a/docs/index.rst b/docs/index.rst index 0f331ab..8fe6403 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -113,11 +113,8 @@ be implemented): 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))``). -* 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 ``:has()`` pseudo-class, e.g. ``:has(> a.important, + p b)``. Limitation: + ``:scope`` is rejected inside it. * 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)``). diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 8e1b2a7..f20f9bf 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -187,6 +187,16 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("div:has(~ div.foo)") == [ "Relation[Element[div]:has(~ Selector[Class[Element[div].foo]])]" ] + assert parse_many("div:has(a b)") == [ + "Relation[Element[div]:has(" + "Selector[CombinedSelector[Element[a] Element[b]]])]" + ] + assert parse_many("div:has(a, > b, + c d)") == [ + "Relation[Element[div]:has(" + "Selector[Element[a]], " + "> Selector[Element[b]], " + "+ Selector[CombinedSelector[Element[c] Element[d]]])]" + ] assert parse_many("div:is(.foo, #bar)") == [ "Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]" ] @@ -350,6 +360,10 @@ def specificity(css: str) -> tuple[int, int, int]: assert specificity(":has(foo)") == (0, 0, 1) assert specificity(":has(.foo)") == (0, 1, 0) assert specificity(":has(> foo)") == (0, 0, 1) + assert specificity(":has(foo bar)") == (0, 0, 2) + # The most specific argument of the list wins + assert specificity(":has(.foo, #bar)") == (1, 0, 0) + assert specificity(":has(> .foo, bar)") == (0, 1, 0) assert specificity(":is(.foo, #bar)") == (1, 0, 0) assert specificity(":is(:hover, :visited)") == (0, 1, 0) @@ -415,6 +429,10 @@ def css2css(css: str, res: str | None = None) -> None: css2css(":has(~ foo)") css2css(":has(+ foo)") css2css("div:has(> div.foo)") + css2css(":has(foo bar)") + # a "*" that is a whole compound selector is kept + css2css(":has(* > bar)") + css2css("div:has(> div.foo, + p, bar baz)") css2css(":is(#bar, .foo)") css2css(":is(:focused, :visited)") css2css(":where(:focused, :visited)") @@ -607,20 +625,26 @@ def get_error(css: str) -> str | None: ) assert get_error("> div p") == ("Expected selector, got ' at 0>") - # Unsupported :has() with several arguments - assert get_error(":has(a, b)") == ("Expected an argument, got ") - assert get_error(":has()") == ("Expected selector, got ") - assert get_error(":has(a b)") == ("Expected an argument, got ") - assert get_error(":has(a .b)") == ("Expected an argument, got ") + assert get_error(":has()") == ("Expected selector, got ") + assert get_error(":has(a,)") == ("Expected selector, got ") + assert get_error(":has(,a)") == ("Expected selector, got ") + assert get_error(":has(> )") == ("Expected selector, got ") + assert get_error(":has(a") == ("Expected ')', got ") + assert get_error(":has(a, b") == ("Expected ')', got ") # '-' is not a valid relative combinator - assert get_error(":has(- p)") == ("Expected an argument, got ") + assert get_error(":has(- p)") == ("Expected selector, got ") # Strings and numbers are not selectors - assert get_error(':has("a")') == ("Expected an argument, got ") - assert get_error(":has(1)") == ("Expected an argument, got ") + assert get_error(':has("a")') == ("Expected selector, got ") + assert get_error(":has(1)") == ("Expected selector, got ") + assert get_error(":has(a::before)") == ( + "Got pseudo-element ::before inside function" + ) # Whitespace around a :has() argument is not a combinator assert get_error("e:has(f )") is None assert get_error("e:has( > f )") is None assert get_error(":has(.a )") is None + assert get_error(":has(a b)") is None + assert get_error(":has(a, b)") is None def test_translation(self) -> None: def xpath(css: str) -> str: @@ -720,10 +744,7 @@ def xpath(css: str) -> str: assert xpath("e:has(> f)") == "e[./f]" assert xpath("e:has(f)") == "e[descendant::f]" assert xpath("e:has(~ f)") == "e[following-sibling::f]" - assert ( - xpath("e:has(+ f)") - == "e[following-sibling::*[(self::f) and (position() = 1)]]" - ) + assert xpath("e:has(+ f)") == "e[following-sibling::*[1]/self::f]" assert xpath("e:has(> f.bar)") == ( "e[./f[@class and contains(" "concat(' ', normalize-space(@class), ' '), ' bar ')]]" @@ -737,28 +758,51 @@ def xpath(css: str) -> str: "concat(' ', normalize-space(@class), ' '), ' bar ')]]" ) assert xpath("e:has(+ f.bar)") == ( - "e[following-sibling::*[((@class and contains(" - "concat(' ', normalize-space(@class), ' '), ' bar ')) " - "and (self::f)) and (position() = 1)]]" + "e[following-sibling::*[1]/self::f[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')]]" ) assert xpath("e:has(+ .bar)") == ( - "e[following-sibling::*[(@class and contains(" - "concat(' ', normalize-space(@class), ' '), ' bar ')) " - "and (position() = 1)]]" + "e[following-sibling::*[1]/self::*[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')]]" ) - assert xpath("e:has(+ *)") == "e[following-sibling::*[position() = 1]]" + assert xpath("e:has(+ *)") == "e[following-sibling::*[1]/self::*]" assert xpath("e.foo:has(f)") == ( "e[(@class and contains(" "concat(' ', normalize-space(@class), ' '), ' foo ')) and (descendant::f)]" ) + # Combinators inside a :has() argument + assert xpath("e:has(f g)") == "e[descendant::f/descendant-or-self::*/g]" + assert xpath("e:has(> f > g)") == "e[./f/g]" + assert xpath("e:has(~ f + g)") == ( + "e[following-sibling::f/following-sibling::*" + "[(self::g) and (position() = 1)]]" + ) + # A leading "+" applies to the first step of the argument, so that any + # further step is walked from the adjacent sibling. + assert xpath("e:has(+ f g)") == ( + "e[following-sibling::*[1]/self::f/descendant-or-self::*/g]" + ) + # A relative selector list matches if any of its arguments does + assert xpath("e:has(f, > g)") == "e[(descendant::f) or (./g)]" + assert xpath("e.foo:has(f, g)") == ( + "e[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')) " + "and ((descendant::f) or (descendant::g))]" + ) + # Compound and functional selectors inside :has() + assert xpath("e:has(#f)") == "e[descendant::*[@id = 'f']]" + assert xpath("e:has([bar])") == "e[descendant::*[@bar]]" + assert xpath("e:has(:not(f))") == "e[descendant::*[not(self::f)]]" + assert xpath("e:has(f:nth-child(2))") == ( + "e[descendant::f[count(preceding-sibling::*) = 1]]" + ) # Negating :has(): the relative selector must be kept as a predicate, # not turned into a literal name test. assert xpath("e:not(:has(a))") == "e[not(descendant::a)]" assert xpath("e:not(:has(> a))") == "e[not(./a)]" assert xpath("e:not(:has(~ a))") == "e[not(following-sibling::a)]" - assert xpath("e:not(:has(+ a))") == ( - "e[not(following-sibling::*[(self::a) and (position() = 1)])]" - ) + assert xpath("e:not(:has(+ a))") == "e[not(following-sibling::*[1]/self::a)]" + assert xpath("e:not(:has(a, > b))") == "e[not((descendant::a) or (./b))]" # A complex selector in :not() is matched against the element itself, # walking combinators through reverse axes. assert xpath("e:not(a b)") == "e[not(self::b and ancestor::*[self::a])]" @@ -1473,6 +1517,19 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: "fifth-li", "sixth-li", ] + # A relative selector list matches if any of its arguments does + assert pcss("ol:has(#li-div, #missing)") == ["first-ol"] + assert pcss("li:has(> div, + li.c)") == ["second-li", "third-li"] + assert pcss("ol:has(> li.c, > li[lang])") == ["first-ol"] + assert pcss("ol:has(#missing, > #missing)") == [] + # Combinators inside a :has() argument + assert pcss("div:has(ol li.c)") == ["outer-div"] + assert pcss("div:has(> ol > li.c)") == ["outer-div"] + assert pcss("li:has(+ li.c ~ li)") == ["second-li", "third-li"] + # Compound and functional selectors inside :has() + assert pcss("ol:has(li[lang])") == ["first-ol"] + assert pcss("ol:has(> #third-li)") == ["first-ol"] + assert pcss("p:has(:not(b))") == ["paragraph"] assert pcss("ol:not(:has(div))") == ["second-ol"] assert pcss("ol:not(:has(> div))") == ["first-ol", "second-ol"] assert pcss("li:not(:has(div))") == [