multi_xml 0.6.0 → 0.9.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.
@@ -0,0 +1,188 @@
1
+ require "libxml-ruby"
2
+ require "stringio"
3
+ require_relative "sax_handler"
4
+ require_relative "libxml"
5
+
6
+ module MultiXML
7
+ module Parsers
8
+ # SAX-based parser using LibXML (faster for large documents)
9
+ #
10
+ # @api private
11
+ module LibxmlSax
12
+ extend MultiXML::Parser
13
+
14
+ module_function
15
+
16
+ # Exception class raised on LibXML parse failure
17
+ # @api private
18
+ ParseError = ::LibXML::XML::Error
19
+
20
+ # Parse XML from a string or IO object
21
+ #
22
+ # @api private
23
+ # @param xml [String, IO] XML content
24
+ # @param namespaces [Symbol] Namespace handling mode
25
+ # @return [Hash] Parsed XML as a hash
26
+ # @raise [LibXML::XML::Error] if XML is malformed
27
+ def parse(xml, namespaces: :strip)
28
+ source = xml.respond_to?(:read) ? xml.read : xml.to_s
29
+ return {} if source.empty?
30
+
31
+ return parse_with_dom(source, namespaces) if dom_fallback?(source, namespaces)
32
+
33
+ parse_with_sax(source, namespaces)
34
+ end
35
+
36
+ # Detect whether a start tag has attributes that collide after stripping
37
+ #
38
+ # @api private
39
+ # @param source [String] XML source
40
+ # @return [Boolean] true when stripped attribute locals collide
41
+ def stripped_attribute_collision?(source)
42
+ source.scan(%r{<(?![!?/])[^>]*>}m).any? do |tag|
43
+ local_names = attribute_names(tag).map { |name| name.split(":", 2).last }
44
+ local_names.uniq.length < local_names.length
45
+ end
46
+ end
47
+
48
+ # Extract non-xmlns attribute names from a start tag
49
+ #
50
+ # @api private
51
+ # @param tag [String] Start tag source
52
+ # @return [Array<String>] attribute names
53
+ def attribute_names(tag)
54
+ tag.scan(/\s([a-zA-Z_][\w.-]*(?::[a-zA-Z_][\w.-]*)?)\s*=/).flatten.reject do |name|
55
+ name == "xmlns" || name.start_with?("xmlns:")
56
+ end
57
+ end
58
+
59
+ # Determine whether libxml_sax must fall back to the DOM parser
60
+ #
61
+ # @api private
62
+ # @param source [String] XML source
63
+ # @param namespaces [Symbol] Namespace handling mode
64
+ # @return [Boolean] true when DOM parsing is required
65
+ def dom_fallback?(source, namespaces)
66
+ namespaces != :strip || stripped_attribute_collision?(source)
67
+ end
68
+
69
+ # Parse via the DOM libxml backend
70
+ #
71
+ # @api private
72
+ # @param source [String] XML source
73
+ # @param namespaces [Symbol] Namespace handling mode
74
+ # @return [Hash] Parsed XML as a hash
75
+ def parse_with_dom(source, namespaces)
76
+ Libxml.parse(StringIO.new(source), namespaces: namespaces)
77
+ end
78
+
79
+ # Parse via libxml-ruby's SAX parser
80
+ #
81
+ # @api private
82
+ # @param source [String] XML source
83
+ # @param namespaces [Symbol] Namespace handling mode
84
+ # @return [Hash] Parsed XML as a hash
85
+ def parse_with_sax(source, namespaces)
86
+ LibXML::XML::Error.set_handler(&LibXML::XML::Error::QUIET_HANDLER)
87
+ handler = Handler.new(namespaces)
88
+ parser = ::LibXML::XML::SaxParser.io(StringIO.new(source))
89
+ parser.callbacks = handler
90
+ parser.parse
91
+ handler.result
92
+ end
93
+
94
+ # LibXML SAX handler.
95
+ #
96
+ # libxml-ruby's namespace-aware callback strips prefixes from the attrs
97
+ # hash, so we rely on the qname-preserving `on_start_element` callback
98
+ # and resolve namespaces via SaxHandler's scope stack.
99
+ #
100
+ # @api private
101
+ class Handler
102
+ include ::LibXML::XML::SaxParser::Callbacks
103
+ include SaxHandler
104
+
105
+ # Create a new SAX handler
106
+ #
107
+ # @api private
108
+ # @param mode [Symbol] Namespace handling mode
109
+ # @return [Handler] new handler instance
110
+ def initialize(mode)
111
+ initialize_handler(mode)
112
+ end
113
+
114
+ # Handle start of document (no-op)
115
+ #
116
+ # @api private
117
+ # @return [void]
118
+ def on_start_document
119
+ end
120
+
121
+ # Handle end of document (no-op)
122
+ #
123
+ # @api private
124
+ # @return [void]
125
+ def on_end_document
126
+ end
127
+
128
+ # Handle parse errors (no-op; libxml-ruby raises directly)
129
+ #
130
+ # @api private
131
+ # @param _error [String] Error message (unused)
132
+ # @return [void]
133
+ def on_error(_error)
134
+ end
135
+
136
+ # Handle start of an element
137
+ #
138
+ # libxml-ruby strips xmlns declarations from attrs and passes through
139
+ # prefixed names for regular attributes. Since libxml_sax only uses
140
+ # this handler in :strip mode, we route through the namespace-aware
141
+ # entrypoint with empty ns_decls and treat attribute qnames as-if
142
+ # they had no namespace — matching the desired :strip output.
143
+ #
144
+ # @api private
145
+ # @param name [String] Element name (possibly prefixed)
146
+ # @param attrs [Hash] Attributes as name => value
147
+ # @return [void]
148
+ def on_start_element(name, attrs = {})
149
+ prefix, local = sax_split_qname(name.to_s)
150
+ tuples = attrs.map do |k, v|
151
+ ap, al = sax_split_qname(k.to_s)
152
+ [ap, al, v]
153
+ end
154
+ handle_start_element_ns(local, prefix, tuples, [])
155
+ end
156
+
157
+ # Handle end of an element
158
+ #
159
+ # @api private
160
+ # @param _name [String] Element name (unused)
161
+ # @return [void]
162
+ def on_end_element(_name)
163
+ handle_end_element
164
+ end
165
+
166
+ private
167
+
168
+ # Split a prefixed name into [prefix, local]
169
+ #
170
+ # @api private
171
+ # @param name [String] Prefixed or local name
172
+ # @return [Array<String, nil>] prefix and local name
173
+ def sax_split_qname(name)
174
+ p, l = name.split(":", 2)
175
+ l ? [p, l] : [nil, p]
176
+ end
177
+
178
+ # Handle character data (also aliased as `on_cdata_block`)
179
+ #
180
+ # @api private
181
+ # @param text [String] Text content
182
+ # @return [void]
183
+ def on_characters(text) = append_text(text)
184
+ alias_method :on_cdata_block, :on_characters
185
+ end
186
+ end
187
+ end
188
+ end
@@ -1,34 +1,75 @@
1
- require 'nokogiri' unless defined?(Nokogiri)
2
- require 'multi_xml/parsers/libxml2_parser'
1
+ require "nokogiri"
2
+ require_relative "dom_parser"
3
3
 
