Skip to content

Commit

Permalink
Support :has
Browse files Browse the repository at this point in the history
Related to #24.
  • Loading branch information
liZe committed Sep 16, 2022
1 parent d1421ff commit 9151996
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 12 deletions.
25 changes: 23 additions & 2 deletions cssselect2/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,37 @@ def _compile_node(selector):

elif isinstance(selector, parser.NegationSelector):
sub_expressions = [
expr for expr in map(_compile_node, selector.selector_list)
expr for expr in [
_compile_node(selector.parsed_tree)
for selector in selector.selector_list]
if expr != '1']
if not sub_expressions:
return '0'
return f'not ({" or ".join(f"({expr})" for expr in sub_expressions)})'

elif isinstance(selector, parser.RelationalSelector):
sub_expressions = []
for relative_selector in selector.selector_list:
expression = _compile_node(relative_selector.selector.parsed_tree)
if expression == '0':
continue
if relative_selector.combinator == ' ':
elements = 'list(el.iter_subtree())[1:]'
elif relative_selector.combinator == '>':
elements = 'el.iter_children()'
elif relative_selector.combinator == '+':
elements = 'list(el.iter_next_siblings())[:1]'
elif relative_selector.combinator == '~':
elements = 'el.iter_next_siblings()'
sub_expressions.append(f'(any({expression} for el in {elements}))')
return ' or '.join(sub_expressions)

elif isinstance(selector, (
parser.MatchesAnySelector, parser.SpecificityAdjustmentSelector)):
sub_expressions = [
expr for expr in map(_compile_node, selector.selector_list)
expr for expr in [
_compile_node(selector.parsed_tree)
for selector in selector.selector_list]
if expr != '0']
if not sub_expressions:
return '0'
Expand Down
70 changes: 60 additions & 10 deletions cssselect2/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
}


