Skip to content

Commit

Permalink
Merge pull request #968 from twalpole/of-type
Browse files Browse the repository at this point in the history
improve *-of-type and *-child pseudo selectors
  • Loading branch information
knu committed Nov 1, 2013
2 parents 91ff698 + 044bc91 commit de6679b
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 72 deletions.
50 changes: 0 additions & 50 deletions lib/nokogiri/css/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,60 +22,10 @@ def accept visitor
###
# Convert this CSS node to xpath with +prefix+ using +visitor+
def to_xpath prefix = '//', visitor = XPathVisitor.new
self.preprocess!
prefix = '.' if ALLOW_COMBINATOR_ON_SELF.include?(type) && value.first.nil?
prefix + visitor.accept(self)
end

# Preprocess this node tree
def preprocess!
### Deal with nth-child
matches = find_by_type(
[:CONDITIONAL_SELECTOR,
[:ELEMENT_NAME],
[:PSEUDO_CLASS,
[:FUNCTION]
]
]
)
matches.each do |match|
if match.value[1].value[0].value[0] =~ /^nth-(last-)?child/
tag_name = match.value[0].value.first
match.value[0].value = ['*']
match.value[1] = Node.new(:COMBINATOR, [
match.value[1].value[0],
Node.new(:FUNCTION, ['self(', tag_name])
])
end
end

### Deal with first-child, last-child
matches = find_by_type(
[:CONDITIONAL_SELECTOR,
[:ELEMENT_NAME], [:PSEUDO_CLASS]
])
matches.each do |match|
if ['first-child', 'last-child'].include?(match.value[1].value.first)
which = match.value[1].value.first.gsub(/-\w*$/, '')
tag_name = match.value[0].value.first
match.value[0].value = ['*']
match.value[1] = Node.new(:COMBINATOR, [
Node.new(:FUNCTION, ["#{which}("]),
Node.new(:FUNCTION, ['self(', tag_name])
])
elsif 'only-child' == match.value[1].value.first
tag_name = match.value[0].value.first
match.value[0].value = ['*']
match.value[1] = Node.new(:COMBINATOR, [
Node.new(:FUNCTION, ["#{match.value[1].value.first}("]),
Node.new(:FUNCTION, ['self(', tag_name])
])
end
end

self
end

# Find a node by type using +types+
def find_by_type types
matches = []
Expand Down
51 changes: 43 additions & 8 deletions lib/nokogiri/css/xpath_visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module Nokogiri
module CSS
class XPathVisitor # :nodoc:
def visit_function node
# note that nth-child and nth-last-child are preprocessed in css/node.rb.

msg = :"visit_function_#{node.value.first.gsub(/[(]/, '')}"
return self.send(msg, node) if self.respond_to?(msg)

