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.
@@ -1,91 +1,216 @@
1
- require 'ox' unless defined?(Ox)
2
-
3
- # Each MultiXml parser is expected to parse an XML document into a Hash. The
4
- # conversion rules are:
5
- #
6
- # - Each document starts out as an empty Hash.
7
- #
8
- # - Reading an element created an entry in the parent Hash that has a key of
9
- # the element name and a value of a Hash with attributes as key value
10
- # pairs. Children are added as described by this rule.
11
- #
12
- # - Text and CDATE is stored in the parent element Hash with a key of
13
- # MultiXml::CONTENT_ROOT and a value of the text itself.
14
- #
15
- # - If a key already exists in the Hash then the value associated with the key
16
- # is converted to an Array with the old and new value in it.
17
- #
18
- # - Other elements such as the xml prolog, doctype, and comments are ignored.
19
- #
20
-
21
- module MultiXml
1
+ require "ox"
2
+
3
+ module MultiXML
22
4
  module Parsers
23
- module Ox #:nodoc:
24
- extend self
5
+ # XML parser using the Ox library (fastest pure-Ruby parser)
6
+ #
7
+ # @api private
8
+ module Ox
9
+ extend MultiXML::Parser
25
10
 
26
- def parse_error
27
- Exception
28
- end
11
+ module_function
29
12
 
30
- def parse(io)
31
- handler = Handler.new
32
- ::Ox.sax_parse(handler, io, :convert_special => true)
33
- handler.doc
13
+ # Exception class raised on Ox parse failure
14
+ # @api private
15
+ ParseError = ::Ox::ParseError
16
+
17
+ # Parse XML from an IO object
18
+ #
19
+ # @api private
20
+ # @param io [IO] IO-like object containing XML
21
+ # @param namespaces [Symbol] Namespace handling mode
22
+ # @return [Hash] Parsed XML as a hash
23
+ def parse(io, namespaces: :strip)
24
+ handler = Handler.new(namespaces)
25
+ ::Ox.sax_parse(handler, io, convert_special: true, skip: :skip_return)
26
+ handler.result
34
27
  end
35
28
 
29
+ # SAX event handler that builds a hash tree while parsing.
30
+ #
31
+ # Ox's SAX callbacks expose element and attribute names in prefixed
32
+ # form (e.g. "atom:feed"). Under :preserve we keep the source form
33
+ # verbatim; under :strip we drop the prefix and filter xmlns
34
+ # declarations out of the attribute stream.
35
+ #
36
+ # @api private
36
37
  class Handler
37
- attr_accessor :stack
38
-
39
- def initialize
40
- @stack = []
38
+ # Create a new SAX handler
39
+ #
40
+ # @api private
41
+ # @param mode [Symbol] Namespace handling mode
42
+ # @return [Handler] new handler instance
43
+ def initialize(mode)
44
+ @mode = mode
45
+ @stack = [{}]
41
46
  end
42
47
 
43
- def doc
44
- @stack[0]
48
+ # Get the parsed result
49
+ #
50
+ # @api private
51
+ # @return [Hash] the parsed hash
52
+ def result = @stack.first
53
+
54
+ # Handle start of an element
55
+ #
56
+ # @api private
57
+ # @param name [Symbol, String] Element name
58
+ # @return [void]
59
+ def start_element(name)
60
+ child = {}
61
+ add_value(current, format_name(name.to_s), child)
62
+ @stack << child
45
63
  end
46
64
 
65
+ # Handle an attribute
66
+ #
67
+ # Ignored outside an element (e.g. attributes on the XML declaration
68
+ # such as `<?xml version="1.0"?>`, which fire before any `start_element`).
69
+ #
70
+ # @api private
71
+ # @param name [Symbol, String] Attribute name
72
+ # @param value [String] Attribute value
73
+ # @return [void]
47
74
  def attr(name, value)
48
- append(name, value) unless @stack.empty?
75
+ return if @stack.size < 2
76
+
77
+ name = name.to_s
78
+ return if xmlns_decl?(name) && @mode != :preserve
79
+
80
+ add_attribute_value(current, format_name(name), value)
49
81
  end
