diff --git a/cssselect/parser.py b/cssselect/parser.py index 6d06ea4..e96a33a 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -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. @@ -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. @@ -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 @@ -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]: @@ -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]: @@ -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() @@ -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() @@ -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( @@ -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() @@ -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] @@ -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)) @@ -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 diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 8ef5d7c..ea754f7 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -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})") @@ -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.""" diff --git a/docs/index.rst b/docs/index.rst index 0f331ab..4a7fa89 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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: diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 8e1b2a7..196f2bd 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -36,7 +36,6 @@ from cssselect.parser import ( Function, FunctionalPseudoElement, - Matching, PseudoElement, Token, parse_series, @@ -175,6 +174,14 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("div:not(a ~ b)") == [ "Negation[Element[div]:not(CombinedSelector[Element[a] ~ Element[b]])]" ] + assert parse_many("div:not(a b, a > .foo)") == [ + "Negation[Element[div]:not(" + "CombinedSelector[Element[a] Element[b]]," + " CombinedSelector[Element[a] > Class[Element[*].foo]])]" + ] + assert parse_many("div:not(:not(a))") == [ + "Negation[Element[div]:not(Negation[Element[*]:not(Element[a])])]" + ] assert parse_many("div:has(div.foo)") == [ "Relation[Element[div]:has(Selector[Class[Element[div].foo]])]" ] @@ -207,6 +214,15 @@ def parse_many(first: str, *others: str) -> list[str]: "SpecificityAdjustment[Element[*]:where(Class[Element[*].foo]," " Class[Element[*].bar])]" ] + assert parse_many("div:is(a b, a > .foo)") == [ + "Matching[Element[div]:is(" + "CombinedSelector[Element[a] Element[b]]," + " CombinedSelector[Element[a] > Class[Element[*].foo]])]" + ] + assert parse_many("div:is(:not(a b))") == [ + "Matching[Element[div]:is(Negation[Element[*]:not(" + "CombinedSelector[Element[a] Element[b]])])]" + ] assert parse_many("td ~ th") == ["CombinedSelector[Element[td] ~ Element[th]]"] assert parse_many(":scope > foo") == [ "CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]]" @@ -345,6 +361,9 @@ def specificity(css: str) -> tuple[int, int, int]: assert specificity(":not(#foo)") == (1, 0, 0) assert specificity(":not(foo bar)") == (0, 0, 2) assert specificity(":not(* > .foo)") == (0, 1, 0) + # The specificity of a selector list is that of its most specific + # selector + assert specificity(":not(foo, #bar)") == (1, 0, 0) assert specificity(":has(*)") == (0, 0, 0) assert specificity(":has(foo)") == (0, 0, 1) @@ -358,6 +377,8 @@ def specificity(css: str) -> tuple[int, int, int]: assert specificity("div:is(#a)") == (1, 0, 1) assert specificity("div:is(.f, .g)") == (0, 1, 1) assert specificity("div.e:is(.f)") == (0, 2, 1) + assert specificity(":is(a b, #x)") == (1, 0, 0) + assert specificity(":where(a b, #x)") == (0, 0, 0) assert specificity("div:where(.x)") == (0, 0, 1) assert specificity("div.e:where(.x)") == (0, 1, 1) @@ -407,6 +428,8 @@ def css2css(css: str, res: str | None = None) -> None: css2css(":not(*.foo bar)", ":not(.foo bar)") # a "*" that is a whole compound selector is kept css2css(":not(* > bar)") + css2css(":not(*.foo, * > foo)", ":not(.foo, * > foo)") + css2css(":not(:not(foo))") css2css(":has(*)") css2css(":has(foo)") css2css(":has(*.foo)", ":has(.foo)") @@ -423,6 +446,11 @@ def css2css(css: str, res: str | None = None) -> None: css2css("div:is(*)") css2css(":is(*, .foo)") css2css(":where(*)") + # combinators inside :is() are kept, and a leading universal selector + # is only redundant in a compound selector + css2css(":is(a b, a > *.foo)", ":is(a b, a > .foo)") + css2css(":is(*.foo, * > foo)", ":is(.foo, * > foo)") + css2css(":where(a \t\n b)", ":where(a b)") css2css("foo:empty") css2css("foo::before") css2css("foo:empty::before") @@ -533,46 +561,38 @@ def get_error(css: str) -> str | None: "Got pseudo-element ::before not at the end of a selector" ) assert get_error(":not(:before)") == ( - "Got pseudo-element ::before inside :not() at 12" + "Got pseudo-element ::before inside function" ) - assert get_error(":not(:not(a))") == ("Got nested :not()") assert get_error(":not(a > :before)") == ( - "Got pseudo-element ::before inside :not() at 16" + "Got pseudo-element ::before inside function" ) - # :not() only takes a single complex selector as an argument - assert get_error(":not(a, b)") == ("Expected ')', got ") assert get_error(":not(a") == ("Expected ')', got ") assert get_error(":not(a >") == ("Expected selector, got ") + assert get_error(":not(a,") == ("Expected selector, got ") + assert get_error(":not(a,)") == ("Expected selector, got ") # Whitespace around a :not() argument is not a combinator assert get_error(":not(a )") is None assert get_error(":not( a )") is None assert get_error("e:not(.a )") is None assert get_error(":not([a] )") is None - # A :not() inside :is()/:where()/:matches() is not a nested :not() - # and gets its own message - assert get_error(":is(:not(a))") == ( - ":not() is not supported inside :is(), :where() and :matches()" - ) - assert get_error(":where(:not(a))") == ( - ":not() is not supported inside :is(), :where() and :matches()" - ) - assert get_error(":matches(:not(a))") == ( - ":not() is not supported inside :is(), :where() and :matches()" - ) assert get_error(":is(:before)") == ( "Got pseudo-element ::before inside function" ) - assert get_error(":is(a b)") == ("Expected an argument, got ") - assert get_error(":where(:before)") == ( + assert get_error(":is(a::before)") == ( "Got pseudo-element ::before inside function" ) - assert get_error(":where(a b)") == ( - "Expected an argument, got " + assert get_error(":where(:before)") == ( + "Got pseudo-element ::before inside function" ) - assert get_error(":is(a") == ("Expected an argument, got ") + assert get_error(":is(a") == ("Expected ')', got ") + assert get_error(":is(a b") == ("Expected ')', got ") + assert get_error(":is(a >") == ("Expected selector, got ") assert get_error(":is(a,") == ("Expected selector, got ") assert get_error(":is(a,)") == ("Expected selector, got ") - assert get_error(":where(a") == ("Expected an argument, got ") + assert get_error(":where(a") == ("Expected ')', got ") + # Whitespace around an :is() argument is not a combinator + assert get_error(":is( a )") is None + assert get_error(":is( a , b )") is None assert get_error(":scope > div :scope header") == ( 'Got pseudo-class ":scope" not at the start of a selector' ) @@ -783,6 +803,15 @@ def xpath(css: str) -> str: assert xpath("e:not(a:has(> b) c)") == ( "e[not(self::c and ancestor::*[(./b) and (self::a)])]" ) + # A selector list argument matches if any of its selectors does + assert xpath("e:not(foo, bar)") == "e[not((self::foo) or (self::bar))]" + assert xpath("e:not(a > b, c ~ d)") == ( + "e[not((self::b and parent::*[self::a]) or " + "(self::d and preceding-sibling::*[self::c]))]" + ) + # A selector matching every element makes :not() match none + assert xpath("e:not(*, .foo)") == "e[0]" + assert xpath("e:not(:not(a))") == "e[not(not(self::a))]" # Element-reading pseudo-classes chained after :has() assert xpath("e:has(f):first-of-type") == ( "e[(descendant::f) and (count(preceding-sibling::e) = 0)]" @@ -840,6 +869,15 @@ def xpath(css: str) -> str: ) assert xpath("e:is(:has(f))") == "e[descendant::f]" assert xpath("e:where(:has(f))") == "e[descendant::f]" + # A complex selector argument is matched against the element itself, + # walking combinators through reverse axes. + assert xpath("e:is(a b)") == "e[self::b and ancestor::*[self::a]]" + assert xpath("e:is(a > b, c ~ d)") == ( + "e[(self::b and parent::*[self::a]) or " + "(self::d and preceding-sibling::*[self::c])]" + ) + assert xpath("e:is(* > b)") == "e[self::b and parent::*]" + assert xpath("e:is(:not(a b))") == "e[not(self::b and ancestor::*[self::a])]" # :matches() is an alias of :is() assert xpath("e:matches(foo, bar)") == "e[(self::foo) or (self::bar)]" assert xpath("e:matches(.a, .b)") == xpath("e:is(.a, .b)") @@ -950,19 +988,6 @@ class LowerValues(GenericTranslator): assert LowerValues().css_to_xpath("[Foo=BAR]", prefix="") == "*[@Foo = 'bar']" - # A member of an :is()/:where() selector list that translates to a - # path (rather than a predicate) cannot be embedded in the outer - # predicate and is rejected. The parser does not currently produce - # such an argument, so build the Matching node directly. - base = parse("x")[0].parsed_tree - combined = parse("a b")[0].parsed_tree - matching = Matching(base, [combined]) - with pytest.raises( - ExpressionError, - match=r"not supported inside :is\(\), :where\(\) and :matches\(\)", - ): - GenericTranslator().xpath(matching) - def test_add_name_test(self) -> None: # Directly exercise XPathExpr.add_name_test(), part of the # customization API: translation never feeds it a name unusable in @@ -1500,6 +1525,8 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: "seventh-li", ] assert pcss("li:not(#second-li ~ li)") == ["first-li", "second-li"] + assert pcss("li:not(#second-li ~ li, #first-li)") == ["second-li"] + assert pcss("li:not(:not(.c))") == ["third-li", "fourth-li"] assert pcss("li:has(div):nth-of-type(2)") == ["second-li"] assert pcss("li:has(div):first-of-type") == [] assert pcss("ol:has(li):first-of-type") == ["first-ol"] @@ -1521,6 +1548,12 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: ] assert pcss("ol.a:is(.nonexistent)") == [] assert pcss("ol.a:is(.b, .nonexistent)") == ["first-ol"] + assert pcss("li:is(ol.a > li.c, #second-li + li)") == [ + "third-li", + "fourth-li", + ] + assert pcss("div:is(#outer-div div)") == ["li-div"] + assert pcss("li:is(:not(#second-li ~ li))") == ["first-li", "second-li"] assert pcss("ol.a.b.c > li.c:nth-child(3)") == ["third-li"] # Invalid characters in XPath element names, should not crash