xml_node_stream 1.0.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/README.md +139 -0
- data/VERSION +1 -0
- data/lib/xml_node_stream/http_stream.rb +228 -0
- data/lib/xml_node_stream/node.rb +98 -47
- data/lib/xml_node_stream/parser/base.rb +49 -12
- data/lib/xml_node_stream/parser/libxml_parser.rb +36 -9
- data/lib/xml_node_stream/parser/nokogiri_parser.rb +42 -12
- data/lib/xml_node_stream/parser/rexml_parser.rb +35 -8
- data/lib/xml_node_stream/parser.rb +52 -29
- data/lib/xml_node_stream/selector.rb +142 -33
- data/lib/xml_node_stream.rb +19 -5
- data/xml_node_stream.gemspec +39 -0
- metadata +36 -92
- data/README.rdoc +0 -61
- data/Rakefile +0 -44
- data/spec/node_spec.rb +0 -140
- data/spec/parser_spec.rb +0 -148
- data/spec/selector_spec.rb +0 -73
- data/spec/spec_helper.rb +0 -2
- data/spec/test.xml +0 -57
- data/spec/xml_node_stream_spec.rb +0 -11
- /data/{MIT_LICENSE → MIT-LICENSE} +0 -0
|
@@ -1,38 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module XmlNodeStream
|
|
2
4
|
class Parser
|
|
3
5
|
# This is the base parser syntax that normalizes the SAX callbacks by providing a common interface
|
|
4
6
|
# so that the actual parser implementation doesn't matter.
|
|
5
7
|
module Base
|
|
6
|
-
|
|
7
8
|
attr_reader :root
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
# Initialize the parser.
|
|
11
|
+
#
|
|
12
|
+
# @yield [Node] each node as it is parsed
|
|
13
|
+
def initialize(&block)
|
|
10
14
|
@nodes = []
|
|
11
15
|
@parse_block = block
|
|
12
16
|
@root = nil
|
|
13
17
|
end
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
# Parse the input stream.
|
|
20
|
+
#
|
|
21
|
+
# @param io [IO] the input stream to parse
|
|
22
|
+
# @return [void]
|
|
23
|
+
# @raise [NotImplementedError] if the parser gem is not loaded
|
|
24
|
+
def parse_stream(io)
|
|
25
|
+
parser_name = self.class.name.split("::").last.sub("Parser", "").downcase
|
|
26
|
+
gem_name = case parser_name
|
|
27
|
+
when "nokogiri" then "nokogiri"
|
|
28
|
+
when "libxml" then "libxml-ruby"
|
|
29
|
+
when "rexml" then "rexml"
|
|
30
|
+
else "unknown"
|
|
31
|
+
end
|
|
32
|
+
raise NotImplementedError.new("Parser gem not loaded: #{gem_name}. Install it with: gem install #{gem_name.split(" ").first}")
|
|
17
33
|
end
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
|
|
35
|
+
# Handle start element event.
|
|
36
|
+
#
|
|
37
|
+
# @param name [String] the element name
|
|
38
|
+
# @param attributes [Hash] the element attributes
|
|
39
|
+
# @return [void]
|
|
40
|
+
# @api private
|
|
41
|
+
def do_start_element(name, attributes)
|
|
20
42
|
node = XmlNodeStream::Node.new(name, @nodes.last, attributes)
|
|
21
43
|
@nodes.push(node)
|
|
22
44
|
end
|
|
23
45
|
|
|
24
|
-
|
|
46
|
+
# Handle end element event.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] the element name
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @api private
|
|
51
|
+
def do_end_element(name)
|
|
25
52
|
node = @nodes.pop
|
|
26
53
|
node.finish!
|
|
27
54
|
@root = node if @nodes.empty?
|
|
28
|
-
@parse_block
|
|
55
|
+
@parse_block&.call(node)
|
|
29
56
|
end
|
|
30
57
|
|
|
31
|
-
|
|
58
|
+
# Handle character data event.
|
|
59
|
+
#
|
|
60
|
+
# @param characters [String] the character data
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @api private
|
|
63
|
+
def do_characters(characters)
|
|
32
64
|
@nodes.last.append(characters) unless @nodes.empty?
|
|
33
65
|
end
|
|
34
66
|
|
|
35
|
-
|
|
67
|
+
# Handle CDATA block event.
|
|
68
|
+
#
|
|
69
|
+
# @param characters [String] the CDATA content
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @api private
|
|
72
|
+
def do_cdata_block(characters)
|
|
36
73
|
@nodes.last.append_cdata(characters) unless @nodes.empty?
|
|
37
74
|
end
|
|
38
75
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
begin
|
|
2
|
-
require
|
|
3
|
-
|
|
4
|
+
require "libxml-ruby"
|
|
5
|
+
|
|
4
6
|
module XmlNodeStream
|
|
5
7
|
class Parser
|
|
6
8
|
# Wrapper for the LibXML SAX parser.
|
|
@@ -8,26 +10,51 @@ begin
|
|
|
8
10
|
include LibXML::XML::SaxParser::Callbacks
|
|
9
11
|
include Base
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
# Parse the input stream using LibXML.
|
|
14
|
+
#
|
|
15
|
+
# @param io [IO] the input stream to parse
|
|
16
|
+
# @return [void]
|
|
17
|
+
def parse_stream(io)
|
|
12
18
|
context = LibXML::XML::Parser::Context.io(io)
|
|
13
19
|
parser = LibXML::XML::SaxParser.new(context)
|
|
14
20
|
parser.callbacks = self
|
|
15
21
|
parser.parse
|
|
16
22
|
end
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
|
|
24
|
+
# Handle LibXML start element callback.
|
|
25
|
+
#
|
|
26
|
+
# @param name [String] the element name
|
|
27
|
+
# @param attributes [Hash] the element attributes
|
|
28
|
+
# @return [void]
|
|
29
|
+
# @api private
|
|
30
|
+
def on_start_element(name, attributes)
|
|
19
31
|
do_start_element(name, attributes)
|
|
20
32
|
end
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
# Handle LibXML end element callback.
|
|
35
|
+
#
|
|
36
|
+
# @param name [String] the element name
|
|
37
|
+
# @return [void]
|
|
38
|
+
# @api private
|
|
39
|
+
def on_end_element(name)
|
|
23
40
|
do_end_element(name)
|
|
24
41
|
end
|
|
25
42
|
|
|
26
|
-
|
|
43
|
+
# Handle LibXML character data callback.
|
|
44
|
+
#
|
|
45
|
+
# @param characters [String] the character data
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @api private
|
|
48
|
+
def on_characters(characters)
|
|
27
49
|
do_characters(characters)
|
|
28
50
|
end
|
|
29
51
|
|
|
30
|
-
|
|
52
|
+
# Handle LibXML CDATA block callback.
|
|
53
|
+
#
|
|
54
|
+
# @param characters [String] the CDATA content
|
|
55
|
+
# @return [void]
|
|
56
|
+
# @api private
|
|
57
|
+
def on_cdata_block(characters)
|
|
31
58
|
do_cdata_block(characters)
|
|
32
59
|
end
|
|
33
60
|
end
|
|
@@ -41,4 +68,4 @@ rescue LoadError
|
|
|
41
68
|
end
|
|
42
69
|
end
|
|
43
70
|
end
|
|
44
|
-
end
|
|
71
|
+
end
|
|
@@ -1,24 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
begin
|
|
2
|
-
require
|
|
3
|
-
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
4
6
|
module XmlNodeStream
|
|
5
7
|
class Parser
|
|
6
8
|
# Wrapper for the Nokogiri SAX parser.
|
|
7
9
|
class NokogiriParser
|
|
8
10
|
include Base
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
# Parse the input stream using Nokogiri.
|
|
13
|
+
#
|
|
14
|
+
# @param io [IO] the input stream to parse
|
|
15
|
+
# @return [void]
|
|
16
|
+
def parse_stream(io)
|
|
11
17
|
listener = Listener.new(self)
|
|
12
18
|
parser = Nokogiri::XML::SAX::Parser.new(listener)
|
|
13
19
|
parser.parse(io)
|
|
14
20
|
end
|
|
15
|
-
|
|
21
|
+
|
|
16
22
|
class Listener < Nokogiri::XML::SAX::Document
|
|
17
|
-
|
|
23
|
+
# Initialize the Nokogiri listener.
|
|
24
|
+
#
|
|
25
|
+
# @param parser [NokogiriParser] the parser instance
|
|
26
|
+
def initialize(parser)
|
|
18
27
|
@parser = parser
|
|
19
28
|
end
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
|
|
30
|
+
# Handle Nokogiri start element callback.
|
|
31
|
+
#
|
|
32
|
+
# @param name [String] the element name
|
|
33
|
+
# @param attributes [Array] the element attributes
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @api private
|
|
36
|
+
def start_element(name, attributes = [])
|
|
22
37
|
attributes_hash = {}
|
|
23
38
|
if attributes.first.is_a?(Array)
|
|
24
39
|
# Newer style where attributes are passed as an array of arrays
|
|
@@ -27,20 +42,35 @@ begin
|
|
|
27
42
|
end
|
|
28
43
|
else
|
|
29
44
|
# Old style where attributes are passed as a flat array
|
|
30
|
-
(attributes.size / 2).times{|i| attributes_hash[attributes[i * 2]] = attributes[(i * 2) + 1]}
|
|
45
|
+
(attributes.size / 2).times { |i| attributes_hash[attributes[i * 2]] = attributes[(i * 2) + 1] }
|
|
31
46
|
end
|
|
32
47
|
@parser.do_start_element(name, attributes_hash)
|
|
33
48
|
end
|
|
34
49
|
|
|
35
|
-
|
|
50
|
+
# Handle Nokogiri end element callback.
|
|
51
|
+
#
|
|
52
|
+
# @param name [String] the element name
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @api private
|
|
55
|
+
def end_element(name)
|
|
36
56
|
@parser.do_end_element(name)
|
|
37
57
|
end
|
|
38
58
|
|
|
39
|
-
|
|
59
|
+
# Handle Nokogiri character data callback.
|
|
60
|
+
#
|
|
61
|
+
# @param characters [String] the character data
|
|
62
|
+
# @return [void]
|
|
63
|
+
# @api private
|
|
64
|
+
def characters(characters)
|
|
40
65
|
@parser.do_characters(characters)
|
|
41
66
|
end
|
|
42
67
|
|
|
43
|
-
|
|
68
|
+
# Handle Nokogiri CDATA block callback.
|
|
69
|
+
#
|
|
70
|
+
# @param characters [String] the CDATA content
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @api private
|
|
73
|
+
def cdata_block(characters)
|
|
44
74
|
@parser.do_cdata_block(characters)
|
|
45
75
|
end
|
|
46
76
|
end
|
|
@@ -55,4 +85,4 @@ rescue LoadError
|
|
|
55
85
|
end
|
|
56
86
|
end
|
|
57
87
|
end
|
|
58
|
-
end
|
|
88
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
begin
|
|
2
|
-
require
|
|
3
|
-
require
|
|
4
|
-
|
|
4
|
+
require "rexml/document"
|
|
5
|
+
require "rexml/streamlistener"
|
|
6
|
+
|
|
5
7
|
module XmlNodeStream
|
|
6
8
|
class Parser
|
|
7
9
|
# Wrapper for the REXML SAX parser.
|
|
@@ -9,24 +11,49 @@ begin
|
|
|
9
11
|
include REXML::StreamListener
|
|
10
12
|
include Base
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
# Parse the input stream using REXML.
|
|
15
|
+
#
|
|
16
|
+
# @param io [IO] the input stream to parse
|
|
17
|
+
# @return [void]
|
|
18
|
+
def parse_stream(io)
|
|
13
19
|
parser = REXML::Parsers::StreamParser.new(io, self)
|
|
14
20
|
parser.parse
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
# Handle REXML tag start callback.
|
|
24
|
+
#
|
|
25
|
+
# @param name [String] the element name
|
|
26
|
+
# @param attributes [Hash] the element attributes
|
|
27
|
+
# @return [void]
|
|
28
|
+
# @api private
|
|
29
|
+
def tag_start(name, attributes)
|
|
18
30
|
do_start_element(name, attributes)
|
|
19
31
|
end
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
# Handle REXML tag end callback.
|
|
34
|
+
#
|
|
35
|
+
# @param name [String] the element name
|
|
36
|
+
# @return [void]
|
|
37
|
+
# @api private
|
|
38
|
+
def tag_end(name)
|
|
22
39
|
do_end_element(name)
|
|
23
40
|
end
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
# Handle REXML text callback.
|
|
43
|
+
#
|
|
44
|
+
# @param content [String] the text content
|
|
45
|
+
# @return [void]
|
|
46
|
+
# @api private
|
|
47
|
+
def text(content)
|
|
26
48
|
do_characters(content)
|
|
27
49
|
end
|
|
28
50
|
|
|
29
|
-
|
|
51
|
+
# Handle REXML CDATA callback.
|
|
52
|
+
#
|
|
53
|
+
# @param content [String] the CDATA content
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @api private
|
|
56
|
+
def cdata(content)
|
|
30
57
|
do_cdata_block(content)
|
|
31
58
|
end
|
|
32
59
|
end
|
|
@@ -1,70 +1,93 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require_relative "parser/base"
|
|
5
|
+
require_relative "http_stream"
|
|
5
6
|
|
|
6
7
|
module XmlNodeStream
|
|
7
8
|
# The abstract parser class that wraps the actual parser implementation.
|
|
8
9
|
class Parser
|
|
9
|
-
|
|
10
10
|
SUPPORTED_PARSERS = [:nokogiri, :libxml, :rexml]
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
class << self
|
|
13
13
|
# Set the parser implementation. The parser argument should be one of :nokogiri, :libxml, or :rexml. If this method
|
|
14
14
|
# is not called, it will default to :rexml which is the slowest choice possible. If you set the parser to one of the
|
|
15
15
|
# other values, though, you'll need to make sure you have the nokogiri gem or libxml-ruby gem installed.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
#
|
|
17
|
+
# @param parser [Symbol, String] the parser name (:nokogiri, :libxml, or :rexml)
|
|
18
|
+
# @return [Symbol] the parser name
|
|
19
|
+
# @raise [ArgumentError] if parser is not one of the supported parsers
|
|
20
|
+
def parser_name=(parser)
|
|
21
|
+
parser_sym = parser&.to_sym
|
|
18
22
|
raise ArgumentError.new("must be one of #{SUPPORTED_PARSERS.inspect}") unless SUPPORTED_PARSERS.include?(parser_sym)
|
|
23
|
+
|
|
19
24
|
@parser_name = parser_sym
|
|
20
25
|
end
|
|
21
|
-
|
|
26
|
+
|
|
22
27
|
# Get the name of the current parser.
|
|
28
|
+
#
|
|
29
|
+
# @return [Symbol] the current parser name
|
|
23
30
|
def parser_name
|
|
24
31
|
@parser_name ||= :rexml
|
|
25
32
|
end
|
|
26
|
-
|
|
33
|
+
|
|
27
34
|
# Parse the document specified in io. This can be either a Stream, URI, Pathname, or String. If it is a String,
|
|
28
35
|
# it can either be a XML document, file system path, or URI. The parser will figure it out. If a block is given,
|
|
29
36
|
# it will be yielded to with each node as it is parsed.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
#
|
|
38
|
+
# @param io [IO, String, URI, Pathname] the input source to parse
|
|
39
|
+
# @yield [Node] each node as it is parsed
|
|
40
|
+
# @return [Node] the root node of the parsed document
|
|
41
|
+
def parse(io, &block)
|
|
42
|
+
close_stream = true
|
|
43
|
+
io = URI.parse(io) if io.is_a?(String) && io.match?(%r{\Ahttp(s)?://})
|
|
44
|
+
|
|
45
|
+
if io.is_a?(String) && io.match?(/<[^>]+>/m)
|
|
46
|
+
io = StringIO.new(io)
|
|
47
|
+
elsif io.is_a?(String)
|
|
48
|
+
unless File.exist?(io)
|
|
49
|
+
raise ArgumentError.new("File not found: #{io}")
|
|
37
50
|
end
|
|
38
|
-
|
|
51
|
+
io = File.open(io, "r:UTF-8")
|
|
39
52
|
elsif io.is_a?(Pathname)
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
unless io.exist?
|
|
54
|
+
raise ArgumentError.new("File not found: #{io}")
|
|
55
|
+
end
|
|
56
|
+
io = io.open("r:UTF-8")
|
|
42
57
|
elsif io.is_a?(URI)
|
|
43
|
-
io = io
|
|
44
|
-
|
|
58
|
+
io = HttpStream.new(io)
|
|
59
|
+
else
|
|
60
|
+
close_stream = false
|
|
45
61
|
end
|
|
46
62
|
|
|
47
63
|
begin
|
|
48
64
|
parser = parser_class(parser_name).new(&block)
|
|
49
65
|
parser.parse_stream(io)
|
|
50
|
-
|
|
66
|
+
parser.root
|
|
51
67
|
ensure
|
|
52
|
-
|
|
68
|
+
if close_stream
|
|
69
|
+
begin
|
|
70
|
+
io.close
|
|
71
|
+
rescue
|
|
72
|
+
# Ignore errors during close to ensure cleanup completes
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
53
76
|
end
|
|
54
77
|
end
|
|
55
|
-
|
|
78
|
+
|
|
56
79
|
protected
|
|
57
|
-
|
|
58
|
-
def parser_class
|
|
80
|
+
|
|
81
|
+
def parser_class(class_symbol)
|
|
59
82
|
@loaded_parsers ||= {}
|
|
60
83
|
klass = @loaded_parsers[class_symbol]
|
|
61
84
|
unless klass
|
|
62
|
-
|
|
85
|
+
require_relative "parser/#{class_symbol}_parser"
|
|
63
86
|
class_name = "#{class_symbol.to_s.capitalize}Parser"
|
|
64
87
|
klass = const_get(class_name)
|
|
65
88
|
@loaded_parsers[class_symbol] = klass
|
|
66
89
|
end
|
|
67
|
-
|
|
90
|
+
klass
|
|
68
91
|
end
|
|
69
92
|
end
|
|
70
93
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module XmlNodeStream
|
|
2
4
|
# Partial implementation of XPath selectors. Only abbreviated paths and the text() function are supported. The rest of XPath
|
|
3
5
|
# is unecessary in the context of a Ruby application since XPath is also a programming language. If you really need an XPath
|
|
@@ -13,59 +15,166 @@ module XmlNodeStream
|
|
|
13
15
|
# * /library/books/book - find all book elements with the full path /library/books/book
|
|
14
16
|
# * author/text() - get the text values of all author child elements
|
|
15
17
|
class Selector
|
|
18
|
+
XPATH_SEGMENT_REGEX = /\A(\.\.?|\*|[a-zA-Z_][\w-]*|text\(\))(\|((\.\.?|\*|[a-zA-Z_][\w-]*|text\(\))))*\z/
|
|
19
|
+
|
|
16
20
|
# Create a selector. Path should be an abbreviated XPath string.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
or_paths.each do |matcher_path|
|
|
25
|
-
part_matchers << Matcher.new(matcher_path)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
21
|
+
#
|
|
22
|
+
# @param path [String] the XPath selector string
|
|
23
|
+
# @raise [ArgumentError] if the path is invalid
|
|
24
|
+
def initialize(path)
|
|
25
|
+
raise ArgumentError, "XPath pattern cannot be empty" if path.nil? || path.empty?
|
|
26
|
+
|
|
27
|
+
@parts = tokenize_path(path)
|
|
28
28
|
end
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
# Apply the selector to the current node. Note, if your path started with a /, it will be applied
|
|
31
31
|
# to the root node.
|
|
32
|
-
|
|
32
|
+
#
|
|
33
|
+
# @param node [Node] the node to apply the selector to
|
|
34
|
+
# @return [Array<Node>] the matching nodes
|
|
35
|
+
def find(node)
|
|
33
36
|
matched = [node]
|
|
34
37
|
@parts.each do |part_matchers|
|
|
35
38
|
context = matched
|
|
36
39
|
matched = []
|
|
40
|
+
|
|
37
41
|
part_matchers.each do |matcher|
|
|
38
42
|
matched.concat(matcher.select(context))
|
|
39
43
|
end
|
|
44
|
+
|
|
40
45
|
break if matched.empty?
|
|
41
46
|
end
|
|
42
|
-
|
|
47
|
+
matched
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Tokenize the XPath into parts using a simple lexer approach
|
|
53
|
+
#
|
|
54
|
+
# @param path [String] the XPath string to tokenize
|
|
55
|
+
# @return [Array<Array<Matcher>>] array of matcher arrays
|
|
56
|
+
# @raise [ArgumentError] if the path is malformed
|
|
57
|
+
def tokenize_path(path)
|
|
58
|
+
# Check for invalid patterns upfront
|
|
59
|
+
raise ArgumentError, "Invalid XPath pattern: #{path} (triple slash not allowed)" if path.include?("///")
|
|
60
|
+
|
|
61
|
+
parts = []
|
|
62
|
+
i = 0
|
|
63
|
+
path_length = path.length
|
|
64
|
+
|
|
65
|
+
while i < path_length
|
|
66
|
+
# Look for // (descendant operator); a leading // searches from the document root
|
|
67
|
+
if i < path_length - 1 && path[i] == "/" && path[i + 1] == "/"
|
|
68
|
+
parts << [Matcher.new("")] if i == 0
|
|
69
|
+
i += 2
|
|
70
|
+
# Check if there's a name after //
|
|
71
|
+
name_match = path[i..].match(/\A([a-zA-Z_][\w-]*)/)
|
|
72
|
+
if name_match
|
|
73
|
+
parts << [Matcher.new("%#{name_match[1]}")]
|
|
74
|
+
i += name_match[1].length
|
|
75
|
+
elsif i >= path_length
|
|
76
|
+
# // at end of path is invalid
|
|
77
|
+
raise ArgumentError, "Invalid XPath pattern: #{path} (// cannot be at end)"
|
|
78
|
+
elsif path[i] == "*" && (i + 1 >= path_length || path[i + 1] == "/")
|
|
79
|
+
# //* selects all descendants
|
|
80
|
+
parts << [Matcher.new("%")]
|
|
81
|
+
i += 1
|
|
82
|
+
else
|
|
83
|
+
parts << [Matcher.new("%")]
|
|
84
|
+
end
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Skip leading slash for absolute paths
|
|
89
|
+
if i == 0 && path[i] == "/"
|
|
90
|
+
parts << [Matcher.new("")]
|
|
91
|
+
i += 1
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Regular path segment
|
|
96
|
+
if path[i] == "/"
|
|
97
|
+
i += 1
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Extract the segment (until next / or end)
|
|
102
|
+
segment_end = i
|
|
103
|
+
in_parens = false
|
|
104
|
+
while segment_end < path_length
|
|
105
|
+
char = path[segment_end]
|
|
106
|
+
if char == "("
|
|
107
|
+
in_parens = true
|
|
108
|
+
elsif char == ")"
|
|
109
|
+
in_parens = false
|
|
110
|
+
elsif char == "/" && !in_parens
|
|
111
|
+
break
|
|
112
|
+
elsif char == "[" || char == "@"
|
|
113
|
+
raise ArgumentError, "Invalid XPath pattern: #{path} (predicates and attributes not supported)"
|
|
114
|
+
end
|
|
115
|
+
segment_end += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
segment = path[i...segment_end]
|
|
119
|
+
raise ArgumentError, "Invalid XPath pattern: #{path} (empty segment)" if segment.empty? && i > 0
|
|
120
|
+
|
|
121
|
+
i = segment_end
|
|
122
|
+
|
|
123
|
+
# Validate segment format
|
|
124
|
+
unless segment.match?(XPATH_SEGMENT_REGEX)
|
|
125
|
+
raise ArgumentError, "Invalid XPath pattern: #{path} (invalid segment: #{segment})"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Handle | (OR operator) within segment
|
|
129
|
+
or_paths = segment.split("|")
|
|
130
|
+
part_matchers = or_paths.map { |matcher_path| Matcher.new(matcher_path) }
|
|
131
|
+
parts << part_matchers
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
parts
|
|
43
135
|
end
|
|
44
|
-
|
|
136
|
+
|
|
45
137
|
# Match a partial path to a node.
|
|
46
138
|
class Matcher
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
139
|
+
# Create a new Matcher.
|
|
140
|
+
#
|
|
141
|
+
# @param path [String] the path pattern to match
|
|
142
|
+
def initialize(path)
|
|
143
|
+
@path = path
|
|
144
|
+
@text = (path == "text()")
|
|
145
|
+
@extractor = case path
|
|
146
|
+
when "text()"
|
|
147
|
+
lambda { |node| node.value unless node.value.nil? || node.value.empty? }
|
|
148
|
+
when "%"
|
|
149
|
+
lambda { |node| node.descendants }
|
|
150
|
+
when "*"
|
|
151
|
+
lambda { |node| node.children }
|
|
152
|
+
when "."
|
|
153
|
+
lambda { |node| node }
|
|
154
|
+
when ".."
|
|
155
|
+
lambda { |node| node.parent || [] }
|
|
156
|
+
when ""
|
|
157
|
+
lambda { |node|
|
|
158
|
+
root = Node.new(nil)
|
|
159
|
+
root.children << node.root
|
|
160
|
+
root
|
|
161
|
+
}
|
|
162
|
+
when /^%(.+)$/ # descendants with name filter: %name
|
|
163
|
+
name = $1
|
|
164
|
+
lambda { |node| node.descendants.select { |d| d.name == name } }
|
|
61
165
|
else
|
|
62
|
-
|
|
166
|
+
lambda { |node| node.children.select { |child| child.name == @path } }
|
|
63
167
|
end
|
|
64
168
|
end
|
|
65
|
-
|
|
169
|
+
|
|
66
170
|
# Select all nodes that match a partial path.
|
|
67
|
-
|
|
68
|
-
|
|
171
|
+
#
|
|
172
|
+
# @param context_nodes [Array<Node>] the nodes to select from
|
|
173
|
+
# @return [Array<Node>] the matching nodes
|
|
174
|
+
def select(context_nodes)
|
|
175
|
+
results = context_nodes.collect { |node| @extractor.call(node) if node.is_a?(Node) }.flatten.compact
|
|
176
|
+
# Text values from different nodes can legitimately be equal, so only dedupe nodes.
|
|
177
|
+
@text ? results : results.uniq
|
|
69
178
|
end
|
|
70
179
|
end
|
|
71
180
|
end
|
data/lib/xml_node_stream.rb
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "xml_node_stream/node"
|
|
8
|
+
require_relative "xml_node_stream/parser"
|
|
9
|
+
require_relative "xml_node_stream/selector"
|
|
4
10
|
|
|
5
11
|
module XmlNodeStream
|
|
12
|
+
VERSION = File.read(File.expand_path("../VERSION", __dir__)).strip
|
|
13
|
+
|
|
6
14
|
# Helper method to parse XML. See Parser#parse for details.
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
#
|
|
16
|
+
# @param io [IO, String, URI, Pathname] the input source to parse
|
|
17
|
+
# @yield [Node] each node as it is parsed
|
|
18
|
+
# @return [Node] the root node of the parsed document
|
|
19
|
+
class << self
|
|
20
|
+
def parse(io, &block)
|
|
21
|
+
Parser.parse(io, &block)
|
|
22
|
+
end
|
|
9
23
|
end
|
|
10
24
|
end
|