diff --git a/cssselect/parser.py b/cssselect/parser.py index 7125030..08a87c3 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -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] @@ -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() @@ -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( @@ -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. @@ -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( @@ -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,)) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index db50c77..d8254e2 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -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('-', '_') diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 320736c..636803a 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -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') == [ @@ -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) @@ -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') @@ -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' ) @@ -381,6 +396,22 @@ def get_error(css): ) assert get_error('> div p') == ("Expected selector, got ' 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()') + + # Unsupported complex selectors + assert get_error(':has(a, b)') == ( + "Expected ')', got ") + assert get_error(':not(a, b)') == ( + "Expected ')', got ") + def test_translation(self): def xpath(css): return _unicode(GenericTranslator().css_to_xpath(css, prefix='')) @@ -490,6 +521,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') == ( @@ -839,6 +872,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 @@ -935,6 +971,7 @@ def count(selector): assert count(':scope > div > div[class=dialog]') == 1 assert count(':scope > div div') == 242 + XMLLANG_IDS = ''' a diff --git a/tox.ini b/tox.ini index 49a1dda..da59eaa 100644 --- a/tox.ini +++ b/tox.ini @@ -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}