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
121 changes: 92 additions & 29 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,14 @@ def canonical(self) -> str:
if isinstance(self.pseudo_element, FunctionalPseudoElement):
pseudo_element = f"::{self.pseudo_element.canonical()}"
elif self.pseudo_element:
pseudo_element = f"::{self.pseudo_element}"
pseudo_element = f"::{_serialize_ident(self.pseudo_element)}"
else:
pseudo_element = ""
res = f"{self.parsed_tree.canonical()}{pseudo_element}"
if len(res) > 1:
res = res.lstrip("*")
# 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

def specificity(self) -> tuple[int, int, int]:
Expand Down Expand Up @@ -148,7 +150,7 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}.{self.class_name}]"

def canonical(self) -> str:
return f"{self.selector.canonical()}.{self.class_name}"
return f"{self.selector.canonical()}.{_serialize_ident(self.class_name)}"

def specificity(self) -> tuple[int, int, int]:
a, b, c = self.selector.specificity()
Expand Down Expand Up @@ -187,7 +189,7 @@ def argument_types(self) -> list[str]:

def canonical(self) -> str:
args = "".join(token.css() for token in self.arguments)
return f"{self.name}({args})"
return f"{_serialize_ident(self.name)}({args})"


class Function:
Expand All @@ -209,7 +211,7 @@ def argument_types(self) -> list[str]:

def canonical(self) -> str:
args = "".join(token.css() for token in self.arguments)
return f"{self.selector.canonical()}:{self.name}({args})"
return f"{self.selector.canonical()}:{_serialize_ident(self.name)}({args})"

def specificity(self) -> tuple[int, int, int]:
a, b, c = self.selector.specificity()
Expand All @@ -230,7 +232,7 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}:{self.ident}]"

def canonical(self) -> str:
return f"{self.selector.canonical()}:{self.ident}"
return f"{self.selector.canonical()}:{_serialize_ident(self.ident)}"

def specificity(self) -> tuple[int, int, int]:
a, b, c = self.selector.specificity()
Expand Down Expand Up @@ -272,24 +274,27 @@ def __init__(self, selector: Tree, combinator: Token, subselector: 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} "

def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}:has({self.subselector!r})]"
return (
f"{self.__class__.__name__}[{self.selector!r}"
f":has({self._combinator_prefix()}{self.subselector!r})]"
)

def canonical(self) -> str:
try:
subsel = self.subselector[0].canonical() # type: ignore[index]
except TypeError:
subsel = self.subselector.canonical()
subsel = self.subselector.canonical()
if len(subsel) > 1:
subsel = subsel.lstrip("*")
return f"{self.selector.canonical()}:has({subsel})"
return f"{self.selector.canonical()}:has({self._combinator_prefix()}{subsel})"

def specificity(self) -> tuple[int, int, int]:
a1, b1, c1 = self.selector.specificity()
try:
a2, b2, c2 = self.subselector[-1].specificity() # type: ignore[index]
except TypeError:
a2, b2, c2 = self.subselector.specificity()
a2, b2, c2 = self.subselector.specificity()
return a1 + a2, b1 + b2, c1 + c2


Expand All @@ -310,12 +315,16 @@ def canonical(self) -> str:
selector_arguments = []
for s in self.selector_list:
selarg = s.canonical()
selector_arguments.append(selarg.lstrip("*"))
args_str = ", ".join(str(s) for s in selector_arguments)
if len(selarg) > 1:
selarg = selarg.lstrip("*")
selector_arguments.append(selarg)
args_str = ", ".join(selector_arguments)
return f"{self.selector.canonical()}:is({args_str})"

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


class SpecificityAdjustment:
Expand All @@ -336,12 +345,16 @@ def canonical(self) -> str:
selector_arguments = []
for s in self.selector_list:
selarg = s.canonical()
selector_arguments.append(selarg.lstrip("*"))
args_str = ", ".join(str(s) for s in selector_arguments)
if len(selarg) > 1:
selarg = selarg.lstrip("*")
selector_arguments.append(selarg)
args_str = ", ".join(selector_arguments)
return f"{self.selector.canonical()}:where({args_str})"

def specificity(self) -> tuple[int, int, int]:
return 0, 0, 0
# :where() itself contributes no specificity, but the compound
# selector it applies to does.
return self.selector.specificity()


class Attrib:
Expand Down Expand Up @@ -391,7 +404,9 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}[{attrib} {self.operator} {self.value.value!r}]]"