4
- module MultiXml
4
+ module MultiXML
5
5
  module Parsers
6
- module Nokogiri #:nodoc:
7
- include Libxml2Parser
6
+ # XML parser using the Nokogiri library
7
+ #
8
+ # @api private
9
+ module Nokogiri
10
+ extend MultiXML::Parser
11
+ include DomParser
8
12
  extend self
9
13
 
10
- def parse_error
11
- ::Nokogiri::XML::SyntaxError
12
- end
14
+ # Exception class raised on Nokogiri parse failure
15
+ # @api private
16
+ ParseError = ::Nokogiri::XML::SyntaxError
17
+
18
+ # Parse XML from an IO object
19
+ #
20
+ # @api private
21
+ # @param io [IO] IO-like object containing XML
22
+ # @param namespaces [Symbol] Namespace handling mode
23
+ # @return [Hash] Parsed XML as a hash
24
+ # @raise [Nokogiri::XML::SyntaxError] if XML is malformed
25
+ def parse(io, namespaces: :strip)
26
+ doc = ::Nokogiri::XML(io)
27
+ raise doc.errors.first unless doc.errors.empty?
13
28
 
14
- def parse(xml)
15
- doc = ::Nokogiri::XML(xml)
16
- raise(doc.errors.first) unless doc.errors.empty?
17
- node_to_hash(doc.root)
29
+ node_to_hash(doc.root, mode: namespaces)
18
30
  end
19
31
 
