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,63 @@
1
+ module MultiXML
2
+ # Mixin providing configurable parse options
3
+ #
4
+ # Supports static hashes or dynamic callables (procs/lambdas). Extended
5
+ # into MultiXML so callers configure process-wide defaults via
6
+ # {MultiXML.parse_options=}.
7
+ #
8
+ # @api private
9
+ module Options
10
+ # Frozen empty hash used as the zero-default for parse options.
11
+ EMPTY_OPTIONS = {}.freeze
12
+
13
+ # Set options for parse operations
14
+ #
15
+ # @api public
16
+ # @param options [Hash, Proc] options hash or callable
17
+ # @return [Hash, Proc] the options
18
+ # @example
19
+ # MultiXML.parse_options = {symbolize_names: true}
20
+ def parse_options=(options)
21
+ @parse_options = options
22
+ end
23
+
24
+ # Get options for parse operations
25
+ #
26
+ # When ``@parse_options`` is a callable (proc/lambda), it's invoked
27
+ # with ``args`` as positional arguments — typically the call-site
28
+ # options hash. When it's a plain hash, ``args`` is ignored.
29
+ #
30
+ # @api public
31
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
32
+ # @return [Hash] resolved options hash
33
+ # @example
34
+ # MultiXML.parse_options #=> {}
35
+ def parse_options(*)
36
+ resolve_options(@parse_options, *) || EMPTY_OPTIONS
37
+ end
38
+
39
+ private
40
+
41
+ # Resolves options from a hash or callable
42
+ #
43
+ # @api private
44
+ # @param options [Hash, Proc, nil] options configuration
45
+ # @param args [Array<Object>] arguments forwarded to a callable provider
46
+ # @return [Hash, nil] resolved options hash
47
+ def resolve_options(options, *)
48
+ return invoke_callable(options, *) if options.respond_to?(:call)
49
+
50
+ options.to_hash if options.respond_to?(:to_hash)
51
+ end
52
+
53
+ # Invokes a callable options provider
54
+ #
55
+ # @api private
56
+ # @param callable [Proc] options provider
57
+ # @param args [Array<Object>] arguments forwarded when the callable is non-arity-zero
58
+ # @return [Hash] options returned by the callable
59
+ def invoke_callable(callable, *)
60
+ callable.arity.zero? ? callable.call : callable.call(*)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,40 @@
1
+ module MultiXML
2
+ # Helpers for normalizing the options hash passed to {MultiXML.parse}
3
+ #
4
+ # Lives in its own module (rather than inside {ParseSupport}, which is
5
+ # mixed into MultiXML's singleton class) so ``self`` inside these
6
+ # methods is ``OptionsNormalization`` rather than ``MultiXML``. That
7
+ # separation is what lets mutation testing distinguish
8
+ # ``MultiXML.warn_deprecation_once(...)`` from
9
+ # ``self.warn_deprecation_once(...)``.
10
+ #
11
+ # @api private
12
+ module OptionsNormalization
13
+ # Translate the deprecated ``:symbolize_keys`` option to ``:symbolize_names``
14
+ #
15
+ # Matches Ruby stdlib's ``JSON.parse`` and sister library MultiJSON
16
+ # naming. Emits a one-time deprecation warning on first encounter
17
+ # of ``:symbolize_keys``. When both names appear together (unusual
18
+ # — only possible if the caller explicitly set both), the canonical
19
+ # ``:symbolize_names`` value wins and ``:symbolize_keys`` is
20
+ # silently dropped.
21
+ #
22
+ # @api private
23
+ # @param options [Hash] options layer to normalize
24
+ # @return [Hash] hash with ``:symbolize_keys`` translated, or the
25
+ # original hash when no translation is needed
26
+ # @example
27
+ # MultiXML::OptionsNormalization.normalize_symbolize_option(symbolize_keys: true)
28
+ def self.normalize_symbolize_option(options)
29
+ return options unless options.key?(:symbolize_keys)
30
+
31
+ MultiXML.warn_deprecation_once(:symbolize_keys_option,
32
+ "The :symbolize_keys option is deprecated and will be removed in v1.0. Use :symbolize_names instead.")
33
+
34
+ new_opts = options.dup
35
+ legacy_value = new_opts.delete(:symbolize_keys)
36
+ new_opts[:symbolize_names] = legacy_value unless new_opts.key?(:symbolize_names)
37
+ new_opts
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,113 @@
1
+ module MultiXML
2
+ # Internal helpers for parsing and post-processing XML
3
+ #
4
+ # @api private
5
+ module ParseSupport
6
+ private
7
+
8
+ # Normalize input to an IO-like object
9
+ #
10
+ # @api private
11
+ # @param xml [String, IO] Input to normalize
12
+ # @return [IO] IO-like object
13
+ def normalize_input(xml)
14
+ return xml if xml.respond_to?(:read)
15
+
16
+ StringIO.new(xml.to_s.strip)
17
+ end
18
+
19
+ # Parse XML with error handling and key normalization
20
+ #
21
+ # @api private
22
+ # @param io [IO] IO-like object containing XML
23
+ # @param original_input [String, IO] Original input for error reporting
24
+ # @param xml_parser [Module] Parser to use
25
+ # @param namespaces [Symbol] Namespace handling mode
26
+ # @return [Hash] Parsed XML (undasherized only when mode is :strip)
27
+ # @raise [ParseError] if XML is malformed
28
+ def parse_with_error_handling(io, original_input, xml_parser, namespaces)
29
+ result = parse_with_namespaces_compatibility(io, xml_parser, namespaces) || {}
30
+ (namespaces == :strip) ? undasherize_keys(result) : result
31
+ rescue xml_parser.parse_error => e
32
+ xml_string = extract_xml_for_error(original_input)
33
+ raise(ParseError.new(e, xml: xml_string, cause: e))
34
+ end
35
+
36
+ # Call the parser while preserving legacy custom parser compatibility
37
+ #
38
+ # @api private
39
+ # @param io [IO] IO-like object containing XML
40
+ # @param xml_parser [Module] Parser to use
41
+ # @param namespaces [Symbol] Namespace handling mode
42
+ # @return [Hash, nil] Parsed XML result
43
+ def parse_with_namespaces_compatibility(io, xml_parser, namespaces)
44
+ if parser_supports_namespaces_keyword?(xml_parser)
45
+ xml_parser.parse(io, namespaces: namespaces)
46
+ else
47
+ xml_parser.parse(io)
48
+ end
49
+ end
50
+
51
+ # Validate the :namespaces mode option
52
+ #
53
+ # @api private
54
+ # @param mode [Symbol] Namespace handling mode to validate
55
+ # @return [Symbol] the validated mode
56
+ # @raise [ArgumentError] if mode is not a recognized value
57
+ def validate_namespaces_mode(mode)
58
+ return mode if NAMESPACE_MODES.include?(mode)
59
+
60
+ expected_modes = "[#{NAMESPACE_MODES.map(&:inspect).join(", ")}]"
61
+ raise ArgumentError, "invalid :namespaces mode #{mode.inspect}; expected one of #{expected_modes}"
62
+ end
63
+
64
+ # Pick the parser to use for this call, honoring the :parser option
65
+ #
66
+ # @api private
67
+ # @param options [Hash] Parsing options
68
+ # @return [Module] Resolved parser module
69
+ def resolve_parse_parser(options)
70
+ options[:parser] ? resolve_parser(options.fetch(:parser)) : parser
71
+ end
72
+
73
+ # Check whether the parser accepts a `namespaces:` keyword
74
+ #
75
+ # @api private
76
+ # @param xml_parser [Module] Parser to inspect
77
+ # @return [Boolean] true when the parser accepts `namespaces:`
78
+ def parser_supports_namespaces_keyword?(xml_parser)
79
+ xml_parser.public_method(:parse).parameters.any? do |kind, name|
80
+ kind == :keyrest || (name == :namespaces && %i[key keyreq].include?(kind))
81
+ end
82
+ end
83
+
84
+ # Extract the original XML for ParseError reporting
85
+ #
86
+ # Some parser backends mutate or close IO objects on error. JRuby's
87
+ # Nokogiri path closes StringIO instances, so prefer rewind/read when
88
+ # available but fall back to the underlying string buffer when present.
89
+ #
90
+ # @api private
91
+ # @param original_input [String, IO] original parse input
92
+ # @return [String] XML payload for ParseError context
93
+ def extract_xml_for_error(original_input)
94
+ return original_input.to_s unless original_input.respond_to?(:read)
95
+
96
+ original_input.tap(&:rewind).read
97
+ rescue IOError
98
+ original_input.respond_to?(:string) ? original_input.string : original_input.to_s
99
+ end
100
+
101
+ # Apply typecasting and key-symbolization as configured
102
+ #
103
+ # @api private
104
+ # @param result [Hash] Parsed hash
105
+ # @param options [Hash] Parsing options
106
+ # @return [Hash] Post-processed hash
107
+ def apply_postprocessing(result, options)
108
+ result = typecast_xml_value(result, options.fetch(:disallowed_types)) if options.fetch(:typecast_xml_value)
109
+ result = symbolize_keys(result) if options.fetch(:symbolize_names)
110
+ result
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,47 @@
1
+ module MultiXML
2
+ # Base module for XML parser implementations
3
+ #
4
+ # Built-in parsers ``extend`` this module and declare the XML library's
5
+ # native parse-error class as a ``ParseError`` constant. The inherited
6
+ # {#parse_error} method reads that constant so {MultiXML.parse} can
7
+ # wrap backend-specific parse failures uniformly.
8
+ #
9
+ # Matches the role of {MultiJSON::Adapter} — a shared contract that
10
+ # custom parsers can pick up by extending this module, while keeping
11
+ # backwards compatibility with parsers that instead define a
12
+ # ``parse_error`` method directly.
13
+ #
14
+ # @example Writing a custom parser
15
+ # module MyParser
16
+ # extend MultiXML::Parser
17
+ #
18
+ # ParseError = Class.new(StandardError)
19
+ #
20
+ # def self.parse(io, namespaces: :strip)
21
+ # # parse io into a Hash, raising ParseError on failure
22
+ # end
23
+ # end
24
+ #
25
+ # MultiXML.parser = MyParser
26
+ #
27
+ # @api public
28
+ module Parser
29
+ # Return the parse-error class declared on the including parser
30
+ #
31
+ # The lookup uses ``inherit: false`` so a stray top-level
32
+ # ``::ParseError`` in the host process (Racc defines one when
33
+ # Nokogiri is loaded) is correctly ignored.
34
+ #
35
+ # @api public
36
+ # @return [Class] the ParseError class declared on ``self``
37
+ # @raise [ParserLoadError] when ``self`` doesn't define ParseError
38
+ # @example
39
+ # MultiXML::Parsers::Nokogiri.parse_error
40
+ # #=> Nokogiri::XML::SyntaxError
41
+ def parse_error
42
+ const_get(:ParseError, false)
43
+ rescue NameError
44
+ raise ParserLoadError, "Parser #{self} must define a ParseError constant"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,150 @@
1
+ module MultiXML
2
+ # Internal helpers for resolving and loading parser backends
3
+ #
4
+ # @api private
5
+ module ParserResolution
6
+ private
7
+
8
+ # Resolve a parser specification to a module
9
+ #
10
+ # @api private
11
+ # @param spec [Symbol, String, Class, Module] Parser specification
12
+ # @return [Module] Resolved parser module
13
+ # @raise [ParserLoadError] if spec is invalid, the parser file
14
+ # can't be required, or the resolved parser doesn't satisfy
15
+ # the parser contract
16
+ def resolve_parser(spec)
17
+ parser = case spec
18
+ when String, Symbol then load_parser(spec)
19
+ when Module then spec
20
+ else raise ParserLoadError, "expected parser to be a Symbol, String, or Module, got #{spec.inspect}"
21
+ end
22
+ validate_parser!(parser)
23
+ rescue ::LoadError => e
24
+ raise ParserLoadError.build(e)
25
+ end
26
+
27
+ # Load a parser by name
28
+ #
29
+ # @api private
30
+ # @param name [Symbol, String] Parser name
31
+ # @return [Module] Loaded parser module
32
+ def load_parser(name)
33
+ name = name.to_s.downcase
34
+ require "multi_xml/parsers/#{name}"
35
+ Parsers.const_get(camelize(name))
36
+ end
37
+
38
+ # Validate that a parser satisfies the documented contract
39
+ #
40
+ # Custom parsers are accepted as modules/classes, so fail fast
41
+ # during parser resolution rather than later on the first parse
42
+ # call. A parser must respond to ``.parse`` and must either
43
+ # define a ``ParseError`` constant or respond to ``.parse_error``.
44
+ #
45
+ # @api private
46
+ # @param parser [Module] parser class or module
47
+ # @return [Module] the validated parser
48
+ # @raise [ParserLoadError] when the parser is missing a required method
49
+ def validate_parser!(parser)
50
+ raise ParserLoadError, "Parser #{parser} must respond to .parse" unless parser.respond_to?(:parse)
51
+ unless parser.const_defined?(:ParseError, false) || parser.respond_to?(:parse_error)
52
+ raise ParserLoadError, "Parser #{parser} must define a ParseError constant or a .parse_error method"
53
+ end
54
+
55
+ parser
56
+ end
57
+
58
+ # Convert underscored string to CamelCase
59
+ #
60
+ # @api private
61
+ # @param name [String] Underscored string
62
+ # @return [String] CamelCased string
63
+ def camelize(name)
64
+ name.split("_").map(&:capitalize).join
65
+ end
66
+
67
+ # Detect the best available parser
68
+ #
69
+ # @api private
70
+ # @return [Symbol] Parser name
71
+ # @raise [NoParserError] if no parser is available
72
+ def detect_parser
73
+ find_loaded_parser || find_available_parser || raise_no_parser_error
74
+ end
75
+
76
+ # Parser constant names mapped to their symbols, in preference order
77
+ #
78
+ # @api private
79
+ LOADED_PARSER_CHECKS = {
80
+ Ox: :ox,
81
+ LibXML: :libxml,
82
+ Nokogiri: :nokogiri,
83
+ Oga: :oga
84
+ }.freeze
85
+ private_constant :LOADED_PARSER_CHECKS
86
+
87
+ # Find an already-loaded parser library
88
+ #
89
+ # @api private
90
+ # @return [Symbol, nil] Parser name or nil if none loaded
91
+ def find_loaded_parser
92
+ LOADED_PARSER_CHECKS.each do |const_name, parser_name|
93
+ next if skip_on_platform?(parser_name)
94
+ return parser_name if Object.const_defined?(const_name)
95
+ end
96
+ nil
97
+ end
98
+
99
+ # Try to load and find an available parser
100
+ #
101
+ # @api private
102
+ # @return [Symbol, nil] Parser name or nil if none available
103
+ def find_available_parser
104
+ PARSER_PREFERENCE.each do |library, parser_name|
105
+ next if skip_on_platform?(parser_name)
106
+ return parser_name if try_require(library)
107
+ end
108
+ nil
109
+ end
110
+
111
+ # Whether a parser should be skipped during auto-detection
112
+ #
113
+ # Ox loads on TruffleRuby but its SAX callbacks misbehave under the
114
+ # native interpreter, so type-attributed XML parses to an empty hash
115
+ # and the disallowed-type check is silently bypassed. Skip it during
116
+ # auto-detection so MultiXML falls through to a working backend.
117
+ # Callers that pass ``parser: :ox`` explicitly still get Ox.
118
+ #
119
+ # @api private
120
+ # @param parser_name [Symbol] parser symbol from preference list
121
+ # @return [Boolean] true when this parser must be skipped
122
+ def skip_on_platform?(parser_name)
123
+ parser_name == :ox && RUBY_ENGINE == "truffleruby"
124
+ end
125
+
126
+ # Attempt to require a library
127
+ #
128
+ # @api private
129
+ # @param library [String] Library to require
130
+ # @return [Boolean] true if successful, false if LoadError
131
+ def try_require(library)
132
+ require library
133
+ true
134
+ rescue LoadError
135
+ false
136
+ end
137
+
138
+ # Raise an error indicating no parser is available
139
+ #
140
+ # @api private
141
+ # @return [void]
142
+ # @raise [NoParserError] always
143
+ def raise_no_parser_error
144
+ raise NoParserError, <<~MSG.chomp
145
+ No XML parser detected. Install one of: ox, nokogiri, libxml-ruby, or oga.
146
+ See https://github.com/sferik/multi_xml for more information.
147
+ MSG
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,190 @@
1
+ module MultiXML
2
+ # Namespace for all supported XML parser backends
3
+ #
4
+ # Each parser (Nokogiri, LibXML, Ox, Oga, REXML, plus SAX variants) is
5
+ # defined as a module under this namespace and exposes a common `parse`
6
+ # and `parse_error` interface.
7
+ #
8
+ # @api private
9
+ module Parsers
10
+ # Shared DOM traversal logic for converting XML nodes to hashes
11
+ #
12
+ # Used by Nokogiri, LibXML, and Oga parsers.
13
+ # Including modules must implement:
14
+ # - each_child(node) { |child| ... }
15
+ # - each_element_attr(node) { |attr| ... } (non-namespace-decl attrs only)
16
+ # - each_namespace_decl(node) { |prefix_or_nil, uri| ... }
17
+ # - element_parts(node) -> [prefix_or_nil, local_name]
18
+ # - attr_parts(attr) -> [prefix_or_nil, local_name]
19
+ #
20
+ # @api private
21
+ module DomParser
22
+ # Convert an XML node to a hash representation
23
+ #
24
+ # @api private
25
+ # @param node [Object] XML node to convert
26
+ # @param hash [Hash] Accumulator hash for results
27
+ # @param mode [Symbol] Namespace handling mode (:strip, :preserve)
28
+ # @return [Hash] Hash representation of the node
29
+ def node_to_hash(node, hash = {}, mode: :strip)
30
+ node_hash = {TEXT_CONTENT_KEY => +""}
31
+ add_value(hash, format_element_name(node, mode), node_hash)
32
+ collect_children(node, node_hash, mode)
33
+ collect_namespace_decls(node, node_hash, mode)
34
+ collect_attributes(node, node_hash, mode)
35
+ strip_whitespace_content(node_hash)
36
+ hash
37
+ end
38
+
39
+ private
40
+
41
+ # Add a value to a hash, converting to array on duplicates
42
+ #
43
+ # @api private
44
+ # @param hash [Hash] Target hash
45
+ # @param key [String] Key to add
46
+ # @param value [Object] Value to add
47
+ # @return [void]
48
+ def add_value(hash, key, value)
49
+ existing = hash[key]
50
+ hash[key] = case existing
51
+ when Array then existing << value
52
+ when Hash then [existing, value]
53
+ else value
54
+ end
55
+ end
56
+
57
+ # Collect all child nodes into a hash
58
+ #
59
+ # @api private
60
+ # @param node [Object] Parent node
61
+ # @param node_hash [Hash] Hash to populate
62
+ # @param mode [Symbol] Namespace handling mode
63
+ # @return [void]
64
+ def collect_children(node, node_hash, mode)
65
+ each_child(node) do |child|
66
+ if child.element?
67
+ node_to_hash(child, node_hash, mode: mode)
68
+ elsif text_or_cdata?(child)
69
+ node_hash[TEXT_CONTENT_KEY] << child.content
70
+ end
71
+ end
72
+ end
73
+
74
+ # Check if a node is text or CDATA
75
+ #
76
+ # @api private
77
+ # @param node [Object] Node to check
78
+ # @return [Boolean] true if text or CDATA
79
+ def text_or_cdata?(node)
80
+ node.text? || node.cdata?
81
+ end
82
+
83
+ # Collect xmlns declarations into the hash under :preserve mode
84
+ #
85
+ # Declarations are unique per prefix on a given element, so no
86
+ # collision handling is needed here.
87
+ #
88
+ # @api private
89
+ # @param node [Object] Node with potential xmlns declarations
90
+ # @param node_hash [Hash] Hash to populate
91
+ # @param mode [Symbol] Namespace handling mode
92
+ # @return [void]
93
+ def collect_namespace_decls(node, node_hash, mode)
94
+ return unless mode == :preserve
95
+
96
+ each_namespace_decl(node) do |prefix, uri|
97
+ node_hash[prefix ? "xmlns:#{prefix}" : "xmlns"] = uri
98
+ end
99
+ end
100
+
101
+ # Collect all attributes from a node
102
+ #
103
+ # Attributes arrive after child elements. When an attribute collides
104
+ # with a child of the same name, the attribute is placed first in the
105
+ # resulting array (e.g. `<user name="A"><name>B</name></user>` →
106
+ # `["A", "B"]`). See `test/attribute_tests.rb`.
107
+ #
108
+ # @api private
109
+ # @param node [Object] Node with attributes
110
+ # @param node_hash [Hash] Hash to populate
111
+ # @param mode [Symbol] Namespace handling mode
112
+ # @return [void]
113
+ def collect_attributes(node, node_hash, mode)
114
+ each_element_attr(node) do |attr|
115
+ add_attribute_value(node_hash, format_attr_name(attr, mode), attr.value)
116
+ end
117
+ end
118
+
119
+ # Format an element's name according to the namespace mode
120
+ #
121
+ # @api private
122
+ # @param node [Object] Element node
123
+ # @param mode [Symbol] Namespace handling mode
124
+ # @return [String] formatted element name
125
+ def format_element_name(node, mode)
126
+ format_name(*element_parts(node), mode)
127
+ end
128
+
129
+ # Format an attribute's name according to the namespace mode
130
+ #
131
+ # @api private
132
+ # @param attr [Object] Attribute node
133
+ # @param mode [Symbol] Namespace handling mode
134
+ # @return [String] formatted attribute name
135
+ def format_attr_name(attr, mode)
136
+ format_name(*attr_parts(attr), mode)
137
+ end
138
+
139
+ # Produce a name string for a given [prefix, local] tuple
140
+ #
141
+ # @api private
142
+ # @param prefix [String, nil] Namespace prefix (nil for default / unprefixed)
143
+ # @param local [String] Local part of the name
144
+ # @param mode [Symbol] Namespace handling mode
145
+ # @return [String] formatted name
146
+ def format_name(prefix, local, mode)
147
+ (mode == :preserve && prefix) ? "#{prefix}:#{local}" : local
148
+ end
149
+
150
+ # Add an attribute value, preserving attr-before-child collision order
151
+ #
152
+ # @api private
153
+ # @param hash [Hash] Target hash
154
+ # @param key [String] Attribute key
155
+ # @param value [String] Attribute value
156
+ # @return [void]
157
+ def add_attribute_value(hash, key, value)
158
+ existing = hash[key]
159
+ hash[key] = case existing
160
+ when nil then value
161
+ when Array then insert_attribute_before_children(existing, value)
162
+ when Hash then [value, existing]
163
+ else [existing, value]
164
+ end
165
+ end
166
+
167
+ # Insert a later attribute before any child-element entries
168
+ #
169
+ # @api private
170
+ # @param values [Array] Existing colliding values
171
+ # @param value [String] Attribute value to insert
172
+ # @return [Array] Updated value list
173
+ def insert_attribute_before_children(values, value)
174
+ child_index = values.index { |entry| entry.is_a?(Hash) } || values.length
175
+ values.dup.insert(child_index, value)
176
+ end
177
+
178
+ # Remove empty or whitespace-only text content
179
+ #
180
+ # @api private
181
+ # @param node_hash [Hash] Hash to clean up
182
+ # @return [void]
183
+ def strip_whitespace_content(node_hash)
184
+ content = node_hash[TEXT_CONTENT_KEY]
185
+ should_remove = content.empty? || (node_hash.size > 1 && content.strip.empty?)
186
+ node_hash.delete(TEXT_CONTENT_KEY) if should_remove
187
+ end
188
+ end
189
+ end
190
+ end
@@ -1,32 +1,72 @@
1
- require 'libxml' unless defined?(LibXML)
2
- require 'multi_xml/parsers/libxml2_parser'
1
+ require "libxml-ruby"
2
+ require_relative "dom_parser"
3
3
 