def canonical(self) -> str:
attrib = f"{self.namespace}|{self.attrib}" if self.namespace else self.attrib
attrib = _serialize_ident(self.attrib)
if self.namespace:
attrib = f"{_serialize_ident(self.namespace)}|{attrib}"

if self.operator == "exists":
op = attrib
Expand Down Expand Up @@ -425,9 +440,9 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.canonical()}]"

def canonical(self) -> str:
element = self.element or "*"
element = _serialize_ident(self.element) if self.element else "*"
if self.namespace:
element = f"{self.namespace}|{element}"
element = f"{_serialize_ident(self.namespace)}|{element}"
return element

def specificity(self) -> tuple[int, int, int]:
Expand All @@ -449,7 +464,7 @@ def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.selector!r}#{self.id}]"

def canonical(self) -> str:
return f"{self.selector.canonical()}#{self.id}"
return f"{self.selector.canonical()}#{_serialize_ident(self.id)}"

def specificity(self) -> tuple[int, int, int]:
a, b, c = self.selector.specificity()
Expand All @@ -474,7 +489,8 @@ def canonical(self) -> str:
subsel = self.subselector.canonical()
if len(subsel) > 1:
subsel = subsel.lstrip("*")
return f"{self.selector.canonical()} {self.combinator} {subsel}"
combinator = " " if self.combinator == " " else f" {self.combinator} "
return f"{self.selector.canonical()}{combinator}{subsel}"

def specificity(self) -> tuple[int, int, int]:
a1, b1, c1 = self.selector.specificity()
Expand Down Expand Up @@ -866,7 +882,13 @@ def value(self) -> str | None:

def css(self) -> str:
if self.type == "STRING":
return repr(self.value)
# Escape as CSS (repr() would use Python escapes, which mean
# something else in CSS, e.g. '\n' is just the letter 'n').
escaped = cast("str", self.value).replace("\\", "\\\\").replace("'", "\\'")
escaped = _sub_string_control_char(_replace_string_control_char, escaped)
return f"'{escaped}'"
if self.type == "IDENT":
return _serialize_ident(cast("str", self.value))
return cast("str", self.value)


Expand Down Expand Up @@ -912,6 +934,7 @@ def _compile(pattern: str) -> MatchFunc:
_sub_simple_escape = re.compile(r"\\(.)").sub
_sub_unicode_escape = re.compile(TokenMacros.unicode_escape, re.IGNORECASE).sub
_sub_newline_escape = re.compile(r"\\(?:\n|\r\n|\r|\f)").sub
_sub_string_control_char = re.compile(r"[\x00-\x1f\x7f]").sub

# Same as r'\1', but faster on CPython
_replace_simple = operator.methodcaller("group", 1)
Expand All @@ -924,11 +947,51 @@ def _replace_unicode(match: re.Match[str]) -> str:
return chr(codepoint)


def _replace_string_control_char(match: re.Match[str]) -> str:
# The trailing space ends the escape sequence, in case the next
# character is a hexadecimal digit.
return f"\\{ord(match.group()):x} "


def unescape_ident(value: str) -> str:
value = _sub_unicode_escape(_replace_unicode, value)
return _sub_simple_escape(_replace_simple, value)


def _serialize_ident(value: str) -> str:
"""Serialize a string as a CSS identifier, escaping special characters.

Implements the CSSOM "serialize an identifier" algorithm:
https://drafts.csswg.org/cssom/#serialize-an-identifier
"""
result = []
for i, char in enumerate(value):
code = ord(char)
serialized = char
if code == 0:
serialized = "\N{REPLACEMENT CHARACTER}"
elif code <= 0x1F or code == 0x7F:
serialized = f"\\{code:x} "
elif "0" <= char <= "9":
if i == 0 or (i == 1 and value[0] == "-"):
# An identifier cannot start with a digit
# (or a '-' followed by a digit).
serialized = f"\\{code:x} "
elif char == "-":
if len(value) == 1 or (i == 0 and value[1] == "-"):
# CSSOM leaves a leading "--" unescaped (such identifiers
# are valid since CSS Syntax 3), but the tokenizer only
# implements the CSS 2.1 identifier grammar and would not
# be able to parse the result, so escape the first "-".
serialized = "\\-"
elif not (
code >= 0x80 or char == "_" or "a" <= char <= "z" or "A" <= char <= "Z"
):
serialized = f"\\{char}"
result.append(serialized)
return "".join(result)