50
82
 
51
- def text(value)
52
- append(MultiXml::CONTENT_ROOT, value)
83
+ # Handle text content (also aliased as `cdata`)
84
+ #
85
+ # @api private
86
+ # @param value [String] Text content
87
+ # @return [void]
88
+ def text(value) = append_text(current, value)
89
+ alias_method :cdata, :text
90
+
91
+ # Handle end of an element
92
+ #
93
+ # @api private
94
+ # @param _name [Symbol, String] Element name (unused)
95
+ # @return [void]
96
+ def end_element(_name)
97
+ strip_whitespace_content if current.key?(TEXT_CONTENT_KEY)
98
+ @stack.pop
53
99
  end
54
100
 
55
- def cdata(value)
56
- append(MultiXml::CONTENT_ROOT, value)
101
+ # Handle parse errors
102
+ #
103
+ # @api private
104
+ # @param message [String] Error message
105
+ # @param line [Integer] Line number
106
+ # @param column [Integer] Column number
107
+ # @return [void]
108
+ # @raise [Ox::ParseError] always
109
+ def error(message, line, column)
110
+ raise ::Ox::ParseError, "#{message} at #{line}:#{column}"
57
111
  end
58
112
 
59
- def start_element(name)
60
- @stack.push({}) if @stack.empty?
61
- h = {}
62
- append(name, h)
63
- @stack.push(h)
113
+ private
114
+
115
+ # Current element hash on top of the stack
116
+ #
117
+ # @api private
118
+ # @return [Hash] current hash being built
119
+ def current = @stack.last
120
+
121
+ # Format a prefixed-or-local name according to the namespace mode
122
+ #
123
+ # @api private
124
+ # @param name [String] Prefixed or local name
125
+ # @return [String] formatted name
126
+ def format_name(name)
127
+ (@mode == :preserve) ? name : name.split(":", 2).last
64
128
  end
65
129
 
66
- def end_element(_)
67
- @stack.pop
130
+ # Check whether an attribute name is an xmlns declaration
131
+ #
132
+ # @api private
133
+ # @param name [String] Attribute name
134
+ # @return [Boolean] true if xmlns or xmlns:*
135
+ def xmlns_decl?(name)
136
+ name == "xmlns" || name.start_with?("xmlns:")
68
137
  end
69
138
 
70
- def error(message, line, column)
71
- raise(Exception.new("#{message} at #{line}:#{column}"))
139
+ # Add a value to a hash, folding into an array on collision
140
+ #
141
+ # @api private
142
+ # @param hash [Hash] Target hash
143
+ # @param key [String] Key to add
144
+ # @param value [Object] Value to add
145
+ # @return [void]
146
+ def add_value(hash, key, value)
147
+ existing = hash[key]
148
+ hash[key] = existing ? merge_values(existing, value) : value
149
+ end
150
+
151
+ # Append a text fragment to the current node's content
152
+ #
153
+ # SAX parsers may deliver element text in multiple callbacks when
154
+ # inline elements split the text stream. MultiXML represents that
155
+ # as one concatenated ``__content__`` string, not an array.
156
+ #
157
+ # @api private
158
+ # @param hash [Hash] Target hash
159
+ # @param value [String] Text fragment
160
+ # @return [void]
161
+ def append_text(hash, value)
162
+ existing = hash[TEXT_CONTENT_KEY]
163
+ hash[TEXT_CONTENT_KEY] = existing ? "#{existing}#{value}" : value
164
+ end
165
+
166
+ # Merge a value with an existing value, creating an array if needed
167
+ #
168
+ # @api private
169
+ # @param existing [Object] Existing value
170
+ # @param value [Object] Value to append
171
+ # @return [Array] array with both values
172
+ def merge_values(existing, value)
173
+ existing.is_a?(Array) ? existing << value : [existing, value]
72
174
  end
73
175
 