20
- private
32
+ private
33
+
34
+ # Iterate over child nodes
35
+ #
36
+ # @api private
37
+ # @param node [Nokogiri::XML::Node] Parent node
38
+ # @return [void]
39
+ def each_child(node, &) = node.children.each(&)
40
+
41
+ # Iterate over attribute nodes (excludes xmlns declarations)
42
+ #
43
+ # @api private
44
+ # @param node [Nokogiri::XML::Node] Element node
45
+ # @return [void]
46
+ def each_element_attr(node, &) = node.attribute_nodes.each(&)
21
47
 
22
- def each_child(node, &block)
23
- node.children.each(&block)
48
+ # Yield each xmlns declaration on this element
49
+ #
50
+ # @api private
51
+ # @param node [Nokogiri::XML::Node] Element node
52
+ # @return [void]
53
+ def each_namespace_decl(node)
54
+ node.namespace_definitions.each { |ns| yield ns.prefix, ns.href }
24
55
  end
25
56
 
26
- def each_attr(node, &block)
27
- node.attribute_nodes.each(&block)
57
+ # Return [prefix, local] for an element
58
+ #
59
+ # @api private
60
+ # @param node [Nokogiri::XML::Node] Element node
61
+ # @return [Array<String, nil>] prefix and local name
62
+ def element_parts(node)
63
+ [node.namespace&.prefix, node.name]
28
64
  end
29
65
 
30
- def node_name(node)
31
- node.node_name
66
+ # Return [prefix, local] for an attribute
67
+ #
68
+ # @api private
69
+ # @param attr [Nokogiri::XML::Attr] Attribute node
70
+ # @return [Array<String, nil>] prefix and local name
71
+ def attr_parts(attr)
72
+ [attr.namespace&.prefix, attr.name]
32
73
  end
33
74
  end
34
75
  end
@@ -0,0 +1,130 @@
1
+ require "nokogiri"
2
+ require "stringio"
3
+ require_relative "sax_handler"
4
+
5
+ module MultiXML
6
+ module Parsers
7
+ # SAX-based parser using Nokogiri (faster for large documents)
8
+ #
9
+ # @api private
10
+ module NokogiriSax
11
+ extend MultiXML::Parser
12
+
13
+ module_function
14
+
15
+ # Exception class raised on Nokogiri parse failure
16
+ # @api private
17
+ ParseError = ::Nokogiri::XML::SyntaxError
18
+
19
+ # Parse XML from a string or IO object
20
+ #
21
+ # @api private
22
+ # @param xml [String, IO] XML content
23
+ # @param namespaces [Symbol] Namespace handling mode
24
+ # @return [Hash] Parsed XML as a hash
25
+ # @raise [Nokogiri::XML::SyntaxError] if XML is malformed
26
+ def parse(xml, namespaces: :strip)
27
+ io = xml.respond_to?(:read) ? xml : StringIO.new(xml)
28
+ return {} if io.eof?
29
+
30
+ handler = Handler.new(namespaces)
31
+ ::Nokogiri::XML::SAX::Parser.new(handler).parse(io)
32
+ handler.result
33
+ end
34
+
35
+ # Nokogiri SAX handler.
36
+ #
37
+ # Nokogiri always invokes `start_element_namespace` (even for documents
38
+ # without namespaces — prefix/uri come through as nil). We don't define
39
+ # `start_element` because it would never fire.
40
+ #
41
+ # @api private
42
+ class Handler < ::Nokogiri::XML::SAX::Document
43
+ include SaxHandler
44
+
45
+ # Create a new SAX handler
46
+ #
47
+ # @api private
48
+ # @param mode [Symbol] Namespace handling mode
49
+ # @return [Handler] new handler instance
50
+ def initialize(mode)
51
+ super()
52
+ initialize_handler(mode)
53
+ end
54
+
55
+ # Handle start of document (no-op)
56
+ #
57
+ # @api private
58
+ # @return [void]
59
+ def start_document
60
+ end
61
+
62
+ # Handle end of document (no-op)
63
+ #
64
+ # @api private
65
+ # @return [void]
66
+ def end_document
67
+ end
68
+
69
+ # Handle parse errors
70
+ #
71
+ # @api private
72
+ # @param message [String] Error message
73
+ # @return [void]
74
+ # @raise [Nokogiri::XML::SyntaxError] always
75
+ def error(message)
76
+ raise ::Nokogiri::XML::SyntaxError, message
77
+ end
78
+
79
+ # Handle start of a namespaced element
80
+ #
81
+ # Signature is fixed by the Nokogiri SAX protocol.
82
+ #
83
+ # @api private
84
+ # @param local [String] Local element name
85
+ # @param attrs [Array<Nokogiri::XML::SAX::Parser::Attribute>] Attributes
86
+ # @param prefix [String, nil] Element namespace prefix
87
+ # @param _uri [String, nil] Element namespace URI (unused)
88
+ # @param ns [Array] Namespace declarations as [prefix, uri] pairs
89
+ # @return [void]
90
+ # rubocop:disable Metrics/ParameterLists, Naming/MethodParameterName
91
+ def start_element_namespace(local, attrs = [], prefix = nil, _uri = nil, ns = [])
92
+ ns_decls = ns.map { |p, u| [normalize(p), u] }
93
+ attr_tuples = attrs.map { |a| [normalize(a.prefix), a.localname, a.value] }
94
+ handle_start_element_ns(local, normalize(prefix), attr_tuples, ns_decls)
95
+ end
96
+ # rubocop:enable Metrics/ParameterLists, Naming/MethodParameterName
97
+
98
+ # Handle end of a namespaced element
99
+ #
100
+ # @api private
101
+ # @param _local [String] Local element name (unused)
102
+ # @param _prefix [String, nil] Namespace prefix (unused)
103
+ # @param _uri [String, nil] Namespace URI (unused)
104
+ # @return [void]
105
+ def end_element_namespace(_local, _prefix = nil, _uri = nil)
106
+ handle_end_element
107
+ end
108
+
109
+ # Handle character data
110
+ #
111
+ # @api private
112
+ # @param text [String] Text content
113
+ # @return [void]
114
+ def characters(text) = append_text(text)
115
+ alias_method :cdata_block, :characters
116
+
117
+ private
118
+
119
+ # Normalize a value, returning nil for empty or nil input
120
+ #
121
+ # @api private
122
+ # @param value [String, nil] Value to normalize
123
+ # @return [String, nil] value or nil if empty
124
+ def normalize(value)
125
+ (value.nil? || value.to_s.empty?) ? nil : value
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,73 +1,140 @@
1
- require 'oga' unless defined?(Oga)
2
- require 'multi_xml/parsers/libxml2_parser'
1
+ require "oga"
2
+ require_relative "dom_parser"
3
3
 