def tokenize(s: str) -> Iterator[Token]:
pos = 0
len_s = len(s)
Expand Down
68 changes: 67 additions & 1 deletion tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ 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(> div.foo)") == [
"Relation[Element[div]:has(> Selector[Class[Element[div].foo]])]"
]
assert parse_many("div:has(+ div.foo)") == [
"Relation[Element[div]:has(+ Selector[Class[Element[div].foo]])]"
]
assert parse_many("div:has(~ div.foo)") == [
"Relation[Element[div]:has(~ Selector[Class[Element[div].foo]])]"
]
assert parse_many("div:is(.foo, #bar)") == [
"Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]"
]
Expand Down Expand Up @@ -311,6 +320,12 @@ def specificity(css: str) -> tuple[int, int, int]:
assert specificity(":is(.foo, #bar)") == (1, 0, 0)
assert specificity(":is(:hover, :visited)") == (0, 1, 0)
assert specificity(":where(:hover, :visited)") == (0, 0, 0)
# The compound selector the pseudo-class applies to counts too
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("div:where(.x)") == (0, 0, 1)
assert specificity("div.e:where(.x)") == (0, 1, 1)

assert specificity("foo:empty") == (0, 1, 1)
assert specificity("foo:before") == (0, 0, 2)
Expand All @@ -327,7 +342,12 @@ def test_css_export(self) -> None:
def css2css(css: str, res: str | None = None) -> None:
selectors = parse(css)
assert len(selectors) == 1
assert selectors[0].canonical() == (res or css)
canonical = selectors[0].canonical()
assert canonical == (res or css)
# canonical() output must round-trip through the parser
reparsed = parse(canonical)
assert len(reparsed) == 1
assert reparsed[0].canonical() == canonical

css2css("*")
css2css(" foo", "foo")
Expand All @@ -352,15 +372,61 @@ def css2css(css: str, res: str | None = None) -> None:
css2css(":has(*)")
css2css(":has(foo)")
css2css(":has(*.foo)", ":has(.foo)")
# combinators inside :has() are kept
css2css(":has(> foo)")
css2css(":has(~ foo)")
css2css(":has(+ foo)")
css2css("div:has(> div.foo)")
css2css(":is(#bar, .foo)")
css2css(":is(:focused, :visited)")
css2css(":where(:focused, :visited)")
# a universal selector argument is kept
css2css(":is(*)")
css2css("div:is(*)")
css2css(":is(*, .foo)")
css2css(":where(*)")
css2css("foo:empty")
css2css("foo::before")
css2css("foo:empty::before")
css2css('::name(arg + "val" - 3)', "::name(arg+'val'-3)")
css2css("#lorem + foo#ipsum:first-child > bar::first-line")
css2css("foo > *")
# a leading universal selector is only redundant in a compound
# selector
css2css("* > foo")
css2css("* foo")
# a single space for the descendant combinator
css2css("div p")
css2css("div \t\n p", "div p")
# strings are escaped as CSS, not as Python literals
css2css(r'[foo="x\a y"]', r"[foo='x\a y']")
css2css(r'[foo="\\"]', r"[foo='\\']")
css2css('[foo="\'"]', "[foo='\\'']")
# identifiers are escaped as CSS on output
css2css(r"di\[v")
css2css(r"e.cl\@ss")
css2css(r".fo\.o")
css2css(r"#a\.b")
css2css(r"[h\]ref]")
css2css(r"[foo=ba\.r]")
css2css(r"n\.s|div")
css2css(r"\31 23") # an identifier cannot start with a bare digit
css2css(r"e\1 x") # control characters use hexadecimal escapes
css2css(r"di\5b v", r"di\[v") # hexadecimal escapes are canonicalized
css2css(r".\-") # an identifier consisting of a single "-" is escaped
css2css(r".-x") # an identifier just starting with "-" isn't escaped
css2css(r"e\0 x", "e\N{REPLACEMENT CHARACTER}x") # NUL becomes U+FFFD
# a leading "--" is escaped: the tokenizer cannot parse it unescaped
css2css(r".\--x")
css2css(r".\--")
css2css(r"e\--x", "e--x") # but a non-leading "--" needs no escape
# pseudo-class, functional pseudo-class and pseudo-element names
# are escaped too
css2css(r":fo\.o")
css2css(r":\31 23")
css2css(r":fo\.o(2)")
css2css(r"::fo\.o")
css2css(r"::fo\.o(2)")

def test_parse_errors(self) -> None:
def get_error(css: str) -> str | None:
Expand Down
Loading