74
- def append(key, value)
75
- key = key.to_s
76
- h = @stack.last
77
- if h.key?(key)
78
- v = h[key]
79
- if v.is_a?(Array)
80
- v << value
81
- else
82
- h[key] = [v, value]
83
- end
84
- else
85
- h[key] = value
176
+ # Add an attribute value while keeping document order on collisions
177
+ #
178
+ # @api private
179
+ # @param hash [Hash] Target hash
180
+ # @param key [String] Attribute key
181
+ # @param value [String] Attribute value
182
+ # @return [void]
183
+ def add_attribute_value(hash, key, value)
184
+ existing = hash[key]
185
+ hash[key] = case existing
186
+ when nil then value
187
+ when Array then insert_attribute_before_children(existing, value)
188
+ when Hash then [value, existing]
189
+ else [existing, value]
86
190
  end
87
191
  end
88
- end # Handler
89
- end # Ox
90
- end # Parsers
91
- end # MultiXml
192
+
193
+ # Insert a later attribute before any child-element entries
194
+ #
195
+ # @api private
196
+ # @param values [Array] Existing colliding values
197
+ # @param value [String] Attribute value to insert
198
+ # @return [Array] Updated value list
199
+ def insert_attribute_before_children(values, value)
200
+ child_index = values.index { |entry| entry.is_a?(Hash) } || values.length
201
+ values.dup.insert(child_index, value)
202
+ end
203
+
204
+ # Remove empty or whitespace-only text content from the current hash
205
+ #
206
+ # @api private
207
+ # @return [void]
208
+ def strip_whitespace_content
209
+ content = current[TEXT_CONTENT_KEY]
210
+ should_remove = content.empty? || (current.size > 1 && content.strip.empty?)
211
+ current.delete(TEXT_CONTENT_KEY) if should_remove
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -1,111 +1,205 @@
1
- require 'rexml/document' unless defined?(REXML::Document)
1
+ require "rexml/document"
2
2
 
3
- module MultiXml
3
+ module MultiXML
4
4
  module Parsers
5
- module Rexml #:nodoc:
5
+ # XML parser using Ruby's built-in REXML library
6
+ #
7
+ # @api private
8
+ module Rexml
9
+ extend MultiXML::Parser
6
10
  extend self
7
11
 
8
- def parse_error
9
- ::REXML::ParseException
12
+ # Exception class raised on REXML parse failure
13
+ # @api private
14
+ ParseError = ::REXML::ParseException
15
+
16
+ # Parse XML from an IO object
17
+ #
18
+ # @api private
19
+ # @param io [IO] IO-like object containing XML
20
+ # @param namespaces [Symbol] Namespace handling mode
21
+ # @return [Hash] Parsed XML as a hash
22
+ # @raise [REXML::ParseException] if XML is malformed
23
+ def parse(io, namespaces: :strip)
24
+ doc = REXML::Document.new(io)
25
+ element_to_hash({}, doc.root, namespaces)
10
26
  end
11
27
 
12
- # Parse an XML Document IO into a simple hash using REXML
28
+ private
29
+
30
+ # Convert an element to hash format
13
31
  #
14
- # xml::
15
- # XML Document IO to parse
16
- def parse(xml)
17
- doc = REXML::Document.new(xml)
18
- raise(REXML::ParseException.new("The document #{doc.to_s.inspect} does not have a valid root")) unless doc.root
19
- merge_element!({}, doc.root)
32
+ # @api private
33
+ # @param hash [Hash] Accumulator hash
34
+ # @param element [REXML::Element] Element to convert
35
+ # @param mode [Symbol] Namespace handling mode
36
+ # @return [Hash] Updated hash
37
+ def element_to_hash(hash, element, mode)
38
+ add_to_hash(hash, format_element_name(element, mode), collapse_element(element, mode))
20
39
  end
21
40
 
22
- private
41
+ # Format element name using prefix/local and namespace mode
42
+ #
43
+ # @api private
44
+ # @param element [REXML::Element] Element node
45
+ # @param mode [Symbol] Namespace handling mode
46
+ # @return [String] formatted element name
47
+ def format_element_name(element, mode)
48
+ format_name(element.prefix, element.name, mode)
49
+ end
23
50
 
24
- # Convert an XML element and merge into the hash
51
+ # Format attribute name using prefix/local and namespace mode
25
52
  #