4
- module MultiXml
4
+ module MultiXML
5
5
  module Parsers
6
- module Oga #:nodoc:
7
- include Libxml2Parser
6
+ # XML parser using the Oga library
7
+ #
8
+ # @api private
9
+ module Oga
10
+ extend MultiXML::Parser
11
+ include DomParser
8
12
  extend self
9
13
 
10
- def parse_error
11
- LL::ParserError
14
+ # Exception class raised on Oga parse failure
15
+ # @api private
16
+ ParseError = LL::ParserError
17
+
18
+ # Parse XML from an IO object
19
+ #
20
+ # @api private
21
+ # @param io [IO] IO-like object containing XML
22
+ # @param namespaces [Symbol] Namespace handling mode
23
+ # @return [Hash] Parsed XML as a hash
24
+ # @raise [LL::ParserError] if XML is malformed
25
+ def parse(io, namespaces: :strip)
26
+ doc = ::Oga.parse_xml(io)
27
+ node_to_hash(doc.children.first, mode: namespaces)
12
28
  end
13
29
 
14
- def parse(io)
15
- document = ::Oga.parse_xml(io)
16
- node_to_hash(document.children[0])
30
+ # Collect child nodes into a hash (Oga-specific implementation)
31
+ #
32
+ # Oga uses different node types than Nokogiri/LibXML.
33
+ #
34
+ # @api private
35
+ # @param node [Oga::XML::Element] Parent node
36
+ # @param node_hash [Hash] Hash to populate
37
+ # @param mode [Symbol] Namespace handling mode
38
+ # @return [void]
39
+ def collect_children(node, node_hash, mode)
40
+ each_child(node) do |child|
41
+ case child
42
+ when ::Oga::XML::Element then node_to_hash(child, node_hash, mode: mode)
43
+ when ::Oga::XML::Text, ::Oga::XML::Cdata then node_hash[TEXT_CONTENT_KEY] << child.text
44
+ end
45
+ end
17
46
  end
18
47
 
19
- def node_to_hash(node, hash = {}) # rubocop:disable AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity
20
- node_hash = {MultiXml::CONTENT_ROOT => ''}
48
+ private
21
49
 
22
- name = node_name(node)
50
+ # Iterate over child nodes
51
+ #
52
+ # @api private
53
+ # @param node [Oga::XML::Element] Parent node
54
+ # @return [void]
55
+ def each_child(node, &) = node.children.each(&)
23
56
 
