Skip to content

Commit

Permalink
Implement rudimentary support for the relational pseudo-class :has()
Browse files Browse the repository at this point in the history
  • Loading branch information
Gallaecio committed Sep 25, 2019
1 parent 518e3e1 commit 12320dc
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 12 deletions.
63 changes: 54 additions & 9 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,30 @@ def specificity(self):
return a1 + a2, b1 + b2, c1 + c2


class Relation(object):
"""
Represents selector:has(subselector)
"""
def __init__(self, selector, subselector):
self.selector = selector
self.subselector = subselector

def __repr__(self):
return '%s[%r:has(%r)]' % (
self.__class__.__name__, self.selector, self.subselector)

def canonical(self):
subsel = self.subselector.canonical()
if len(subsel) > 1:
subsel = subsel.lstrip('*')
return '%s:has(%s)' % (self.selector.canonical(), subsel)

def specificity(self):
a1, b1, c1 = self.selector.specificity()
a2, b2, c2 = self.subselector.specificity()
return a1 + a2, b1 + b2, c1 + c2


class Attrib(object):
"""
Represents selector[namespace|attrib operator value]
Expand Down Expand Up @@ -456,7 +480,7 @@ def parse_selector(stream):
return result, pseudo_element


def parse_simple_selector(stream, inside_negation=False):
def parse_simple_selector(stream, nestable=True):
stream.skip_whitespace()
selector_start = len(stream.used)
peek = stream.peek()
Expand All @@ -479,7 +503,7 @@ def parse_simple_selector(stream, inside_negation=False):
while 1:
peek = stream.peek()
if peek.type in ('S', 'EOF') or peek.is_delim(',', '+', '>', '~') or (
inside_negation and peek == ('DELIM', ')')):
not nestable and peek == ('DELIM', ')')):
break
if pseudo_element:
raise SelectorSyntaxError(
Expand Down Expand Up @@ -507,7 +531,8 @@ def parse_simple_selector(stream, inside_negation=False):
pseudo_element, parse_arguments(stream))
continue
ident = stream.next_ident()
if ident.lower() in ('first-line', 'first-letter',
lowercase_indent = ident.lower()
if lowercase_indent in ('first-line', 'first-letter',
'before', 'after'):
# Special case: CSS 2.1 pseudo-elements can have a single ':'
# Any new pseudo-element must have two.
Expand All @@ -523,13 +548,16 @@ def parse_simple_selector(stream, inside_negation=False):
'Got immediate child pseudo-element ":scope" '
'not at the start of a selector')
continue

stream.next()
stream.skip_whitespace()
if ident.lower() == 'not':
if inside_negation:
raise SelectorSyntaxError('Got nested :not()')

if lowercase_indent == 'not':
if not nestable:
raise SelectorSyntaxError(
'Got :not() within :has() or another :not()')
argument, argument_pseudo_element = parse_simple_selector(
stream, inside_negation=True)
stream, nestable=False)
next = stream.next()
if argument_pseudo_element:
raise SelectorSyntaxError(
Expand All @@ -538,8 +566,25 @@ def parse_simple_selector(stream, inside_negation=False):
if next != ('DELIM', ')'):
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
result = Negation(result, argument)
else:
result = Function(result, ident, parse_arguments(stream))
continue

if lowercase_indent == 'has':
if not nestable:
raise SelectorSyntaxError(
'Got :has() within :not() or another :has()')
argument, argument_pseudo_element = parse_simple_selector(
stream, nestable=False)
next = stream.next()
if argument_pseudo_element:
raise SelectorSyntaxError(
'Got pseudo-element ::%s inside :has() at %s'
% (argument_pseudo_element, next.pos))
if next != ('DELIM', ')'):
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
result = Relation(result, argument)
continue

result = Function(result, ident, parse_arguments(stream))
else:
raise SelectorSyntaxError(
"Expected selector, got %s" % (peek,))
Expand Down
8 changes: 8 additions & 0 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ def xpath_negation(self, negation):
else:
return xpath.add_condition('0')

def xpath_relation(self, relation):
xpath = self.xpath(relation.selector)
sub_xpath = self.xpath(relation.subselector)
if sub_xpath.condition:
return xpath.add_condition('%s' % sub_xpath.condition)
else:
return xpath.add_condition('*')

def xpath_function(self, function):
"""Translate a functional pseudo-class."""
method = 'xpath_%s_function' % function.name.replace('-', '_')
Expand Down
35 changes: 33 additions & 2 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ def parse_many(first, *others):
'Hash[Element[div]#foobar]']
assert parse_many('div:not(div.foo)') == [
'Negation[Element[div]:not(Class[Element[div].foo])]']
assert parse_many('div:has(div.foo)') == [
'Relation[Element[div]:has(Class[Element[div].foo])]']
assert parse_many('td ~ th') == [
'CombinedSelector[Element[td] ~ Element[th]]']
assert parse_many(':scope > foo') == [
Expand Down Expand Up @@ -266,6 +268,13 @@ def specificity(css):
assert specificity(':not(:empty)') == (0, 1, 0)
assert specificity(':not(#foo)') == (1, 0, 0)

assert specificity(':has(*)') == (0, 0, 0)
assert specificity(':has(foo)') == (0, 0, 1)
assert specificity(':has(.foo)') == (0, 1, 0)
assert specificity(':has([foo])') == (0, 1, 0)
assert specificity(':has(:empty)') == (0, 1, 0)
assert specificity(':has(#foo)') == (1, 0, 0)

assert specificity('foo:empty') == (0, 1, 1)
assert specificity('foo:before') == (0, 0, 2)
assert specificity('foo::before') == (0, 0, 2)
Expand Down Expand Up @@ -300,6 +309,12 @@ def css2css(css, res=None):
css2css(':not(*[foo])', ':not([foo])')
css2css(':not(:empty)')
css2css(':not(#foo)')
css2css(':has(*)')
css2css(':has(foo)')
css2css(':has(*.foo)', ':has(.foo)')
css2css(':has(*[foo])', ':has([foo])')
css2css(':has(:empty)')
css2css(':has(#foo)')
css2css('foo:empty')
css2css('foo::before')
css2css('foo:empty::before')
Expand Down Expand Up @@ -371,8 +386,8 @@ def get_error(css):
"Got pseudo-element ::before not at the end of a selector")
assert get_error(':not(:before)') == (
"Got pseudo-element ::before inside :not() at 12")
assert get_error(':not(:not(a))') == (
"Got nested :not()")
assert get_error(':has(:before)') == (
"Got pseudo-element ::before inside :has() at 12")
assert get_error(':scope > div :scope header') == (
'Got immediate child pseudo-element ":scope" not at the start of a selector'
)
Expand All @@ -381,6 +396,16 @@ def get_error(css):
)
assert get_error('> div p') == ("Expected selector, got <DELIM '>' at 0>")

# Unsupported nesting
assert get_error(':has(:has(a))') == (
'Got :has() within :not() or another :has()')
assert get_error(':has(:not(a))') == (
'Got :not() within :has() or another :not()')
assert get_error(':not(:has(a))') == (
'Got :has() within :not() or another :has()')
assert get_error(':not(:not(a))') == (
'Got :not() within :has() or another :not()')

def test_translation(self):
def xpath(css):
return _unicode(GenericTranslator().css_to_xpath(css, prefix=''))
Expand Down Expand Up @@ -490,6 +515,8 @@ def xpath(css):
"e[@id = 'myid']")
assert xpath('e:not(:nth-child(odd))') == (
"e[not(count(preceding-sibling::*) mod 2 = 0)]")
assert xpath('e:has(:nth-child(odd))') == (
"e[count(preceding-sibling::*) mod 2 = 0]")
assert xpath('e:nOT(*)') == (
"e[0]") # never matches
assert xpath('e f') == (
Expand Down Expand Up @@ -839,6 +866,9 @@ def pcss(main, *selectors, **kwargs):
assert pcss('ol :Not(li[class])') == [
'first-li', 'second-li', 'li-div',
'fifth-li', 'sixth-li', 'seventh-li']
assert pcss('link:has(*)') == []
assert pcss('link:has([href])') == ['link-href']
assert pcss('ol:has(div)') == ['first-ol']
assert pcss('ol.a.b.c > li.c:nth-child(3)') == ['third-li']

# Invalid characters in XPath element names, should not crash
Expand Down Expand Up @@ -935,6 +965,7 @@ def count(selector):
assert count(':scope > div > div[class=dialog]') == 1
assert count(':scope > div div') == 242


XMLLANG_IDS = '''
<test>
<a id="first" xml:lang="en">a</a>
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ deps=
-r tests/requirements.txt

commands =
py.test --cov-report term --cov=cssselect
py.test --cov-report term --cov=cssselect {posargs:tests}

0 comments on commit 12320dc

Please sign in to comment.