diff --git a/lib/nokogiri/xml/node.rb b/lib/nokogiri/xml/node.rb index bdaae38ebea..e39c5f6fb0b 100644 --- a/lib/nokogiri/xml/node.rb +++ b/lib/nokogiri/xml/node.rb @@ -1,5 +1,6 @@ require 'stringio' require 'nokogiri/xml/node/save_options' +require 'nokogiri/xml/xpath_functions' module Nokogiri module XML @@ -145,7 +146,11 @@ def search *paths def xpath *paths return NodeSet.new(document) unless document + default_handler = paths.include?(nil) ? nil : XPathFunctions.handler paths, handler, ns, binds = extract_params(paths) + if handler.nil? and caller.find { |m| m =~ /:in `xpath'/ }.nil? + handler = default_handler + end sets = paths.map { |path| ctx = XPathContext.new(self) diff --git a/lib/nokogiri/xml/xpath_functions.rb b/lib/nokogiri/xml/xpath_functions.rb new file mode 100644 index 00000000000..9c18f2d76a2 --- /dev/null +++ b/lib/nokogiri/xml/xpath_functions.rb @@ -0,0 +1,79 @@ +module Nokogiri + module XML + class XPathFunctions + @@handler = nil + class << self + def handler + @@handler ||= create_handler + end + + ### + # call-seq: define :sym, &block + # + # Define a new global XPath function. + # + # Nokogiri::XML::XPathFunctions.define(:regex) do |node_set, regex| + # node_set.find_all { |node| node['some_attribute'] =~ /#{regex}/ } + # end + # + # node.xpath('.//title[regex(., "\w+")]') + # + # Any custom handler passed to Node#xpath the normal way will override + # the default internal handler. Passing +nil+ will evaluate the expression + # with no custom function handler at all. + # + def define sym, &block + handler.__class__.send(:define_method, sym, &block) + end + + ### + # call-seq: undef :sym + # + # Undefine a previously-defined global XPath function. + # + # Nokogiri::XML::XPathFunctions.undef(:regex) + # + def undef sym + handler.__class__.send(:undef_method, sym) + end + + ### + # call-seq: reset! + # + # Reset the global XPath function handler to its default state + # (i.e., no user-defined functions) + # + # Nokogiri::XML::XPathFunctions.reset! + # + def reset! + @@handler = nil + end + + ### + # Create a handler class with NO normal methods but the bare + # necessities: #send and #method_missing. + # + # All other methods will be aliased as __#{method}__ and invoked + # via #method_missing if they haven't been overridden via #define + def create_handler + klazz = Class.new + klazz.instance_methods.each do |method| + unless method =~ /(^__)|(^send$)/ + klazz.send(:alias_method,:"__#{method}__", method.to_sym) + klazz.send(:undef_method,method.to_sym) + end + end + klazz.send(:define_method, :method_missing) do |sym, *args| + if sym.to_s =~ /^__(.+)__$/ + super $1.to_sym, *args + else + self.send(:"__#{sym.to_s}__", *args) + end + end + klazz.new + end + protected :create_handler + end + end + end +end diff --git a/test/xml/test_xpath_functions.rb b/test/xml/test_xpath_functions.rb new file mode 100644 index 00000000000..b0bef5f08b4 --- /dev/null +++ b/test/xml/test_xpath_functions.rb @@ -0,0 +1,160 @@ +require "helper" + +module Nokogiri + module XML + class TestXPathFunctions < Nokogiri::TestCase + + def setup + super + + @xml = Nokogiri::XML.parse(File.read(XML_FILE), XML_FILE) + + @ns = @xml.root.namespaces + + # TODO: Maybe I should move this to the original code. + @ns["nokogiri"] = "http://www.nokogiri.org/default_ns/ruby/extensions_functions" + + Nokogiri::XML::XPathFunctions.reset! + + Nokogiri::XML::XPathFunctions.define(:things) do + @things ||= [] + end + + Nokogiri::XML::XPathFunctions.define(:thing) do |thing| + things << thing + thing + end + + Nokogiri::XML::XPathFunctions.define(:returns_array) do |node_set| + things << node_set.to_a + node_set.to_a + end + + Nokogiri::XML::XPathFunctions.define(:my_filter) do |set, attribute, value| + set.find_all { |x| x[attribute] == value } + end + + Nokogiri::XML::XPathFunctions.define(:saves_node_set) do |node_set| + @things = node_set + end + + @handler = Nokogiri::XML::XPathFunctions.handler + end + + def test_pass_self_to_function + set = if Nokogiri.uses_libxml? + @xml.xpath('//employee/address[my_filter(., "domestic", "Yes")]') + else + @xml.xpath('//employee/address[nokogiri:my_filter(., "domestic", "Yes")]', @ns) + end + assert set.length > 0 + set.each do |node| + assert_equal 'Yes', node['domestic'] + end + end + + def test_custom_xpath_function_gets_strings + set = @xml.xpath('//employee') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[thing("asdf")]') + else + @xml.xpath('//employee[nokogiri:thing("asdf")]', @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal(['asdf'] * set.length, @handler.things) + end + + def test_custom_xpath_gets_true_booleans + set = @xml.xpath('//employee') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[thing(true())]') + else + @xml.xpath("//employee[nokogiri:thing(true())]", @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal([true] * set.length, @handler.things) + end + + def test_custom_xpath_gets_false_booleans + set = @xml.xpath('//employee') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[thing(false())]') + else + @xml.xpath("//employee[nokogiri:thing(false())]", @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal([false] * set.length, @handler.things) + end + + def test_custom_xpath_gets_numbers + set = @xml.xpath('//employee') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[thing(10)]') + else + @xml.xpath('//employee[nokogiri:thing(10)]', @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal([10] * set.length, @handler.things) + end + + def test_custom_xpath_gets_node_sets + set = @xml.xpath('//employee/name') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[thing(name)]') + else + @xml.xpath('//employee[nokogiri:thing(name)]', @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal(set.to_a, @handler.things.flatten) + end + + def test_custom_xpath_gets_node_sets_and_returns_array + set = @xml.xpath('//employee/name') + if Nokogiri.uses_libxml? + @xml.xpath('//employee[returns_array(name)]') + else + @xml.xpath('//employee[nokogiri:returns_array(name)]', @ns) + end + assert_equal(set.length, @handler.things.length) + assert_equal(set.to_a, @handler.things.flatten) + end + + def test_custom_xpath_handler_is_passed_a_decorated_node_set + x = Module.new do + def awesome! ; end + end + util_decorate(@xml, x) + + assert @xml.xpath('//employee/name') + + @xml.xpath('//employee[saves_node_set(name)]') + assert_equal @xml, @handler.things.document + assert @handler.things.respond_to?(:awesome!) + end + + def test_override_custom_xpath_handler + assert_raise RuntimeError, /xmlXPathCompOpEval: function thing not found/ do + @xml.xpath('//employee[thing(name)]', nil) + end + end + + def test_reset_custom_xpath_handler + Nokogiri::XML::XPathFunctions.reset! + assert_raise RuntimeError, /xmlXPathCompOpEval: function thing not found/ do + @xml.xpath('//employee[thing(name)]') + end + end + + def test_replace_custom_xpath_handler + my_handler = Class.new { + def thing(node_set, initial) + node_set.find_all { |n| n.content.start_with?(initial) } + end + }.new + set = @xml.xpath('//employee[thing(name, "R")]', my_handler) + assert_equal(set.length, 2) + end + + end + end +end