4
- module MultiXml
4
+ module MultiXML
5
5
  module Parsers
6
- module Libxml #:nodoc:
7
- include Libxml2Parser
6
+ # XML parser using the LibXML library
7
+ #
8
+ # @api private
9
+ module Libxml
10
+ extend MultiXML::Parser
11
+ include DomParser
8
12
  extend self
9
13
 
10
- def parse_error
11
- ::LibXML::XML::Error
12
- end
14
+ # Exception class raised on LibXML parse failure
15
+ # @api private
16
+ ParseError = ::LibXML::XML::Error
13
17
 
14
- def parse(xml)
15
- node_to_hash(LibXML::XML::Parser.io(xml).parse.root)
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 [LibXML::XML::Error] if XML is malformed
25
+ def parse(io, namespaces: :strip)
26
+ node_to_hash(::LibXML::XML::Parser.io(io).parse.root, mode: namespaces)
16
27
  end
17
28
 
18
- private
29
+ private
30
+
31
+ # Iterate over child nodes
32
+ #
33
+ # @api private
34
+ # @param node [LibXML::XML::Node] Parent node
35
+ # @return [void]
36
+ def each_child(node, &) = node.each_child(&)
37
+
38
+ # Iterate over attribute nodes
39
+ #
40
+ # @api private
41
+ # @param node [LibXML::XML::Node] Element node
42
+ # @return [void]
43
+ def each_element_attr(node, &) = node.each_attr(&)
19
44
 
20
- def each_child(node, &block)
21
- node.each_child(&block)
45
+ # Yield each xmlns declaration on this element
46
+ #
47
+ # @api private
48
+ # @param node [LibXML::XML::Node] Element node
49
+ # @return [void]
50
+ def each_namespace_decl(node)
51
+ node.namespaces.definitions.each { |ns| yield ns.prefix, ns.href }
22
52
  end
23
53
 
24
- def each_attr(node, &block)
25
- node.each_attr(&block)
54
+ # Return [prefix, local] for an element
55
+ #
56
+ # @api private
57
+ # @param node [LibXML::XML::Node] Element node
58
+ # @return [Array<String, nil>] prefix and local name
59
+ def element_parts(node)
60
+ [node.namespaces.namespace&.prefix, node.name]
26
61
  end
27
62
 
28
- def node_name(node)
29
- node.name
63
+ # Return [prefix, local] for an attribute
64
+ #
65
+ # @api private
66
+ # @param attr [LibXML::XML::Attr] Attribute node
67
+ # @return [Array<String, nil>] prefix and local name
68
+ def attr_parts(attr)
69
+ [attr.ns? ? attr.ns.prefix : nil, attr.name]
30
70
  end
31
71
  end
32
72
  end