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.
- checksums.yaml +5 -5
- data/.mutant.yml +21 -0
- data/.rspec +2 -0
- data/.rubocop.yml +65 -0
- data/CHANGELOG.md +62 -0
- data/Gemfile +26 -0
- data/LICENSE.md +1 -1
- data/README.md +181 -57
- data/Rakefile +68 -0
- data/Steepfile +29 -0
- data/benchmark/overall_parser_benchmark.rb +5 -0
- data/benchmark.rb +1002 -0
- data/lib/multi_xml/concurrency.rb +31 -0
- data/lib/multi_xml/constants.rb +179 -0
- data/lib/multi_xml/deprecated.rb +35 -0
- data/lib/multi_xml/errors.rb +147 -0
- data/lib/multi_xml/file_like.rb +62 -0
- data/lib/multi_xml/helpers.rb +228 -0
- data/lib/multi_xml/options.rb +63 -0
- data/lib/multi_xml/options_normalization.rb +40 -0
- data/lib/multi_xml/parse_support.rb +113 -0
- data/lib/multi_xml/parser.rb +47 -0
- data/lib/multi_xml/parser_resolution.rb +150 -0
- data/lib/multi_xml/parsers/dom_parser.rb +190 -0
- data/lib/multi_xml/parsers/libxml.rb +57 -17
- data/lib/multi_xml/parsers/libxml_sax.rb +188 -0
- data/lib/multi_xml/parsers/nokogiri.rb +60 -19
- data/lib/multi_xml/parsers/nokogiri_sax.rb +130 -0
- data/lib/multi_xml/parsers/oga.rb +116 -49
- data/lib/multi_xml/parsers/ox.rb +191 -66
- data/lib/multi_xml/parsers/rexml.rb +169 -75
- data/lib/multi_xml/parsers/sax_handler.rb +169 -0
- data/lib/multi_xml/version.rb +6 -44
- data/lib/multi_xml.rb +187 -284
- data/sig/multi_xml.rbs +304 -0
- metadata +55 -23
- data/lib/multi_xml/parsers/libxml2_parser.rb +0 -72
- data/multi_xml.gemspec +0 -19
|
@@ -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
|
|
2
|
-
|
|
1
|
+
require "libxml-ruby"
|
|
2
|
+
require_relative "dom_parser"
|
|
3
3
|
|
|
4
|
-
module
|
|
4
|
+
module MultiXML
|
|
5
5
|
module Parsers
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
# Exception class raised on LibXML parse failure
|
|
15
|
+
# @api private
|
|
16
|
+
ParseError = ::LibXML::XML::Error
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|