moxml 0.1.26 → 0.2.0
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 +4 -4
- data/.github/workflows/windows.yml +62 -0
- data/Gemfile +3 -1
- data/README.adoc +69 -6
- data/lib/compat/opal/moxml_boot.rb +5 -0
- data/lib/moxml/adapter/base.rb +53 -49
- data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
- data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
- data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
- data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
- data/lib/moxml/adapter/customized_leptris.rb +16 -0
- data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
- data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
- data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
- data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
- data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
- data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
- data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
- data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
- data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
- data/lib/moxml/adapter/customized_oga.rb +2 -0
- data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
- data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
- data/lib/moxml/adapter/headed_ox.rb +9 -128
- data/lib/moxml/adapter/leptris.rb +965 -0
- data/lib/moxml/adapter/libxml.rb +131 -99
- data/lib/moxml/adapter/nokogiri.rb +29 -8
- data/lib/moxml/adapter/oga.rb +91 -54
- data/lib/moxml/adapter/ox.rb +93 -137
- data/lib/moxml/adapter/rexml.rb +61 -50
- data/lib/moxml/adapter.rb +2 -1
- data/lib/moxml/attribute.rb +8 -4
- data/lib/moxml/attribute_resolver.rb +189 -0
- data/lib/moxml/config.rb +29 -3
- data/lib/moxml/context.rb +12 -0
- data/lib/moxml/element.rb +29 -12
- data/lib/moxml/entity/reference.rb +28 -0
- data/lib/moxml/entity.rb +125 -0
- data/lib/moxml/node.rb +90 -1
- data/lib/moxml/sax/namespace_splitter.rb +5 -2
- data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xml_emitter.rb +71 -0
- data/lib/moxml/xpath/compiler.rb +60 -7
- data/lib/moxml/xpath/parser.rb +22 -15
- data/lib/moxml.rb +3 -0
- data/spec/examples/readme_examples_spec.rb +0 -4
- data/spec/examples/xpath_examples_spec.rb +0 -11
- data/spec/integration/all_adapters_spec.rb +17 -0
- data/spec/integration/sax_parity_spec.rb +58 -0
- data/spec/integration/shared_examples/edge_cases.rb +0 -10
- data/spec/integration/shared_examples/integration_workflows.rb +0 -10
- data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
- data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
- data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
- data/spec/moxml/adapter/leptris_spec.rb +75 -0
- data/spec/moxml/adapter/ox_spec.rb +20 -6
- data/spec/moxml/adapter/platform_spec.rb +15 -2
- data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
- data/spec/moxml/attribute_resolver_spec.rb +108 -0
- data/spec/moxml/doctype_spec.rb +1 -1
- data/spec/moxml/entity_spec.rb +76 -0
- data/spec/moxml/node_spec.rb +104 -0
- data/spec/moxml/sax_entity_parity_spec.rb +358 -0
- data/spec/moxml/xml_emitter_spec.rb +64 -0
- data/spec/moxml/xpath/axes_spec.rb +72 -0
- data/spec/moxml/xpath/parser_spec.rb +6 -0
- data/spec/moxml/xpath_capabilities_spec.rb +5 -3
- data/spec/performance/benchmark_spec.rb +13 -6
- data/spec/performance/thread_safety_spec.rb +0 -4
- data/spec/spec_helper.rb +5 -1
- metadata +21 -2
data/lib/moxml/entity.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# Entity-reference round-trip pipeline.
|
|
5
|
+
#
|
|
6
|
+
# XML libraries split into two camps: those that preserve named
|
|
7
|
+
# entity references natively (nokogiri) and those that expand or
|
|
8
|
+
# reject them at parse (ox, rexml, oga, libxml, leptris). Moxml
|
|
9
|
+
# bridges the camps with one pipeline, owned here and delegated to
|
|
10
|
+
# by Adapter::Base:
|
|
11
|
+
#
|
|
12
|
+
# 1. Parse time — .preprocess_entities rewrites non-standard
|
|
13
|
+
# entity references to a two-character marker (see MARKER) so
|
|
14
|
+
# the reference survives native parsing verbatim. The five
|
|
15
|
+
# standard entities (& < > " ') are NOT
|
|
16
|
+
# converted.
|
|
17
|
+
# 2. Read time — marker-bearing text is split into text and
|
|
18
|
+
# Entity::Reference nodes (adapters whose natives cannot hold
|
|
19
|
+
# references), or decoded in place (.decode_entities covers the
|
|
20
|
+
# five standard entities plus numeric character references).
|
|
21
|
+
# 3. Serialize time — .restore_entities maps markers back to named
|
|
22
|
+
# references, including markers a native serializer already
|
|
23
|
+
# rendered as character references.
|
|
24
|
+
#
|
|
25
|
+
# How references are STORED between parse and serialize is
|
|
26
|
+
# adapter-shaped (in-tree value objects for ox, attachment
|
|
27
|
+
# sequences for rexml/libxml, text markers for oga/leptris); the
|
|
28
|
+
# marker lifecycle and the Entity::Reference value type are the
|
|
29
|
+
# shared, single-sourced parts.
|
|
30
|
+
module Entity
|
|
31
|
+
autoload :Reference, "moxml/entity/reference"
|
|
32
|
+
|
|
33
|
+
# Marker for adapters that resolve entities during parsing.
|
|
34
|
+
# U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a
|
|
35
|
+
# two-character sentinel chosen because this exact sequence
|
|
36
|
+
# followed by a valid entity name pattern is vanishingly unlikely
|
|
37
|
+
# in real XML content.
|
|
38
|
+
MARKER = "\u{FFFC}\u{FEFF}"
|
|
39
|
+
NAME_PATTERN = "[a-zA-Z_][\\w.:-]*"
|
|
40
|
+
NAME_RE = /&(#{NAME_PATTERN});/
|
|
41
|
+
MARKER_RE = /\u{FFFC}\u{FEFF}(#{NAME_PATTERN});/
|
|
42
|
+
SERIALIZED_MARKER_RE = /(#{NAME_PATTERN});/
|
|
43
|
+
STANDARD_ENTITIES = %w[amp lt gt quot apos].freeze
|
|
44
|
+
|
|
45
|
+
NAMED_DECODE_MAP = {
|
|
46
|
+
"amp" => "&", "lt" => "<", "gt" => ">",
|
|
47
|
+
"quot" => '"', "apos" => "'"
|
|
48
|
+
}.freeze
|
|
49
|
+
private_constant :NAMED_DECODE_MAP
|
|
50
|
+
|
|
51
|
+
DECODE_RE = /&(?:(amp|lt|gt|quot|apos)|#(?:(\d+)|[xX]([0-9a-fA-F]+)));/
|
|
52
|
+
private_constant :DECODE_RE
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# Replace non-standard entity references with markers before
|
|
57
|
+
# parsing. Always returns a UTF-8 encoded string.
|
|
58
|
+
def preprocess_entities(xml)
|
|
59
|
+
return "" if xml.nil?
|
|
60
|
+
|
|
61
|
+
str = if xml.encoding == Encoding::BINARY
|
|
62
|
+
# Binary strings are assumed to be UTF-8. If the bytes are
|
|
63
|
+
# not valid UTF-8, fall back to encoding as UTF-8 with
|
|
64
|
+
# replacement to avoid raising on gsub.
|
|
65
|
+
dup = xml.dup.force_encoding("UTF-8")
|
|
66
|
+
if dup.valid_encoding?
|
|
67
|
+
dup
|
|
68
|
+
else
|
|
69
|
+
xml.dup.encode("UTF-8",
|
|
70
|
+
"ASCII-8BIT", invalid: :replace, undef: :replace)
|
|
71
|
+
end
|
|
72
|
+
elsif xml.encoding == Encoding::UTF_8
|
|
73
|
+
xml
|
|
74
|
+
else
|
|
75
|
+
xml.encode("UTF-8")
|
|
76
|
+
end
|
|
77
|
+
# Fast path: no `&` means no entity references to mark — skip
|
|
78
|
+
# the regex scan and string allocation entirely. The vast
|
|
79
|
+
# majority of XML payloads contain no entity references.
|
|
80
|
+
return str unless str.include?("&")
|
|
81
|
+
|
|
82
|
+
str.gsub(NAME_RE) do |match|
|
|
83
|
+
STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{MARKER}#{::Regexp.last_match(1)};"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolve numeric (&#NN; / &#xNN;) and the five standard named
|
|
88
|
+
# (& < > " ') XML entity references in one
|
|
89
|
+
# single pass. The resulting characters are data; no further
|
|
90
|
+
# decoding is applied. Numeric refs producing invalid UTF-8
|
|
91
|
+
# (NUL, surrogate halves, > U+10FFFF) are preserved verbatim
|
|
92
|
+
# to avoid silently emitting malformed bytes.
|
|
93
|
+
def decode_entities(text)
|
|
94
|
+
return text unless text.is_a?(String) && text.include?("&")
|
|
95
|
+
|
|
96
|
+
text.gsub(DECODE_RE) do
|
|
97
|
+
if (named = ::Regexp.last_match(1))
|
|
98
|
+
NAMED_DECODE_MAP[named]
|
|
99
|
+
else
|
|
100
|
+
code = ::Regexp.last_match(2) ? ::Regexp.last_match(2).to_i : ::Regexp.last_match(3).to_i(16)
|
|
101
|
+
if code.zero? || code.between?(0xD800, 0xDFFF) || code > 0x10FFFF
|
|
102
|
+
::Regexp.last_match(0)
|
|
103
|
+
else
|
|
104
|
+
[code].pack("U")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Restore entity markers back to named entity references.
|
|
111
|
+
def restore_entities(text)
|
|
112
|
+
return text unless text.is_a?(String)
|
|
113
|
+
|
|
114
|
+
# Force UTF-8 encoding since markers are UTF-8 characters
|
|
115
|
+
str = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
|
|
116
|
+
# Fast path: the vast majority of documents carry no entity
|
|
117
|
+
# markers — two C-level scans beat two regex passes.
|
|
118
|
+
return str unless str.include?(MARKER) ||
|
|
119
|
+
str.include?("")
|
|
120
|
+
|
|
121
|
+
result = str.gsub(MARKER_RE, '&\1;')
|
|
122
|
+
result.gsub(SERIALIZED_MARKER_RE, '&\1;')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/moxml/node.rb
CHANGED
|
@@ -55,6 +55,8 @@ module Moxml
|
|
|
55
55
|
refreshed = adapter.actual_native(node.native, @native)
|
|
56
56
|
node.refresh_native!(refreshed) if refreshed && refreshed != node.native
|
|
57
57
|
node.parent_node = self
|
|
58
|
+
# The adopted subtree's in-scope namespaces changed
|
|
59
|
+
node.invalidate_namespace_cache!
|
|
58
60
|
invalidate_children_cache!
|
|
59
61
|
self
|
|
60
62
|
end
|
|
@@ -77,9 +79,15 @@ module Moxml
|
|
|
77
79
|
invalidate_parent_children_cache!
|
|
78
80
|
adapter.remove(@native)
|
|
79
81
|
invalidate_children_cache!
|
|
82
|
+
# The detached subtree left its declaring ancestors behind
|
|
83
|
+
invalidate_namespace_cache!
|
|
80
84
|
self
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
# Namespace-scope caches live on Element; the base no-op lets tree
|
|
88
|
+
# mutations invalidate uniformly without type checks.
|
|
89
|
+
def invalidate_namespace_cache!; end
|
|
90
|
+
|
|
83
91
|
def replace(node)
|
|
84
92
|
node = prepare_node(node)
|
|
85
93
|
invalidate_parent_children_cache!
|
|
@@ -103,7 +111,10 @@ module Moxml
|
|
|
103
111
|
end
|
|
104
112
|
|
|
105
113
|
def xpath(expression, namespaces = {})
|
|
106
|
-
|
|
114
|
+
result = adapter.xpath(@native, expression, namespaces)
|
|
115
|
+
# Adapter contract: Array<native> | scalar. Scalars (count(),
|
|
116
|
+
# string-length(), booleans) pass through unwrapped.
|
|
117
|
+
result.is_a?(Array) ? NodeSet.new(result, context) : result
|
|
107
118
|
end
|
|
108
119
|
|
|
109
120
|
def at_xpath(expression, namespaces = {})
|
|
@@ -186,6 +197,69 @@ module Moxml
|
|
|
186
197
|
children.each(&block)
|
|
187
198
|
end
|
|
188
199
|
|
|
200
|
+
# Returns all ancestor nodes from the parent up to and including
|
|
201
|
+
# the document node.
|
|
202
|
+
#
|
|
203
|
+
# @return [NodeSet] ancestors ordered nearest-first
|
|
204
|
+
def ancestors
|
|
205
|
+
return NodeSet.new([], context) if document?
|
|
206
|
+
|
|
207
|
+
natives = []
|
|
208
|
+
current = parent
|
|
209
|
+
while current
|
|
210
|
+
natives << current.native
|
|
211
|
+
break if current.document?
|
|
212
|
+
|
|
213
|
+
current = current.parent
|
|
214
|
+
end
|
|
215
|
+
NodeSet.new(natives, context)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Returns all descendant nodes (children, grandchildren, and so on),
|
|
219
|
+
# excluding the node itself.
|
|
220
|
+
#
|
|
221
|
+
# @return [NodeSet] descendants in document order
|
|
222
|
+
def descendants
|
|
223
|
+
natives = []
|
|
224
|
+
each_node { |node| natives << node.native }
|
|
225
|
+
NodeSet.new(natives, context)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Returns an XPath expression that uniquely locates this node within
|
|
229
|
+
# its document. Positional predicates are emitted only when sibling
|
|
230
|
+
# elements share the same qualified name, keeping paths minimal.
|
|
231
|
+
#
|
|
232
|
+
# @return [String] XPath expression
|
|
233
|
+
# @raise [Moxml::NotImplementedError] for node types other than
|
|
234
|
+
# element and document
|
|
235
|
+
def path
|
|
236
|
+
return "/" if document?
|
|
237
|
+
|
|
238
|
+
unless element?
|
|
239
|
+
raise Moxml::NotImplementedError.new(
|
|
240
|
+
"path is only supported for element and document nodes",
|
|
241
|
+
feature: "path",
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
segments = []
|
|
246
|
+
current = self
|
|
247
|
+
while current && !current.document?
|
|
248
|
+
segments.unshift(path_segment_for(current))
|
|
249
|
+
current = current.parent
|
|
250
|
+
end
|
|
251
|
+
"/#{segments.join('/')}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Returns the 1-based line number where this node appears in the
|
|
255
|
+
# source XML, or nil when the underlying adapter does not track
|
|
256
|
+
# source positions.
|
|
257
|
+
#
|
|
258
|
+
# @return [Integer, nil]
|
|
259
|
+
def line_number
|
|
260
|
+
adapter.line_number(@native)
|
|
261
|
+
end
|
|
262
|
+
|
|
189
263
|
def outer_xml
|
|
190
264
|
to_xml
|
|
191
265
|
end
|
|
@@ -286,6 +360,21 @@ module Moxml
|
|
|
286
360
|
|
|
287
361
|
private
|
|
288
362
|
|
|
363
|
+
# XPath segment for an element: the qualified name, plus a positional
|
|
364
|
+
# predicate only when same-named element siblings make it ambiguous.
|
|
365
|
+
def path_segment_for(element)
|
|
366
|
+
name = element.name
|
|
367
|
+
parent = element.parent
|
|
368
|
+
return name unless parent
|
|
369
|
+
|
|
370
|
+
same_name = parent.children.select do |child|
|
|
371
|
+
child.element? && child.name == name
|
|
372
|
+
end
|
|
373
|
+
return name if same_name.size == 1
|
|
374
|
+
|
|
375
|
+
"#{name}[#{same_name.find_index(element) + 1}]"
|
|
376
|
+
end
|
|
377
|
+
|
|
289
378
|
def prepare_node(node)
|
|
290
379
|
case node
|
|
291
380
|
when String then Text.new(adapter.create_text(node), context)
|
|
@@ -9,6 +9,8 @@ module Moxml
|
|
|
9
9
|
# attribute. This module provides a single implementation.
|
|
10
10
|
module NamespaceSplitter
|
|
11
11
|
# @param attributes [Hash, Array<Array>] attributes as a hash or array of pairs
|
|
12
|
+
# @yieldparam value [Object] raw attribute/namespace value
|
|
13
|
+
# @yieldreturn [Object] transformed value to store
|
|
12
14
|
# @return [Array(Hash, Hash)] [regular_attrs, namespaces]
|
|
13
15
|
def split_attributes_and_namespaces(attributes)
|
|
14
16
|
attrs = {}
|
|
@@ -16,11 +18,12 @@ module Moxml
|
|
|
16
18
|
|
|
17
19
|
each_attribute(attributes) do |name, value|
|
|
18
20
|
name_s = name.to_s
|
|
21
|
+
v = block_given? ? yield(value) : value
|
|
19
22
|
if name_s == "xmlns" || name_s.start_with?("xmlns:")
|
|
20
23
|
prefix = name_s == "xmlns" ? nil : name_s.sub("xmlns:", "")
|
|
21
|
-
ns[prefix] =
|
|
24
|
+
ns[prefix] = v
|
|
22
25
|
else
|
|
23
|
-
attrs[name_s] =
|
|
26
|
+
attrs[name_s] = v
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
@@ -10,7 +10,7 @@ module Moxml
|
|
|
10
10
|
class DSAKeyValue
|
|
11
11
|
attr_accessor :p, :q, :g, :y, :j, :seed, :pgen_counter
|
|
12
12
|
|
|
13
|
-
# rubocop:disable Naming/MethodParameterName -- P/Q/G/Y/J are W3C spec names
|
|
13
|
+
# rubocop:disable-next Naming/MethodParameterName -- P/Q/G/Y/J are W3C spec names
|
|
14
14
|
def initialize(p:, q:, y:, g: nil, j: nil, seed: nil,
|
|
15
15
|
pgen_counter: nil)
|
|
16
16
|
@p = p
|
|
@@ -21,7 +21,6 @@ module Moxml
|
|
|
21
21
|
@seed = seed
|
|
22
22
|
@pgen_counter = pgen_counter
|
|
23
23
|
end
|
|
24
|
-
# rubocop:enable Naming/MethodParameterName
|
|
25
24
|
end
|
|
26
25
|
end
|
|
27
26
|
end
|
data/lib/moxml/version.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# Single source of XML emission knowledge: character escaping,
|
|
5
|
+
# CDATA end-sequence segmentation, and declaration/DOCTYPE
|
|
6
|
+
# rendering. Adapters keep their tree walks (those are
|
|
7
|
+
# adapter-shaped) but stop re-deriving the wire format:
|
|
8
|
+
#
|
|
9
|
+
# - text escapes & < >; attribute values also escape " —
|
|
10
|
+
# apostrophes stay literal (moxml-canonical form)
|
|
11
|
+
# - a CDATA section's end sequence is split into adjacent sections
|
|
12
|
+
# - the moxml declaration shape is <?xml version="..."
|
|
13
|
+
# encoding="..." standalone="..."?> with only the set attributes
|
|
14
|
+
# - DOCTYPE renders PUBLIC and SYSTEM external identifiers
|
|
15
|
+
module XmlEmitter
|
|
16
|
+
TEXT_ESCAPE_RE = /[<>&]/
|
|
17
|
+
TEXT_ESCAPE_MAP = {
|
|
18
|
+
"<" => "<", ">" => ">", "&" => "&"
|
|
19
|
+
}.freeze
|
|
20
|
+
ATTRIBUTE_ESCAPE_RE = /[<>&"]/
|
|
21
|
+
ATTRIBUTE_ESCAPE_MAP = {
|
|
22
|
+
"<" => "<", ">" => ">", "&" => "&", '"' => """
|
|
23
|
+
}.freeze
|
|
24
|
+
CDATA_END = "]]>"
|
|
25
|
+
CDATA_END_SPLIT = "]]]]><![CDATA[>"
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @return [String] text with & < > escaped
|
|
30
|
+
def escape_text(text)
|
|
31
|
+
str = text.is_a?(String) ? text : text.to_s
|
|
32
|
+
return str unless str.match?(TEXT_ESCAPE_RE)
|
|
33
|
+
|
|
34
|
+
str.gsub(TEXT_ESCAPE_RE, TEXT_ESCAPE_MAP)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] attribute value with & < > " escaped
|
|
38
|
+
def escape_attribute(value)
|
|
39
|
+
str = value.is_a?(String) ? value : value.to_s
|
|
40
|
+
return str unless str.match?(ATTRIBUTE_ESCAPE_RE)
|
|
41
|
+
|
|
42
|
+
str.gsub(ATTRIBUTE_ESCAPE_RE, ATTRIBUTE_ESCAPE_MAP)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] a CDATA section, splitting any embedded end
|
|
46
|
+
# sequence into adjacent sections
|
|
47
|
+
def cdata(content)
|
|
48
|
+
"<![CDATA[#{content.to_s.gsub(CDATA_END, CDATA_END_SPLIT)}]]>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String] an XML declaration with only the set attributes
|
|
52
|
+
def declaration_xml(version, encoding, standalone)
|
|
53
|
+
output = %(<?xml version="#{version}")
|
|
54
|
+
output << %( encoding="#{encoding}") if encoding && !encoding.to_s.empty?
|
|
55
|
+
output << %( standalone="#{standalone}") unless standalone.nil?
|
|
56
|
+
output << "?>"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] a DOCTYPE with its external identifier
|
|
60
|
+
def doctype_xml(name, public_id, system_id)
|
|
61
|
+
output = "<!DOCTYPE #{name}"
|
|
62
|
+
if public_id && !public_id.to_s.empty?
|
|
63
|
+
output << %( PUBLIC "#{public_id}")
|
|
64
|
+
output << %( "#{system_id}") if system_id
|
|
65
|
+
elsif system_id && !system_id.to_s.empty?
|
|
66
|
+
output << %( SYSTEM "#{system_id}")
|
|
67
|
+
end
|
|
68
|
+
output << ">"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/moxml/xpath/compiler.rb
CHANGED
|
@@ -425,15 +425,22 @@ module Moxml
|
|
|
425
425
|
end
|
|
426
426
|
end
|
|
427
427
|
|
|
428
|
-
# AXIS: attribute - Enables @attribute syntax
|
|
428
|
+
# AXIS: attribute - Enables @attribute syntax. Name tests share
|
|
429
|
+
# the resolver's expanded-name semantics (Namespaces 1.0): bare
|
|
430
|
+
# names match no-namespace attributes only; prefixed names match
|
|
431
|
+
# by namespace URI, never by prefix spelling.
|
|
429
432
|
def on_axis_attribute(ast, input)
|
|
430
433
|
elem_class = const_ref("Moxml", "Element")
|
|
431
434
|
attribute = unique_literal(:attribute)
|
|
432
435
|
|
|
433
436
|
input.is_a?(elem_class).if_true do
|
|
434
437
|
input.attributes.each.add_block(attribute) do
|
|
435
|
-
|
|
436
|
-
|
|
438
|
+
condition = if ast.type == :test
|
|
439
|
+
attribute_test_condition(ast, input, attribute)
|
|
440
|
+
else
|
|
441
|
+
# Wildcards and node types keep the generic path
|
|
442
|
+
process(ast, attribute)
|
|
443
|
+
end
|
|
437
444
|
|
|
438
445
|
if block_given?
|
|
439
446
|
condition.if_true { yield attribute }
|
|
@@ -444,6 +451,34 @@ module Moxml
|
|
|
444
451
|
end
|
|
445
452
|
end
|
|
446
453
|
|
|
454
|
+
# Attribute node-test condition built on AttributeResolver. The
|
|
455
|
+
# XPath context's prefix mapping (when provided) wins over the
|
|
456
|
+
# document's own declarations and is resolved at compile time.
|
|
457
|
+
# Returns nil for the both-wildcard test (@*): no name constraint.
|
|
458
|
+
def attribute_test_condition(ast, element, attribute)
|
|
459
|
+
resolver = const_ref("Moxml", "AttributeResolver")
|
|
460
|
+
ns = ast.value[:namespace]
|
|
461
|
+
name = ast.value[:name]
|
|
462
|
+
|
|
463
|
+
return nil if name == STAR && (!ns || ns == STAR)
|
|
464
|
+
|
|
465
|
+
local = name == STAR ? self_nil : string(name)
|
|
466
|
+
|
|
467
|
+
if ns && ns != STAR && @namespaces && (uri = @namespaces[ns])
|
|
468
|
+
return resolver.attribute_test_uri?(element, attribute,
|
|
469
|
+
string(uri.to_s), local)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
prefix = if ns.nil?
|
|
473
|
+
self_nil
|
|
474
|
+
elsif ns == STAR
|
|
475
|
+
symbol(:any)
|
|
476
|
+
else
|
|
477
|
+
string(ns)
|
|
478
|
+
end
|
|
479
|
+
resolver.attribute_test?(element, attribute, prefix, local)
|
|
480
|
+
end
|
|
481
|
+
|
|
447
482
|
# AXIS: descendant - All descendant nodes (without self)
|
|
448
483
|
def on_axis_descendant(ast, input)
|
|
449
484
|
node = unique_literal(:descendant)
|
|
@@ -494,6 +529,13 @@ module Moxml
|
|
|
494
529
|
when "node"
|
|
495
530
|
# node() matches everything — use a truthy literal
|
|
496
531
|
Ruby::Node.new(:lit, ["true"])
|
|
532
|
+
when "text"
|
|
533
|
+
# CDATA sections are text nodes in the XPath data model
|
|
534
|
+
text_class = const_ref("Moxml", "Text")
|
|
535
|
+
cdata_class = const_ref("Moxml", "Cdata")
|
|
536
|
+
input.is_a?(text_class).or(input.is_a?(cdata_class))
|
|
537
|
+
when "comment"
|
|
538
|
+
input.is_a?(const_ref("Moxml", "Comment"))
|
|
497
539
|
else element_or_attribute(input)
|
|
498
540
|
end
|
|
499
541
|
end
|
|
@@ -531,9 +573,21 @@ module Moxml
|
|
|
531
573
|
# Match namespace if specified
|
|
532
574
|
if ns && ns != STAR
|
|
533
575
|
if @namespaces && @namespaces[ns]
|
|
534
|
-
# Resolve prefix to URI using namespace mappings
|
|
576
|
+
# Resolve prefix to URI using namespace mappings. An empty
|
|
577
|
+
# URI binding ("xmlns" => "") selects elements in no
|
|
578
|
+
# namespace, e.g. after an xmlns="" undeclaration.
|
|
535
579
|
ns_uri = @namespaces[ns]
|
|
536
|
-
ns_match =
|
|
580
|
+
ns_match = if ns_uri.to_s.empty?
|
|
581
|
+
input.namespace.nil?.or(input.namespace.uri.eq(string("")))
|
|
582
|
+
else
|
|
583
|
+
input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
|
|
584
|
+
end
|
|
585
|
+
elsif ns == "xmlns"
|
|
586
|
+
# Nokogiri-compatible reserved prefix: xmlns:name addresses
|
|
587
|
+
# elements in the default namespace (no prefix).
|
|
588
|
+
ns_match = input.namespace.and(
|
|
589
|
+
input.namespace.prefix.to_s.eq(string("")),
|
|
590
|
+
)
|
|
537
591
|
else
|
|
538
592
|
# No mapping provided - check against element's namespace prefix
|
|
539
593
|
# Need to ensure input.namespace exists first
|
|
@@ -977,10 +1031,9 @@ module Moxml
|
|
|
977
1031
|
conversions << conversion.to_string(arg_var)
|
|
978
1032
|
end
|
|
979
1033
|
|
|
980
|
-
# rubocop:disable
|
|
1034
|
+
# rubocop:disable-next Performance/Sum
|
|
981
1035
|
concatted = assigns.inject(:followed_by)
|
|
982
1036
|
.followed_by(conversions.inject(:+))
|
|
983
|
-
# rubocop:enable Style/RedundantSum, Performance/Sum
|
|
984
1037
|
|
|
985
1038
|
block_given? ? concatted.empty?.if_false { yield concatted } : concatted
|
|
986
1039
|
end
|
data/lib/moxml/xpath/parser.rb
CHANGED
|
@@ -327,11 +327,18 @@ module Moxml
|
|
|
327
327
|
def parse_relative_path
|
|
328
328
|
steps = [parse_step]
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
# Continue on both step separators: "/step" and the
|
|
331
|
+
# descendant-or-self shorthand "//step". A "//" arrives as a
|
|
332
|
+
# single :dslash (".//step", "a//step") or occasionally as a
|
|
333
|
+
# :slash/:slash or :slash/:dslash pair.
|
|
334
|
+
while !at_end? && match?(:slash, :dslash)
|
|
335
|
+
is_dslash = match?(:dslash)
|
|
331
336
|
advance
|
|
332
|
-
if match?(:slash)
|
|
333
|
-
# Double slash within path: expands to descendant-or-self::node()
|
|
337
|
+
if !is_dslash && match?(:slash, :dslash)
|
|
334
338
|
advance
|
|
339
|
+
is_dslash = true
|
|
340
|
+
end
|
|
341
|
+
if is_dslash
|
|
335
342
|
steps << AST::Node.axis("descendant-or-self", AST::Node.node_type("node"))
|
|
336
343
|
end
|
|
337
344
|
steps << parse_step unless at_end? || match?(:pipe, :rbracket,
|
|
@@ -352,14 +359,10 @@ module Moxml
|
|
|
352
359
|
return AST::Node.parent
|
|
353
360
|
elsif match?(:at)
|
|
354
361
|
advance
|
|
355
|
-
# Attribute: @name or @*
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
else
|
|
360
|
-
name = consume(:name, "Expected attribute name after @")
|
|
361
|
-
node_test = AST::Node.test(nil, name[1])
|
|
362
|
-
end
|
|
362
|
+
# Attribute: @name, @prefix:name, @prefix:* or @* (XPath 1.0
|
|
363
|
+
# §3.2, NameTest). parse_node_test handles prefixed and
|
|
364
|
+
# wildcard names; bare names land in its plain-name branch.
|
|
365
|
+
node_test = parse_node_test
|
|
363
366
|
step = AST::Node.axis("attribute", node_test)
|
|
364
367
|
return parse_predicates(step)
|
|
365
368
|
end
|
|
@@ -385,14 +388,18 @@ module Moxml
|
|
|
385
388
|
if match?(:star)
|
|
386
389
|
advance
|
|
387
390
|
return AST::Node.wildcard
|
|
388
|
-
elsif match?(:node_type)
|
|
391
|
+
elsif match?(:node_type) && peek_is?(:lparen)
|
|
389
392
|
type_name = current_value
|
|
390
393
|
advance
|
|
391
394
|
consume(:lparen, "Expected '(' after node type")
|
|
392
395
|
consume(:rparen, "Expected ')' after node type")
|
|
393
396
|
return AST::Node.node_type(type_name)
|
|
394
|
-
elsif match?(:name, :and, :or, :mod, :div)
|
|
395
|
-
# Accept keywords as valid element names (they're valid XML
|
|
397
|
+
elsif match?(:name, :node_type, :and, :or, :mod, :div)
|
|
398
|
+
# Accept keywords as valid element names (they're valid XML
|
|
399
|
+
# names). Node-type words (text, comment, node) also land here
|
|
400
|
+
# when not followed by '(' — they name elements, either alone
|
|
401
|
+
# ("//text") or as the local part of a prefixed name
|
|
402
|
+
# ("//svg:text").
|
|
396
403
|
name = current_value
|
|
397
404
|
advance
|
|
398
405
|
|
|
@@ -402,7 +409,7 @@ module Moxml
|
|
|
402
409
|
if match?(:star)
|
|
403
410
|
advance
|
|
404
411
|
return AST::Node.test(name, "*")
|
|
405
|
-
elsif match?(:name, :and, :or, :mod, :div)
|
|
412
|
+
elsif match?(:name, :node_type, :and, :or, :mod, :div)
|
|
406
413
|
# Accept keywords as local names too
|
|
407
414
|
local_name = current_value
|
|
408
415
|
advance
|
data/lib/moxml.rb
CHANGED
|
@@ -76,6 +76,7 @@ module Moxml
|
|
|
76
76
|
autoload :Cdata, "moxml/cdata"
|
|
77
77
|
autoload :Comment, "moxml/comment"
|
|
78
78
|
autoload :Attribute, "moxml/attribute"
|
|
79
|
+
autoload :AttributeResolver, "moxml/attribute_resolver"
|
|
79
80
|
autoload :ProcessingInstruction, "moxml/processing_instruction"
|
|
80
81
|
autoload :Declaration, "moxml/declaration"
|
|
81
82
|
autoload :Namespace, "moxml/namespace"
|
|
@@ -83,9 +84,11 @@ module Moxml
|
|
|
83
84
|
autoload :EntityReference, "moxml/entity_reference"
|
|
84
85
|
autoload :DocumentBuilder, "moxml/document_builder"
|
|
85
86
|
autoload :Builder, "moxml/builder"
|
|
87
|
+
autoload :Entity, "moxml/entity"
|
|
86
88
|
autoload :EntityRegistry, "moxml/entity_registry"
|
|
87
89
|
autoload :NativeAttachment, "moxml/native_attachment"
|
|
88
90
|
autoload :XmlUtils, "moxml/xml_utils"
|
|
91
|
+
autoload :XmlEmitter, "moxml/xml_emitter"
|
|
89
92
|
autoload :Adapter, "moxml/adapter"
|
|
90
93
|
autoload :XPath, "moxml/xpath"
|
|
91
94
|
autoload :SAX, "moxml/sax"
|
|
@@ -110,10 +110,6 @@ RSpec.shared_examples "README Examples" do
|
|
|
110
110
|
describe "Error handling example" do
|
|
111
111
|
it "handles errors as shown in README" do
|
|
112
112
|
context = Moxml.new
|
|
113
|
-
if context.config.adapter_name == :ox
|
|
114
|
-
pending "Ox doesn't have a native XPath"
|
|
115
|
-
end
|
|
116
|
-
|
|
117
113
|
expect do
|
|
118
114
|
context.parse("<invalid>")
|
|
119
115
|
end.to raise_error(Moxml::ParseError)
|
|
@@ -24,20 +24,12 @@ RSpec.shared_examples "XPath Examples" do
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
it "finds nodes with namespaces" do
|
|
27
|
-
if context.config.adapter_name == :ox
|
|
28
|
-
pending "Ox doesn't have a native XPath"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
27
|
titles = doc.xpath("//dc:title",
|
|
32
28
|
"dc" => "http://purl.org/dc/elements/1.1/")
|
|
33
29
|
expect(titles.map(&:text)).to eq(%w[First Second])
|
|
34
30
|
end
|
|
35
31
|
|
|
36
32
|
it "finds nodes by attributes" do
|
|
37
|
-
if %i[ox headed_ox].include?(context.config.adapter_name)
|
|
38
|
-
pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
33
|
book = doc.at_xpath('//book[@id="2"]')
|
|
42
34
|
expect(book).not_to be_nil
|
|
43
35
|
title = book.at_xpath(".//dc:title",
|
|
@@ -46,9 +38,6 @@ RSpec.shared_examples "XPath Examples" do
|
|
|
46
38
|
end
|
|
47
39
|
|
|
48
40
|
it "finds nested attributes efficiently" do
|
|
49
|
-
if %i[ox headed_ox].include?(context.config.adapter_name)
|
|
50
|
-
pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
|
|
51
|
-
end
|
|
52
41
|
# More efficient - specific path
|
|
53
42
|
titles1 = doc.xpath("//book/dc:title")
|
|
54
43
|
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Leptris needs the unreleased programmatic-document-construction C
|
|
4
|
+
# API; skip its integration matrix until the binding ships it.
|
|
5
|
+
#
|
|
6
|
+
# const_defined? only — deliberately NO require here: loading a native
|
|
7
|
+
# gem at example-group definition time is a load-order side effect
|
|
8
|
+
# (and the only new load-time behavior on this branch). The leptris
|
|
9
|
+
# adapter spec is what loads the gem in its own process.
|
|
10
|
+
def leptris_ready?
|
|
11
|
+
return false unless Object.const_defined?(:Leptris)
|
|
12
|
+
|
|
13
|
+
Leptris::XML::Document.respond_to?(:create)
|
|
14
|
+
end
|
|
15
|
+
|
|
3
16
|
RSpec.describe "Cross-adapter integration" do
|
|
4
17
|
# Integration shared examples - use the names as defined in the files
|
|
5
18
|
all_shared_examples = [
|
|
@@ -41,6 +54,10 @@ RSpec.describe "Cross-adapter integration" do
|
|
|
41
54
|
end
|
|
42
55
|
end
|
|
43
56
|
|
|
57
|
+
if adapter_name == :leptris && !leptris_ready?
|
|
58
|
+
before { skip "requires leptris with Leptris::XML::Document.create (unreleased C API)" }
|
|
59
|
+
end
|
|
60
|
+
|
|
44
61
|
all_shared_examples.each do |shared_example_name|
|
|
45
62
|
it_behaves_like shared_example_name
|
|
46
63
|
end
|