26
- # hash::
27
- # Hash to merge the converted element into.
28
- # element::
29
- # XML element to merge into hash
30
- def merge_element!(hash, element)
31
- merge!(hash, element.name, collapse(element))
53
+ # @api private
54
+ # @param attr [REXML::Attribute] Attribute node
55
+ # @param mode [Symbol] Namespace handling mode
56
+ # @return [String] formatted attribute name
57
+ def format_attr_name(attr, mode)
58
+ format_name(attr.prefix, attr.name, mode)
32
59
  end
33
60
 
34
- # Actually converts an XML document element into a data structure.
61
+ # Produce a name string for a given [prefix, local] tuple
35
62
  #
36
- # element::
37
- # The document element to be collapsed.
38
- def collapse(element)
39
- hash = get_attributes(element)
63
+ # @api private
64
+ # @param prefix [String, nil] Namespace prefix
65
+ # @param local [String] Local part of the name
66
+ # @param mode [Symbol] Namespace handling mode
67
+ # @return [String] formatted name
68
+ def format_name(prefix, local, mode)
69
+ (mode == :preserve && prefix && !prefix.empty?) ? "#{prefix}:#{local}" : local
70
+ end
71
+
72
+ # Collapse an element into a hash with attributes and content
73
+ #
74
+ # @api private
75
+ # @param element [REXML::Element] Element to collapse
76
+ # @param mode [Symbol] Namespace handling mode
77
+ # @return [Hash] Hash representation
78
+ def collapse_element(element, mode)
79
+ node_hash = collect_attributes(element, mode)
40
80
 
41
81
  if element.has_elements?
42
- element.each_element { |child| merge_element!(hash, child) }
43
- merge_texts!(hash, element) unless empty_content?(element)
44
- hash
45
- else
46
- merge_texts!(hash, element)
82
+ collect_child_elements(element, node_hash, mode)
83
+ add_text_content(node_hash, element) unless whitespace_only?(element)
84
+ elsif node_hash.empty? || !whitespace_only?(element)
85
+ add_text_content(node_hash, element)
47
86
  end
87
+
88
+ node_hash
48
89
  end
49
90
 
50
- # Merge all the texts of an element into the hash
91
+ # Collect all attributes from an element into a hash
51
92
  #
52
- # hash::
53
- # Hash to add the converted element to.
54
- # element::
55
- # XML element whose texts are to me merged into the hash
56
- def merge_texts!(hash, element)
57
- if element.has_text?
58
- # must use value to prevent double-escaping
59
- texts = ''
60
- element.texts.each { |t| texts << t.value }
61
- merge!(hash, MultiXml::CONTENT_ROOT, texts)
62
- else
63
- hash
93
+ # @api private
94
+ # @param element [REXML::Element] Element with attributes
95
+ # @param mode [Symbol] Namespace handling mode
96
+ # @return [Hash] Hash of attribute name-value pairs
97
+ def collect_attributes(element, mode)
98
+ element.attributes.each_attribute.with_object({}) do |attr, hash|
99
+ if xmlns_decl?(attr)
100
+ add_attribute_value(hash, xmlns_decl_key(attr), attr.value) if mode == :preserve
101
+ else
102
+ add_attribute_value(hash, format_attr_name(attr, mode), attr.value)
103
+ end
64
104
  end
65
105
  end
66
106
 
67
- # Adds a new key/value pair to an existing Hash. If the key to be added
68
- # already exists and the existing value associated with key is not
69
- # an Array, it will be wrapped in an Array. Then the new value is
70
- # appended to that Array.
107
+ # Check whether an attribute represents an xmlns declaration
71
108
  #
