diff --git a/cssselect/parser.py b/cssselect/parser.py index f969769..9966d07 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -574,7 +574,9 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]: def parse_simple_selector( - stream: TokenStream, inside_negation: bool = False + stream: TokenStream, + inside_negation: bool = False, + inside_selector_list: bool = False, ) -> tuple[Tree, PseudoElement | None]: stream.skip_whitespace() selector_start = len(stream.used) @@ -613,8 +615,12 @@ def parse_simple_selector( stream.next() 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. + if len(stream.used) != selector_start: + raise SelectorSyntaxError(f"Expected selector, got {peek}") stream.next() - result = Element(None, stream.next_ident()) + result = Element(None, stream.next_ident_or_star()) elif peek == ("DELIM", "["): stream.next() result = parse_attrib(result, stream) @@ -637,24 +643,30 @@ def parse_simple_selector( continue if stream.peek() != ("DELIM", "("): result = Pseudo(result, ident) - if repr(result) == "Pseudo[Element[*]:scope]" and not ( - len(stream.used) == 2 - or (len(stream.used) == 3 and stream.used[0].type == "S") - or (len(stream.used) >= 3 and stream.used[-3].is_delim(",")) - or ( - len(stream.used) >= 4 - and stream.used[-3].type == "S" - and stream.used[-4].is_delim(",") - ) - ): - raise SelectorSyntaxError( - 'Got immediate child pseudo-element ":scope" ' - "not at the start of a selector" - ) + 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. + preceding = stream.used[:selector_start] + while preceding and preceding[-1].type == "S": + preceding = preceding[:-1] + if inside_selector_list or ( + preceding and not preceding[-1].is_delim(",") + ): + raise SelectorSyntaxError( + 'Got pseudo-class ":scope" not at the start of a selector' + ) continue 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( @@ -703,36 +715,48 @@ 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]: # noqa: RET503 +def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: stream.skip_whitespace() - subselector = "" + subselector_tokens: list[Token] = [] next_ = stream.next() - if next_ in [("DELIM", "+"), ("DELIM", "-"), ("DELIM", ">"), ("DELIM", "~")]: + if next_ in [("DELIM", "+"), ("DELIM", ">"), ("DELIM", "~")]: combinator = next_ stream.skip_whitespace() next_ = stream.next() else: combinator = Token("DELIM", " ", pos=0) + seen_whitespace = False while 1: - if next_.type in ("IDENT", "STRING", "NUMBER") or next_ in [ - ("DELIM", "."), - ("DELIM", "*"), - ]: - subselector += cast("str", next_.value) + 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", ")"): - result = parse(subselector) - return combinator, result[0] + break 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) + def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments = [] while 1: - result, pseudo_element = parse_simple_selector(stream, True) + result, pseudo_element = parse_simple_selector( + stream, inside_negation=True, inside_selector_list=True + ) if pseudo_element: raise SelectorSyntaxError( f"Got pseudo-element ::{pseudo_element} inside function" @@ -806,7 +830,8 @@ def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: for token in tokens: if token.type == "STRING": raise ValueError("String tokens not allowed in series.") - s = "".join(cast("str", token.value) for token in tokens).strip() + # The An+B microsyntax is ASCII-case-insensitive: 2N+1, EVEN, Odd... + s = ascii_lower("".join(cast("str", token.value) for token in tokens).strip()) if s == "odd": return 2, 1 if s == "even": diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 96eac3f..8510a5d 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -633,7 +633,7 @@ def xpath_nth_last_of_type_function( self, xpath: XPathExpr, function: Function ) -> XPathExpr: if xpath.element == "*": - raise ExpressionError("*:nth-of-type() is not implemented") + raise ExpressionError("*:nth-last-of-type() is not implemented") return self.xpath_nth_child_function( xpath, function, last=True, add_name_test=False ) @@ -669,7 +669,8 @@ def xpath_root_pseudo(self, xpath: XPathExpr) -> XPathExpr: # for product in response.css('.product'): # description = product.css(':scope > div::text').get() def xpath_scope_pseudo(self, xpath: XPathExpr) -> XPathExpr: - return xpath.add_condition("1") + xpath.add_name_test() + return xpath.add_condition("position() = 1") def xpath_first_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition("count(preceding-sibling::*) = 0") diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..10315f7 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -91,6 +91,7 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("*|*") == ["Element[*]"] assert parse_many("*|foo") == ["Element[foo]"] assert parse_many("|foo") == ["Element[foo]"] + assert parse_many("|*") == ["Element[*]"] assert parse_many("foo|*") == ["Element[foo|*]"] assert parse_many("foo|bar") == ["Element[foo|bar]"] # This will never match, but it is valid: @@ -185,6 +186,9 @@ def parse_many(first: str, *others: str) -> list[str]: "CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > " "Hash[Element[*]#foo]] Hash[Element[*]#bar]]" ] + assert parse_many("*:scope") == ["Pseudo[Element[*]:scope]"] + assert parse_many("div:scope") == ["Pseudo[Element[div]:scope]"] + assert parse_many(".foo:scope") == ["Pseudo[Class[Element[*].foo]:scope]"] def test_pseudo_elements(self) -> None: def parse_pseudo(css: str) -> list[tuple[str, str | None]]: @@ -384,6 +388,9 @@ def get_error(css: str) -> str | None: assert get_error("div > ") == ("Expected selector, got ") assert get_error(" > div") == ("Expected selector, got ' at 2>") assert get_error("foo|#bar") == ("Expected ident or '*', got ") + assert get_error(".foo|bar") == ("Expected selector, got ") + assert get_error("#bar|foo") == ("Expected selector, got ") + assert get_error("[baz]|foo") == ("Expected selector, got ") assert get_error("#.foo") == ("Expected selector, got ") assert get_error(".#foo") == ("Expected ident, got ") assert get_error(":#foo") == ("Expected ident, got ") @@ -415,6 +422,17 @@ def get_error(css: str) -> str | None: "Got pseudo-element ::before inside :not() at 12" ) assert get_error(":not(:not(a))") == ("Got nested :not()") + # 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" ) @@ -426,16 +444,53 @@ def get_error(css: str) -> str | None: "Expected an argument, got " ) assert get_error(":scope > div :scope header") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("div :scope header") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error("a div:scope") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error("a > .foo:scope") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error("*:scope") is None + assert get_error("div:scope") is None + assert get_error("foo, *:scope") is None + # :scope is rejected in :is()/:where()/:matches() arguments too; + # a comma there separates arguments, not selectors. + assert get_error(":is(:scope)") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error(":is(a, :scope)") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error(":where(a, :scope)") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error(":matches(a, :scope)") == ( + 'Got pseudo-class ":scope" not at the start of a selector' + ) + assert get_error("foo, :is(a, :scope)") == ( + 'Got pseudo-class ":scope" not at the start of a selector' ) 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()") == ("Expected selector, got ") + assert get_error(":has(a b)") == ("Expected an argument, got ") + assert get_error(":has(a .b)") == ("Expected an argument, got ") + # '-' is not a valid relative combinator + assert get_error(":has(- p)") == ("Expected an argument, 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 ") + # 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 def test_translation(self) -> None: def xpath(css: str) -> str: @@ -563,6 +618,18 @@ def xpath(css: str) -> str: assert xpath(r"di\[v") == ("*[name() = 'di[v']") assert xpath(r"[h\a0 ref]") == ("*[attribute::*[name() = 'h ref']]") # h\xa0ref assert xpath(r"[h\]ref]") == ("*[attribute::*[name() = 'h]ref']]") + # Escaped identifiers survive inside :has() arguments + assert xpath(r"e:has(di\[v)") == "e[descendant::*[name() = 'di[v']]" + + # :scope, alone and in a compound selector + assert xpath(":scope") == "*[position() = 1]" + assert xpath("*:scope") == "*[position() = 1]" + assert xpath("div:scope") == "*[(name() = 'div') and (position() = 1)]" + assert xpath(".foo:scope") == ( + "*[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')) " + "and (position() = 1)]" + ) with pytest.raises(ExpressionError): xpath(":fİrst-child") @@ -572,9 +639,9 @@ def xpath(css: str) -> str: xpath(":only-of-type") with pytest.raises(ExpressionError): xpath(":last-of-type") - with pytest.raises(ExpressionError): + with pytest.raises(ExpressionError, match=r"\*:nth-of-type\(\)"): xpath(":nth-of-type(1)") - with pytest.raises(ExpressionError): + with pytest.raises(ExpressionError, match=r"\*:nth-last-of-type\(\)"): xpath(":nth-last-of-type(1)") with pytest.raises(ExpressionError): xpath(":nth-child(n-)") @@ -616,7 +683,7 @@ def test_quoting(self) -> None: '''descendant-or-self::*[@aval = '"""']''' ) assert css_to_xpath(':scope > div[dataimg=""]') == ( - "descendant-or-self::*[1]/div[@dataimg = '']" + "descendant-or-self::*[position() = 1]/div[@dataimg = '']" ) def test_unicode_escapes(self) -> None: @@ -726,7 +793,7 @@ def xpath(css: str) -> str: assert xpath("p img::attr(src)") == ( "descendant-or-self::p/descendant-or-self::*/img/@src" ) - assert xpath(":scope") == "descendant-or-self::*[1]" + assert xpath(":scope") == "descendant-or-self::*[position() = 1]" assert xpath(":first-or-second[href]") == ( "descendant-or-self::*[(@id = 'first' or @id = 'second') and (@href)]" ) @@ -775,6 +842,12 @@ def series(css: str) -> tuple[int, int] | None: assert series("5") == (0, 5) assert series("foo") is None assert series("n+") is None + # ASCII-case-insensitive + assert series("2N+1") == (2, 1) + assert series("EVEN") == (2, 0) + assert series("Odd") == (2, 1) + assert series("N") == (1, 0) + assert series("-N+3") == (-1, 3) def test_lang(self) -> None: document = etree.fromstring(XMLLANG_IDS) @@ -903,6 +976,10 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: assert pcss(":scope body > div") == ["outer-div", "foobar-div"] assert pcss(":scope head") == ["nil"] assert pcss(":scope html") == [] + # Compound :scope matches the scope root only if the rest of the + # compound selector matches it too + assert pcss("html:scope") == ["html"] + assert pcss("div:scope") == [] # --- nth-* and nth-last-* -------------------------------------