def parse(input, namespaces=None, forgiving=False):
def parse(input, namespaces=None, forgiving=False, relative=False):
"""Yield tinycss2 selectors found in given ``input``.
:param input:
Expand All @@ -32,7 +32,7 @@ def parse(input, namespaces=None, forgiving=False):
tokens = TokenStream(input)
namespaces = namespaces or {}
try:
yield parse_selector(tokens, namespaces)
yield parse_selector(tokens, namespaces, relative)
except SelectorError as exception:
if not forgiving:
raise exception
Expand All @@ -42,7 +42,7 @@ def parse(input, namespaces=None, forgiving=False):
return
elif next == ',':
try:
yield parse_selector(tokens, namespaces)
yield parse_selector(tokens, namespaces, relative)
except SelectorError as exception:
if not forgiving:
raise exception
Expand All @@ -51,25 +51,36 @@ def parse(input, namespaces=None, forgiving=False):
raise SelectorError(next, f'unexpected {next.type} token.')


def parse_selector(tokens, namespaces):
def parse_selector(tokens, namespaces, relative=False):
tokens.skip_whitespace_and_comment()
if relative:
peek = tokens.peek()
if peek in ('>', '+', '~'):
initial_combinator = peek.value
tokens.next()
else:
initial_combinator = ' '
tokens.skip_whitespace_and_comment()
result, pseudo_element = parse_compound_selector(tokens, namespaces)
while 1:
has_whitespace = tokens.skip_whitespace()
while tokens.skip_comment():
has_whitespace = tokens.skip_whitespace() or has_whitespace
selector = Selector(result, pseudo_element)
if relative:
selector = RelativeSelector(initial_combinator, selector)
if pseudo_element is not None:
return Selector(result, pseudo_element)
return selector
peek = tokens.peek()
if peek is None or peek == ',':
return Selector(result, pseudo_element)
return selector
elif peek in ('>', '+', '~'):
combinator = peek.value
tokens.next()
elif has_whitespace:
combinator = ' '
else:
return Selector(result, pseudo_element)
return selector
compound, pseudo_element = parse_compound_selector(tokens, namespaces)
result = CombinedSelector(result, combinator, compound)

Expand Down Expand Up @@ -147,7 +158,7 @@ def parse_simple_selector(tokens, namespaces):
return PseudoClassSelector(name), None
elif next is not None and next.type == 'function':
name = next.lower_name
if name in ('is', 'where', 'not'):
if name in ('is', 'where', 'not', 'has'):
return parse_logical_combination(next, namespaces, name), None
else:
return (
Expand All @@ -160,16 +171,21 @@ def parse_simple_selector(tokens, namespaces):

def parse_logical_combination(matches_any_token, namespaces, name):
forgiving = True
relative = False
if name == 'is':
selector_class = MatchesAnySelector
elif name == 'where':
selector_class = SpecificityAdjustmentSelector
elif name == 'not':
forgiving = False
selector_class = NegationSelector
elif name == 'has':
relative = True
selector_class = RelationalSelector

selectors = [
selector.parsed_tree for selector in
parse(matches_any_token.arguments, namespaces, forgiving=forgiving)
selector for selector in
parse(matches_any_token.arguments, namespaces, forgiving, relative)
if selector.pseudo_element is None]
return selector_class(selectors)

Expand Down Expand Up @@ -318,6 +334,25 @@ def __repr__(self):
return '%r::%s' % (self.parsed_tree, self.pseudo_element)


class RelativeSelector:
def __init__(self, combinator, selector):
self.combinator = combinator
self.selector = selector

@property
def specificity(self):
return self.selector.specificity

@property
def pseudo_element(self):
return self.selector.pseudo_element

def __repr__(self):
return (
f'{self.selector!r}' if self.combinator == ' '
else f'{self.combinator} {self.selector!r}')


class CombinedSelector:
def __init__(self, left, combinator, right):
#: Combined or compound selector
Expand Down Expand Up @@ -454,6 +489,21 @@ def __repr__(self):
return f':not({", ".join(repr(sel) for sel in self.selector_list)})'


class RelationalSelector:
def __init__(self, selector_list):
self.selector_list = selector_list

@property
def specificity(self):
if self.selector_list:
return max(selector.specificity for selector in self.selector_list)
else:
return (0, 0, 0)

def __repr__(self):
return f':has({", ".join(repr(sel) for sel in self.selector_list)})'


class MatchesAnySelector:
def __init__(self, selector_list):
self.selector_list = selector_list
Expand Down
12 changes: 12 additions & 0 deletions cssselect2/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ def iter_siblings(self):
else:
yield from self.parent.iter_children()

def iter_next_siblings(self):
"""Return an iterator of newly-created :class:`ElementWrapper` objects
for this element’s next siblings, in tree order.
"""
found = False
for sibling in self.iter_siblings():
if found:
yield sibling
if sibling == self:
found = True

def iter_children(self):
"""Return an iterator of newly-created :class:`ElementWrapper` objects
for this element’s child elements,
Expand Down
14 changes: 14 additions & 0 deletions tests/test_cssselect2.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,20 @@ def test_lang():
('input:where([type="text"])', ['text-checked']),
('div:where(:not(#outer-div))', ['li-div', 'foobar-div']),
('div:where(div::before)', []),
('p:has(input)', ['paragraph']),
('p:has(fieldset input)', ['paragraph']),
('p:has(> fieldset)', ['paragraph']),
('ol:has(> div)', []),
('ol:has(input, li)', ['first-ol']),
('ol:has(input, fieldset)', []),
('ol:has(+ p)', ['first-ol']),
('ol:has(~ ol)', ['first-ol']),
('ol:has(>a, ~ ol)', ['first-ol']),
('ol:has(a,ol, li )', ['first-ol']),
('ol:has(*)', ['first-ol']),
('ol:has(:not(li))', ['first-ol']),
('ol:has( > :not( li ))', []),
('ol:has(:not(li, div))', []),
# Invalid characters in XPath element names, should not crash
(r'di\a0 v', []),
Expand Down

0 comments on commit 9151996

Please sign in to comment.