Expand All @@ -13,19 +13,31 @@ def visit_function node
"self::#{node.value[1]}"
when /^eq\(/
"position() = #{node.value[1]}"
when /^(nth|nth-of-type|nth-child)\(/
when /^(nth|nth-of-type)\(/
if node.value[1].is_a?(Nokogiri::CSS::Node) and node.value[1].type == :NTH
nth(node.value[1])
else
"position() = #{node.value[1]}"
end
when /^(nth-last-child|nth-last-of-type)\(/
when /^nth-child\(/
if node.value[1].is_a?(Nokogiri::CSS::Node) and node.value[1].type == :NTH
nth(node.value[1], :child => true)
else
"count(preceding-sibling::*) = #{node.value[1].to_i-1}"
end
when /^nth-last-of-type\(/
if node.value[1].is_a?(Nokogiri::CSS::Node) and node.value[1].type == :NTH
nth(node.value[1], :last => true)
else
index = node.value[1].to_i - 1
index == 0 ? "position() = last()" : "position() = last() - #{index}"
end
when /^nth-last-child\(/
if node.value[1].is_a?(Nokogiri::CSS::Node) and node.value[1].type == :NTH
nth(node.value[1], :last => true, :child => true)
else
"count(following-sbiling::*) = #{node.value[1].to_i-1}"
end
when /^(first|first-of-type)\(/
"position() = 1"
when /^(last|last-of-type)\(/
Expand Down Expand Up @@ -105,11 +117,13 @@ def visit_pseudo_class node
return self.send(msg, node) if self.respond_to?(msg)

case node.value.first
when "first", "first-child" then "position() = 1"
when "last", "last-child" then "position() = last()"
when "first" then "position() = 1"
when "first-child" then "count(preceding-sibling::*) = 0"
when "last" then "position() = last()"
when "last-child" then "count(following-sibling::*) = 0"
when "first-of-type" then "position() = 1"
when "last-of-type" then "position() = last()"
when "only-child" then "last() = 1"
when "only-child" then "count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0"
when "only-of-type" then "last() = 1"
when "empty" then "not(node())"
when "parent" then "node()"
Expand All @@ -124,8 +138,15 @@ def visit_class_condition node
"contains(concat(' ', normalize-space(@class), ' '), ' #{node.value.first} ')"
end

def visit_combinator node
if is_of_type_pseudo_class?(node.value.last)
"#{node.value.first.accept(self) if node.value.first}][#{node.value.last.accept(self)}"
else
"#{node.value.first.accept(self) if node.value.first} and #{node.value.last.accept(self)}"
end
end

{
'combinator' => ' and ',
'direct_adjacent_selector' => "/following-sibling::*[1]/self::",
'following_selector' => "/following-sibling::",
'descendant_selector' => '//',
Expand Down Expand Up @@ -156,7 +177,11 @@ def nth node, options={}
raise ArgumentError, "expected an+b node to contain 4 tokens, but is #{node.value.inspect}" unless node.value.size == 4

a, b = read_a_and_positive_b node.value
position = options[:last] ? "(last()-position()+1)" : "position()"
position = if options[:child]
options[:last] ? "(count(following-sibling::*) + 1)" : "(count(preceding-sibling::*) + 1)"
else
options[:last] ? "(last()-position()+1)" : "position()"
end

if (b == 0)
return "(#{position} mod #{a}) = 0"
Expand All @@ -179,6 +204,16 @@ def read_a_and_positive_b values
end
[a, b]
end

def is_of_type_pseudo_class? node
if node.type==:PSEUDO_CLASS
if node.value[0].is_a?(Nokogiri::CSS::Node) and node.value[0].type == :FUNCTION
node.value[0].value[0]
else
node.value[0]
end =~ /(nth|first|last|only)-of-type(\()?/
end
end
end
end
end
63 changes: 61 additions & 2 deletions test/css/test_nthiness.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,20 @@ def setup
<div>
<b>bold1 </b>
<i>italic1 </i>
<b>bold2 </b>
<b class="a">bold2 </b>
<em class="a">emphasis1 </em>
<i>italic2 </i>
<p>para1 </p>
<b>bold3 </b>
<b class="a">bold3 </b>
</div>
<div>
<i class="b">italic3 </i>
<em>emphasis2 </em>
<i class="b">italic4 </i>
<em>emphasis3 </em>
<i class="c">italic5 </i>
<span><i class="b">italic6 </i></span>
<i>italic7 </i>
</div>
<div>
<p>para2 </p>
Expand All @@ -38,6 +48,20 @@ def setup
<div>
<p>para4 </p>
</div>
<div>
<h2></h2>
<h1 class='c'>header1 </h1>
<h2></h2>
</div>
<div>
<h1 class='c'>header2 </h1>
<h1 class='c'>header3 </h1>
</div>
<div>
<h1 class='c'>header4</h1>
</div>
<p class='empty'></p>
<p class='not-empty'><b></b></p>
</html>
Expand Down Expand Up @@ -95,29 +119,64 @@ def test_last
def test_first_child
assert_result_rows [1], @parser.search("div/b:first-child"), "bold"
assert_result_rows [1], @parser.search("table/tr:first-child")
assert_result_rows [2,4], @parser.search("div/h1.c:first-child"), "header"
end

def test_last_child
assert_result_rows [3], @parser.search("div/b:last-child"), "bold"
assert_result_rows [14], @parser.search("table/tr:last-child")
assert_result_rows [3,4], @parser.search("div/h1.c:last-child"), "header"
end

def test_nth_child
assert_result_rows [2], @parser.search("div/b:nth-child(3)"), "bold"
assert_result_rows [5], @parser.search("table/tr:nth-child(5)")
assert_result_rows [1,3], @parser.search("div/h1.c:nth-child(2)"), "header"
assert_result_rows [3,4], @parser.search("div/i.b:nth-child(2n+1)"), "italic"
end

def test_first_of_type
assert_result_rows [1], @parser.search("table/tr:first-of-type")
assert_result_rows [1], @parser.search("div/b:first-of-type"), "bold"
assert_result_rows [2], @parser.search("div/b.a:first-of-type"), "bold"
assert_result_rows [3], @parser.search("div/i.b:first-of-type"), "italic"
end

def test_last_of_type
assert_result_rows [14], @parser.search("table/tr:last-of-type")
assert_result_rows [3], @parser.search("div/b:last-of-type"), "bold"
assert_result_rows [2,7], @parser.search("div/i:last-of-type"), "italic"
assert_result_rows [2,6,7], @parser.search("div i:last-of-type"), "italic"
assert_result_rows [4], @parser.search("div/i.b:last-of-type"), "italic"
end

def test_nth_of_type
assert_result_rows [1], @parser.search("div/b:nth-of-type(1)"), "bold"
assert_result_rows [2], @parser.search("div/b:nth-of-type(2)"), "bold"
assert_result_rows [2], @parser.search("div/.a:nth-of-type(1)"), "bold"
assert_result_rows [2,4,7], @parser.search("div i:nth-of-type(2n)"), "italic"
assert_result_rows [1,3,5,6], @parser.search("div i:nth-of-type(2n+1)"), "italic"
assert_result_rows [1], @parser.search("div .a:nth-of-type(2n)"), "emphasis"
assert_result_rows [2,3], @parser.search("div .a:nth-of-type(2n+1)"), "bold"
end

def test_nth_last_of_type
assert_result_rows [14], @parser.search("table/tr:nth-last-of-type(1)")
assert_result_rows [12], @parser.search("table/tr:nth-last-of-type(3)")
assert_result_rows [2,6,7], @parser.search("div i:nth-last-of-type(1)"), "italic"
assert_result_rows [1,5], @parser.search("div i:nth-last-of-type(2)"), "italic"
assert_result_rows [4], @parser.search("div/i.b:nth-last-of-type(1)"), "italic"
assert_result_rows [3], @parser.search("div/i.b:nth-last-of-type(2)"), "italic"
end

def test_only_of_type
assert_result_rows [1,4], @parser.search("div/p:only-of-type"), "para"
assert_result_rows [5], @parser.search("div/i.c:only-of-type"), "italic"
end

def test_only_child
assert_result_rows [4], @parser.search("div/p:only-child"), "para"
assert_result_rows [4], @parser.search("div/h1.c:only-child"), "header"
end

def test_empty
Expand Down
27 changes: 17 additions & 10 deletions test/css/test_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ def test_includes
end

def test_function_with_arguments
assert_xpath "//*[position() = 2 and self::a]",
assert_xpath "//a[count(preceding-sibling::*) = 1]",
@parser.parse("a[2]")
assert_xpath "//*[position() = 2 and self::a]",
assert_xpath "//a[count(preceding-sibling::*) = 1]",
@parser.parse("a:nth-child(2)")
end

Expand Down Expand Up @@ -158,24 +158,31 @@ def test_nonstandard_nth_selectors
def test_standard_nth_selectors
assert_xpath '//a[position() = 1]', @parser.parse('a:first-of-type()')
assert_xpath '//a[position() = 1]', @parser.parse('a:first-of-type') # no parens
assert_xpath '//a[position() = 99]', @parser.parse('a:nth-of-type(99)')
assert_xpath "//a[contains(concat(' ', normalize-space(@class), ' '), ' b ')][position() = 1]",
@parser.parse('a.b:first-of-type') # no parens
assert_xpath '//a[position() = 99]', @parser.parse('a:nth-of-type(99)')
assert_xpath "//a[contains(concat(' ', normalize-space(@class), ' '), ' b ')][position() = 99]",
@parser.parse('a.b:nth-of-type(99)')
assert_xpath '//a[position() = last()]', @parser.parse('a:last-of-type()')
assert_xpath '//a[position() = last()]', @parser.parse('a:last-of-type') # no parens
assert_xpath "//a[contains(concat(' ', normalize-space(@class), ' '), ' b ')][position() = last()]",
@parser.parse('a.b:last-of-type') # no parens
assert_xpath '//a[position() = last()]', @parser.parse('a:nth-last-of-type(1)')
assert_xpath '//a[position() = last() - 98]', @parser.parse('a:nth-last-of-type(99)')
assert_xpath "//a[contains(concat(' ', normalize-space(@class), ' '), ' b ')][position() = last() - 98]",
@parser.parse('a.b:nth-last-of-type(99)')
end

def test_nth_child_selectors
assert_xpath '//*[position() = 1 and self::a]', @parser.parse('a:first-child')
assert_xpath '//*[position() = 99 and self::a]', @parser.parse('a:nth-child(99)')
assert_xpath '//*[position() = last() and self::a]', @parser.parse('a:last-child')
assert_xpath '//*[position() = last() and self::a]', @parser.parse('a:nth-last-child(1)')
assert_xpath '//*[position() = last() - 98 and self::a]', @parser.parse('a:nth-last-child(99)')
assert_xpath '//a[count(preceding-sibling::*) = 0]', @parser.parse('a:first-child')
assert_xpath '//a[count(preceding-sibling::*) = 98]', @parser.parse('a:nth-child(99)')
assert_xpath '//a[count(following-sibling::*) = 0]', @parser.parse('a:last-child')
assert_xpath '//a[count(following-sbiling::*) = 0]', @parser.parse('a:nth-last-child(1)')
assert_xpath '//a[count(following-sbiling::*) = 98]', @parser.parse('a:nth-last-child(99)')
end

def test_miscellaneous_selectors
assert_xpath '//*[last() = 1 and self::a]',
@parser.parse('a:only-child')
assert_xpath '//a[count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0]', @parser.parse('a:only-child')
assert_xpath '//a[last() = 1]', @parser.parse('a:only-of-type')
assert_xpath '//a[not(node())]', @parser.parse('a:empty')
end
Expand Down
4 changes: 2 additions & 2 deletions test/css/test_xpath_visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ def test_not_simple_selector
end

def test_not_last_child
assert_xpath('//ol/*[not(position() = last())]',
assert_xpath('//ol/*[not(count(following-sibling::*) = 0)]',
@parser.parse('ol > *:not(:last-child)'))
end

def test_not_only_child
assert_xpath('//ol/*[not(last() = 1)]',
assert_xpath('//ol/*[not(count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0)]',
@parser.parse('ol > *:not(:only-child)'))
end

Expand Down

0 comments on commit de6679b

Please sign in to comment.