72
- # hash::
73
- # Hash to add key/value pair to.
74
- # key::
75
- # Key to be added.
76
- # value::
77
- # Value to be associated with key.
78
- def merge!(hash, key, value)
79
- if hash.key?(key)
80
- if hash[key].instance_of?(Array)
81
- hash[key] << value
82
- else
83
- hash[key] = [hash[key], value]
84
- end
85
- elsif value.instance_of?(Array)
86
- hash[key] = [value]
109
+ # @api private
110
+ # @param attr [REXML::Attribute] Attribute to inspect
111
+ # @return [Boolean] true if xmlns declaration
112
+ def xmlns_decl?(attr)
113
+ attr.prefix == "xmlns" || ((attr.prefix.nil? || attr.prefix.empty?) && attr.name == "xmlns")
114
+ end
115
+
116
+ # Build the key for an xmlns declaration under :preserve
117
+ #
118
+ # @api private
119
+ # @param attr [REXML::Attribute] Declaration attribute
120
+ # @return [String] key such as "xmlns" or "xmlns:atom"
121
+ def xmlns_decl_key(attr)
122
+ (attr.prefix == "xmlns") ? "xmlns:#{attr.name}" : "xmlns"
123
+ end
124
+
125
+ # Collect all child elements into a hash
126
+ #
127
+ # @api private
128
+ # @param element [REXML::Element] Parent element
129
+ # @param node_hash [Hash] Hash to populate
130
+ # @param mode [Symbol] Namespace handling mode
131
+ # @return [void]
132
+ def collect_child_elements(element, node_hash, mode)
133
+ element.each_element { |child| element_to_hash(node_hash, child, mode) }
134
+ end
135
+
136
+ # Add text content from an element to a hash
137
+ #
138
+ # @api private
139
+ # @param hash [Hash] Target hash
140
+ # @param element [REXML::Element] Element with text
141
+ # @return [Hash] Updated hash
142
+ def add_text_content(hash, element)
143
+ return hash unless element.has_text?
144
+
145
+ text = element.texts.map(&:value).join
146
+ add_to_hash(hash, TEXT_CONTENT_KEY, text)
147
+ end
148
+
149
+ # Add a value to a hash, handling duplicates as arrays
150
+ #
151
+ # @api private
152
+ # @param hash [Hash] Target hash
153
+ # @param key [String] Key to add
154
+ # @param value [Object] Value to add
155
+ # @return [Hash] Updated hash
156
+ def add_to_hash(hash, key, value)
157
+ existing = hash[key]
158
+ hash[key] = if existing
159
+ existing.is_a?(Array) ? existing << value : [existing, value]
160
+ elsif value.is_a?(Array)
161
+ [value]
87
162
  else
88
- hash[key] = value
163
+ value
164
+ end
165
+ hash
166
+ end
167
+
168
+ # Add an attribute value while keeping document order on collisions
169
+ #
170
+ # @api private
171
+ # @param hash [Hash] Target hash
172
+ # @param key [String] Attribute key
173
+ # @param value [String] Attribute value
174
+ # @return [Hash] Updated hash
175
+ def add_attribute_value(hash, key, value)
176
+ existing = hash[key]
177
+ hash[key] = case existing
178
+ when nil then value
179
+ when Array then insert_attribute_before_children(existing, value)
180
+ when Hash then [value, existing]
181
+ else [existing, value]
89
182
  end
90
183
  hash
91
184
  end
92
185
 
93
- # Converts the attributes array of an XML element into a hash.
94
- # Returns an empty Hash if node has no attributes.
186
+ # Insert a later attribute before any child-element entries
95
187
  #
96
- # element::
97
- # XML element to extract attributes from.
98
- def get_attributes(element)
99
- attributes = {}
100
- element.attributes.each { |n, v| attributes[n] = v }
101
- attributes
188
+ # @api private
189
+ # @param values [Array] Existing colliding values
190
+ # @param value [String] Attribute value to insert
191
+ # @return [Array] Updated value list
192
+ def insert_attribute_before_children(values, value)
193
+ child_index = values.index { |entry| entry.is_a?(Hash) } || values.length
194
+ values.dup.insert(child_index, value)
102
195
  end
103
196
 
104
- # Determines if a document element has text content
197
+ # Check if element contains only whitespace text
105
198
  #
106
- # element::
107
- # XML element to be checked.
108
- def empty_content?(element)
199
+ # @api private
200
+ # @param element [REXML::Element] Element to check
201
+ # @return [Boolean] true if whitespace only
202
+ def whitespace_only?(element)
109
203
  element.texts.join.strip.empty?
110
204
  end
111
205
  end