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.
@@ -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
- def initialize (&block)
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
- def parse_stream (io)
16
- raise NotImplementedError.new("could not load gem")
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
- def do_start_element (name, attributes)
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
- def do_end_element (name)
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.call(node) if @parse_block
55
+ @parse_block&.call(node)
29
56
  end
30
57
 
31
- def do_characters (characters)
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
- def do_cdata_block (characters)
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 'libxml'
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
- def parse_stream (io)
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
- def on_start_element (name, attributes)
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
- def on_end_element (name)
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
- def on_characters (characters)
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
- def on_cdata_block (characters)
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 'nokogiri'
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
- def parse_stream (io)
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
- def initialize (parser)
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
- def start_element (name, attributes = [])
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
- def end_element (name)
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
- def characters (characters)
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
- def cdata_block (characters)
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 'rexml/document'
3
- require 'rexml/streamlistener'
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
- def parse_stream (io)
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
- def tag_start (name, attributes)
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
- def tag_end (name)
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
- def text (content)
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
- def cdata (content)
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
- require 'open-uri'
2
- require 'rubygems'
3
- require 'pathname'
4
- require File.expand_path(File.join(File.dirname(__FILE__), 'parser', 'base'))
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
- def parser_name= (parser)
17
- parser_sym = parser.to_sym
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
- def parse (io, &block)
31
- close_stream = false
32
- if io.is_a?(String)
33
- if io.include?('<') and io.include?('>')
34
- io = StringIO.new(io)
35
- else
36
- io = open(io)
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
- close_stream = true
51
+ io = File.open(io, "r:UTF-8")
39
52
  elsif io.is_a?(Pathname)
40
- io = io.open
41
- close_stream = true
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.open
44
- close_stream = true
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
- return parser.root
66
+ parser.root
51
67
  ensure
52
- io.close if close_stream
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 (class_symbol)
80
+
81
+ def parser_class(class_symbol)
59
82
  @loaded_parsers ||= {}
60
83
  klass = @loaded_parsers[class_symbol]
61
84
  unless klass
62
- require File.expand_path(File.join(File.dirname(__FILE__), 'parser', "#{class_symbol}_parser"))
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
- return klass
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
- def initialize (path)
18
- @parts = []
19
- path.gsub('//', '/%/').split('/').each do |part_path|
20
- part_matchers = []
21
- @parts << part_matchers
22
- or_paths = part_path.split('|')
23
- or_paths << "" if or_paths.empty?
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
- def find (node)
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
- return matched
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
- def initialize (path)
48
- case path
49
- when 'text()'
50
- @extractor = lambda{|node| node.value}
51
- when '%'
52
- @extractor = lambda{|node| node.descendants}
53
- when '*'
54
- @extractor = lambda{|node| node.children}
55
- when '.'
56
- @extractor = lambda{|node| node}
57
- when '..'
58
- @extractor = lambda{|node| node.parent ? node.parent : []}
59
- when ''
60
- @extractor = lambda{|node| root = Node.new(nil); root.children << node.root; root}
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
- @extractor = lambda{|node| node.children.select{|child| child.name == path}}
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
- def select (context_nodes)
68
- context_nodes.collect{|node| @extractor.call(node) if node.is_a?(Node)}.flatten
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
@@ -1,10 +1,24 @@
1
- require File.expand_path(File.join(File.dirname(__FILE__), 'xml_node_stream', 'node'))
2
- require File.expand_path(File.join(File.dirname(__FILE__), 'xml_node_stream', 'parser'))
3
- require File.expand_path(File.join(File.dirname(__FILE__), 'xml_node_stream', 'selector'))
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
- def self.parse (io, &block)
8
- Parser.parse(io, &block)
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