24
- # Insert node hash into parent hash correctly.
25
- case hash[name]
26
- when Array
27
- hash[name] << node_hash
28
- when Hash
29
- hash[name] = [hash[name], node_hash]
30
- when NilClass
31
- hash[name] = node_hash
32
- end
57
+ # Iterate over attribute nodes (excludes xmlns declarations)
58
+ #
59
+ # @api private
60
+ # @param node [Oga::XML::Element] Element node
61
+ # @return [void]
62
+ def each_element_attr(node)
63
+ node.attributes.each do |attr|
64
+ next if oga_xmlns_attr?(attr)
33
65
 
34
- # Handle child elements
35
- each_child(node) do |c|
36
- if c.is_a?(::Oga::XML::Element)
37
- node_to_hash(c, node_hash)
38
- elsif c.is_a?(::Oga::XML::Text) || c.is_a?(::Oga::XML::Cdata)
39
- node_hash[MultiXml::CONTENT_ROOT] << c.text
40
- end
66
+ yield attr
41
67
  end
68
+ end
42
69
 
43
- # Remove content node if it is empty
44
- if node_hash[MultiXml::CONTENT_ROOT].strip.empty?
45
- node_hash.delete(MultiXml::CONTENT_ROOT)
70
+ # Yield each xmlns declaration on this element
71
+ #
72
+ # Oga stores only locally declared namespaces on each element
73
+ # (inherited ones are resolved via lookup, not merged into
74
+ # #namespaces), so we can yield them directly.
75
+ #
76
+ # @api private
77
+ # @param node [Oga::XML::Element] Element node
78
+ # @return [void]
79
+ def each_namespace_decl(node)
80
+ namespace_scope(node).each do |key, ns|
81
+ prefix = (key == "xmlns") ? nil : key
82
+ yield prefix, ns.uri
46
83
  end
84
+ end
47
85
 
48
- # Handle attributes
49
- each_attr(node) do |a|
50
- key = node_name(a)
51
- v = node_hash[key]
52
- node_hash[key] = (v ? [a.value, v] : a.value)
53
- end
86
+ # Return [prefix, local] for an element
87
+ #
88
+ # @api private
89
+ # @param node [Oga::XML::Element] Element node
90
+ # @return [Array<String, nil>] prefix and local name
91
+ def element_parts(node)
92
+ [oga_prefix(node.namespace), node.name]
93
+ end
54
94
 
55
- hash
95
+ # Return [prefix, local] for an attribute
96
+ #
97
+ # @api private
98
+ # @param attr [Oga::XML::Attribute] Attribute node
99
+ # @return [Array<String, nil>] prefix and local name
100
+ def attr_parts(attr)
101
+ [oga_prefix(attr.namespace), attr.name]
56
102
  end
57
103
 
58
- private
104
+ # Translate Oga's default-namespace sentinel to nil
105
+ #
106
+ # Oga represents the default namespace with the sentinel name "xmlns";
107
+ # we translate that to nil so it isn't emitted as a prefix.
108
+ #
109
+ # @api private
110
+ # @param namespace [Oga::XML::Namespace, nil] Namespace object
111
+ # @return [String, nil] prefix or nil
112
+ def oga_prefix(namespace)
113
+ return nil unless namespace
59
114
 
60
- def each_child(node, &block)
61
- node.children.each(&block)
115
+ (namespace.name == "xmlns") ? nil : namespace.name
62
116
  end
63
117
 
64
- def each_attr(node, &block)
65
- node.attributes.each(&block)
118
+ # Check whether an Oga attribute is actually an xmlns declaration
119
+ #
120
+ # Oga exposes xmlns declarations via Element#namespaces but may also
121
+ # surface them as raw attributes in some cases — filter either shape.
122
+ #
123
+ # @api private
124
+ # @param attr [Oga::XML::Attribute] Attribute node
125
+ # @return [Boolean] true if it's an xmlns declaration
126
+ def oga_xmlns_attr?(attr)
127
+ attr.name == "xmlns" || attr.namespace_name == "xmlns"
66
128
  end
67
129
 
68
- def node_name(node)
69
- node.name
130
+ # Local namespace scope for a node
131
+ #
132
+ # @api private
133
+ # @param node [Oga::XML::Element] Element node
134
+ # @return [Hash{String => Oga::XML::Namespace}] scope
135
+ def namespace_scope(node)
136
+ node.namespaces || {}
70
137
  end
71
- end # Oga
72
- end # Parsers
73
- end # MultiXml
138
+ end
139
+ end
140
+ end