moxml 0.5.32 → 0.5.34
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/Rakefile +8 -0
- data/benchmark/pipeline_bench.rb +74 -0
- data/docs/_pages/adapter-protocol.adoc +5 -0
- data/docs/_pages/node-api-reference.adoc +26 -0
- data/docs/_pages/performance.adoc +43 -0
- data/lib/moxml/adapter/base.rb +25 -2
- data/lib/moxml/adapter/leptris.rb +85 -25
- data/lib/moxml/adapter/nokogiri.rb +21 -14
- data/lib/moxml/adapter/oga.rb +14 -0
- data/lib/moxml/adapter/ox.rb +42 -13
- data/lib/moxml/adapter/rexml.rb +9 -1
- data/lib/moxml/attribute.rb +39 -4
- data/lib/moxml/attribute_resolver.rb +12 -8
- data/lib/moxml/c14n/exclusive.rb +1 -1
- data/lib/moxml/context.rb +29 -6
- data/lib/moxml/document.rb +1 -1
- data/lib/moxml/element.rb +59 -10
- data/lib/moxml/node.rb +25 -5
- data/lib/moxml/text.rb +1 -1
- data/lib/moxml/version.rb +1 -1
- data/spec/integration/contract_parity_spec.rb +136 -0
- data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +4 -1
- data/spec/moxml/adapter/leptris_spec.rb +53 -0
- metadata +4 -2
data/lib/moxml/attribute.rb
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Moxml
|
|
4
4
|
class Attribute < Node
|
|
5
|
+
# Immutable between name= writes (which clear it via
|
|
6
|
+
# clear_native_memo!), unlike element names that can change via
|
|
7
|
+
# native adoption; the memo removes an adapter read per access.
|
|
5
8
|
def name
|
|
6
|
-
adapter.attribute_name(@native)
|
|
9
|
+
@name ||= adapter.attribute_name(@native)
|
|
7
10
|
end
|
|
8
11
|
|
|
9
12
|
def name=(new_name)
|
|
@@ -11,6 +14,7 @@ module Moxml
|
|
|
11
14
|
# to keep tracking — same object for in-place adapters, fresh
|
|
12
15
|
# object for value-object adapters (leptris).
|
|
13
16
|
context.bump_namespace_scope_generation
|
|
17
|
+
@name = nil
|
|
14
18
|
@native = adapter.set_attribute_name(@native, new_name)
|
|
15
19
|
end
|
|
16
20
|
|
|
@@ -22,7 +26,16 @@ module Moxml
|
|
|
22
26
|
|
|
23
27
|
def value
|
|
24
28
|
val = @native.value.to_s
|
|
25
|
-
|
|
29
|
+
# Same guard as Element#text: entity-free documents skip the
|
|
30
|
+
# marker restore scans. The memo rides the owning element's
|
|
31
|
+
# (attr natives have no entity probe); a detached attribute
|
|
32
|
+
# wrapper falls back to the unconditional restore.
|
|
33
|
+
parent = @parent_node
|
|
34
|
+
if parent.nil? || parent.entity_bearing?
|
|
35
|
+
adapter.restore_entities(val)
|
|
36
|
+
else
|
|
37
|
+
val
|
|
38
|
+
end
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
alias content value
|
|
@@ -33,7 +46,13 @@ module Moxml
|
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def value=(new_value)
|
|
36
|
-
|
|
49
|
+
name = self.name
|
|
50
|
+
if name == "xmlns" || name.start_with?("xmlns:")
|
|
51
|
+
# Declaration rewrite — namespace scope changed
|
|
52
|
+
context.bump_namespace_scope_generation
|
|
53
|
+
else
|
|
54
|
+
@parent_node&.invalidate_attribute_value_cache!
|
|
55
|
+
end
|
|
37
56
|
adapter.set_attribute_value(@native, new_value)
|
|
38
57
|
end
|
|
39
58
|
|
|
@@ -58,10 +77,26 @@ module Moxml
|
|
|
58
77
|
native_elem && Moxml::Node.wrap(native_elem, context)
|
|
59
78
|
end
|
|
60
79
|
|
|
80
|
+
# The wrapper's parent tracking is authoritative for attributes
|
|
81
|
+
# (set at materialization by Element#attributes): the generic
|
|
82
|
+
# adapter read raises on engines whose Attr natives expose no
|
|
83
|
+
# #parent (leptris), and the tracked value needs no wrap.
|
|
84
|
+
def parent
|
|
85
|
+
@parent_node
|
|
86
|
+
end
|
|
87
|
+
|
|
61
88
|
def remove
|
|
89
|
+
# The name must be read before the removal — engines free the
|
|
90
|
+
# attribute native, and post-removal reads are use-after-free.
|
|
91
|
+
name = self.name
|
|
92
|
+
declaration = name == "xmlns" || name.start_with?("xmlns:")
|
|
62
93
|
adapter.remove_attribute_native(@native)
|
|
63
94
|
if @parent_node.is_a?(Moxml::Element)
|
|
64
|
-
|
|
95
|
+
if declaration
|
|
96
|
+
@parent_node.invalidate_attribute_cache!
|
|
97
|
+
else
|
|
98
|
+
@parent_node.invalidate_local_attribute_cache!
|
|
99
|
+
end
|
|
65
100
|
end
|
|
66
101
|
self
|
|
67
102
|
end
|
|
@@ -41,7 +41,7 @@ module Moxml
|
|
|
41
41
|
uri = prefix_uri(element, prefix)
|
|
42
42
|
return nil if uri.nil?
|
|
43
43
|
|
|
44
|
-
adapter = element.
|
|
44
|
+
adapter = element.adapter
|
|
45
45
|
if adapter.is_a?(Moxml::Adapter::Leptris)
|
|
46
46
|
return adapter.expanded_attr_value(element.native, uri, local)
|
|
47
47
|
end
|
|
@@ -105,8 +105,12 @@ module Moxml
|
|
|
105
105
|
# @return [String] the assigned value
|
|
106
106
|
def assign(element, name, value)
|
|
107
107
|
name = name.to_s
|
|
108
|
-
adapter = element.
|
|
109
|
-
|
|
108
|
+
adapter = element.adapter
|
|
109
|
+
# One probe covers both spellings ("xmlns" and "xmlns:*"); a
|
|
110
|
+
# plain attribute literally starting with "xmlns" would only
|
|
111
|
+
# over-invalidate (global bump instead of local) — a no-op
|
|
112
|
+
# for correctness.
|
|
113
|
+
if name.start_with?("xmlns")
|
|
110
114
|
adapter.set_attribute(element.native, name, value)
|
|
111
115
|
element.invalidate_attribute_cache!
|
|
112
116
|
return value
|
|
@@ -122,7 +126,7 @@ module Moxml
|
|
|
122
126
|
# writes.
|
|
123
127
|
if !name.include?(":") && adapter.bare_set_qname_safe?
|
|
124
128
|
adapter.set_attribute(element.native, name, value)
|
|
125
|
-
element.
|
|
129
|
+
element.invalidate_local_attribute_cache!
|
|
126
130
|
return value
|
|
127
131
|
end
|
|
128
132
|
|
|
@@ -133,7 +137,7 @@ module Moxml
|
|
|
133
137
|
end
|
|
134
138
|
|
|
135
139
|
adapter.set_attribute(element.native, name, value)
|
|
136
|
-
element.
|
|
140
|
+
element.invalidate_local_attribute_cache!
|
|
137
141
|
value
|
|
138
142
|
end
|
|
139
143
|
|
|
@@ -143,8 +147,8 @@ module Moxml
|
|
|
143
147
|
# @return [Moxml::Attribute, nil] the removed attribute
|
|
144
148
|
def remove(element, name)
|
|
145
149
|
name = name.to_s
|
|
146
|
-
adapter = element.
|
|
147
|
-
if name
|
|
150
|
+
adapter = element.adapter
|
|
151
|
+
if name.start_with?("xmlns")
|
|
148
152
|
adapter.remove_attribute(element.native, name)
|
|
149
153
|
element.invalidate_attribute_cache!
|
|
150
154
|
return nil
|
|
@@ -154,7 +158,7 @@ module Moxml
|
|
|
154
158
|
return nil unless attr
|
|
155
159
|
|
|
156
160
|
adapter.remove_attribute_native(attr.native)
|
|
157
|
-
element.
|
|
161
|
+
element.invalidate_local_attribute_cache!
|
|
158
162
|
attr
|
|
159
163
|
end
|
|
160
164
|
|
data/lib/moxml/c14n/exclusive.rb
CHANGED
|
@@ -139,7 +139,7 @@ module Moxml
|
|
|
139
139
|
|
|
140
140
|
def bindings_declared_on(element)
|
|
141
141
|
bindings = {}
|
|
142
|
-
element.
|
|
142
|
+
element.namespace_definitions.each do |ns|
|
|
143
143
|
prefix = ns.prefix
|
|
144
144
|
prefix = "" if prefix.nil? || prefix == "xmlns"
|
|
145
145
|
bindings[prefix] = ns.uri
|
data/lib/moxml/context.rb
CHANGED
|
@@ -24,6 +24,13 @@ module Moxml
|
|
|
24
24
|
# up to the old 65,536 wholesale-clear valve which also
|
|
25
25
|
# destroyed identity for live wrappers when it fired.
|
|
26
26
|
@wrappers = WEAK_WRAPPERS ? ObjectSpace::WeakMap.new : {}.compare_by_identity
|
|
27
|
+
# Static per context (the registry flavor never changes).
|
|
28
|
+
@wrappers_strong = !WEAK_WRAPPERS
|
|
29
|
+
# Resolved on first registration — the adapter-resolution
|
|
30
|
+
# chain ran per mint and the decision never changes for a
|
|
31
|
+
# context's adapter (re-configuring an adapter mid-context is
|
|
32
|
+
# unsupported: minted wrappers already carry the old one).
|
|
33
|
+
@register_wrappers = nil
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
def wrapper_for(native)
|
|
@@ -31,15 +38,19 @@ module Moxml
|
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def register_wrapper(native, wrapper)
|
|
34
|
-
# Adapter opt-in
|
|
35
|
-
# access (libxml mints fresh
|
|
36
|
-
#
|
|
37
|
-
# default is opt-in.
|
|
38
|
-
|
|
41
|
+
# Adapter opt-in, resolved once (see initialize): adapters
|
|
42
|
+
# whose natives are recreated per access (libxml mints fresh
|
|
43
|
+
# Ruby objects for the same C node) opt out so the map does
|
|
44
|
+
# not accumulate dead entries; the default is opt-in.
|
|
45
|
+
if @register_wrappers.nil?
|
|
46
|
+
@register_wrappers =
|
|
47
|
+
@config&.adapter&.wrappers_recyclable? != false
|
|
48
|
+
end
|
|
49
|
+
return unless @register_wrappers
|
|
39
50
|
|
|
40
51
|
# The strong fallback (Opal) needs the wholesale-clear valve;
|
|
41
52
|
# the WeakMap registry is self-cleaning.
|
|
42
|
-
@wrappers.clear if @
|
|
53
|
+
@wrappers.clear if @wrappers_strong && @wrappers.size >= 65_536
|
|
43
54
|
@wrappers[native] = wrapper
|
|
44
55
|
end
|
|
45
56
|
|
|
@@ -106,6 +117,18 @@ module Moxml
|
|
|
106
117
|
# Memory stays bounded by the largest subtree, not the document
|
|
107
118
|
# (iterparse_file streams the file C-side). Yielded elements are
|
|
108
119
|
# parentless and valid only inside the block.
|
|
120
|
+
# Parse an XML fragment: returns its top-level nodes as an
|
|
121
|
+
# Array of wrappers, uniformly across adapters (issue #188 —
|
|
122
|
+
# fragment: true was Nokogiri-only). Engines without a fragment
|
|
123
|
+
# node type parse inside a synthetic root; the fragment's nodes
|
|
124
|
+
# are the result either way.
|
|
125
|
+
def parse_fragment(xml)
|
|
126
|
+
adapter = config.adapter
|
|
127
|
+
adapter.parse_fragment(xml, self).map do |native|
|
|
128
|
+
Moxml::Node.wrap(native, self)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
109
132
|
def iterparse(xml, mode: :top_level, &block)
|
|
110
133
|
config.adapter.iterparse(xml, mode, self, &block)
|
|
111
134
|
end
|
data/lib/moxml/document.rb
CHANGED
data/lib/moxml/element.rb
CHANGED
|
@@ -63,7 +63,16 @@ module Moxml
|
|
|
63
63
|
# names, where resolution is real work.
|
|
64
64
|
adapter = self.adapter
|
|
65
65
|
if !key.include?(":") && adapter.bare_get_qname_safe?
|
|
66
|
-
|
|
66
|
+
# The entity-restore decision rides the wrapper's generation
|
|
67
|
+
# memo (as Element#text) — the adapter-level probe walks to
|
|
68
|
+
# the document and the attachment store on every read, which
|
|
69
|
+
# dominated the fast path.
|
|
70
|
+
value = adapter.bare_attr_value(@native, key)
|
|
71
|
+
if value.is_a?(String) && entity_bearing?
|
|
72
|
+
return adapter.restore_entities(value)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
cache = attribute_read_cache
|
|
@@ -86,7 +95,11 @@ module Moxml
|
|
|
86
95
|
# every attribute or namespace mutation anywhere bumps it.
|
|
87
96
|
def attribute_read_cache
|
|
88
97
|
generation = context.namespace_scope_generation
|
|
89
|
-
|
|
98
|
+
# @attribute_cache.nil? covers the local invalidation clears —
|
|
99
|
+
# a value or list change without a scope bump (see
|
|
100
|
+
# invalidate_local_attribute_cache!) — so writes never allocate
|
|
101
|
+
# a replacement hash; the next read materializes one lazily.
|
|
102
|
+
if @attribute_cache.nil? || @attribute_cache_generation != generation
|
|
90
103
|
@attribute_cache = {}
|
|
91
104
|
@attribute_cache_generation = generation
|
|
92
105
|
end
|
|
@@ -99,8 +112,11 @@ module Moxml
|
|
|
99
112
|
end
|
|
100
113
|
|
|
101
114
|
def attributes
|
|
115
|
+
# Primed like Node.wrap: the adapter and type are known at
|
|
116
|
+
# mint, so the first name/value/attribute? access skips the
|
|
117
|
+
# context hop and the adapter's type probe.
|
|
102
118
|
@attributes ||= adapter.attributes(@native).map do |attr|
|
|
103
|
-
a = Attribute.new(attr, context)
|
|
119
|
+
a = Attribute.new(attr, context, adapter, :attribute)
|
|
104
120
|
a.parent_node = self
|
|
105
121
|
a
|
|
106
122
|
end
|
|
@@ -180,12 +196,25 @@ module Moxml
|
|
|
180
196
|
invalidate_namespace_cache!
|
|
181
197
|
end
|
|
182
198
|
|
|
199
|
+
# All namespaces IN SCOPE for this element — its own
|
|
200
|
+
# declarations plus everything inherited from ancestors —
|
|
201
|
+
# matching the Nokogiri #namespaces contract consumers port
|
|
202
|
+
# against (issue #198: this returned only own declarations,
|
|
203
|
+
# losing ancestor scope under every backend).
|
|
183
204
|
def namespaces
|
|
184
|
-
|
|
205
|
+
in_scope_namespaces
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The element's OWN namespace declarations only (not
|
|
209
|
+
# inherited). C14n's visibly-utilized calculation and the
|
|
210
|
+
# materializer's declaration pairs want exactly this shape.
|
|
211
|
+
def namespace_definitions
|
|
212
|
+
return @namespace_definitions unless @namespace_definitions.nil?
|
|
213
|
+
|
|
214
|
+
@namespace_definitions = adapter.namespace_definitions(@native).map do |ns|
|
|
185
215
|
Namespace.new(ns, context)
|
|
186
216
|
end
|
|
187
217
|
end
|
|
188
|
-
alias namespace_definitions namespaces
|
|
189
218
|
|
|
190
219
|
# The element's OWN namespace declarations as [prefix, uri]
|
|
191
220
|
# pairs (nil prefix = default namespace) — not the inherited
|
|
@@ -217,7 +246,10 @@ module Moxml
|
|
|
217
246
|
|
|
218
247
|
def text
|
|
219
248
|
val = adapter.text_content(@native)
|
|
220
|
-
|
|
249
|
+
# Entity-free documents (the common case) skip the marker
|
|
250
|
+
# restore scans entirely — the per-wrapper entity_bearing?
|
|
251
|
+
# memo rides the adapter's serialize generation.
|
|
252
|
+
entity_bearing? ? adapter.restore_entities(val) : val
|
|
221
253
|
end
|
|
222
254
|
|
|
223
255
|
alias content text
|
|
@@ -229,7 +261,7 @@ module Moxml
|
|
|
229
261
|
|
|
230
262
|
def inner_text
|
|
231
263
|
text = raw_inner_text
|
|
232
|
-
adapter.restore_entities(text)
|
|
264
|
+
entity_bearing? ? adapter.restore_entities(text) : text
|
|
233
265
|
end
|
|
234
266
|
|
|
235
267
|
# Returns inner text without entity marker restoration.
|
|
@@ -291,9 +323,25 @@ module Moxml
|
|
|
291
323
|
children
|
|
292
324
|
end
|
|
293
325
|
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
326
|
+
# Attribute mutations that cannot change namespace scope (bare
|
|
327
|
+
# and non-xmlns prefixed names) invalidate locally: the
|
|
328
|
+
# resolved-read cache alone for value writes, the wrapper list
|
|
329
|
+
# too when the attribute set changes. No context generation
|
|
330
|
+
# bump — that would evict every wrapper's caches document-wide
|
|
331
|
+
# on each write of a bulk build.
|
|
332
|
+
def invalidate_attribute_value_cache!
|
|
333
|
+
@attribute_cache = nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def invalidate_local_attribute_cache!
|
|
337
|
+
@attributes = nil
|
|
338
|
+
@attribute_cache = nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Called by the namespace-scoped attribute paths (xmlns writes
|
|
342
|
+
# and removals) and Attribute#name=. Clears the attribute list
|
|
343
|
+
# and the resolved-read cache, and bumps the context generation
|
|
344
|
+
# so cross-wrapper reads recompute.
|
|
297
345
|
def invalidate_attribute_cache!
|
|
298
346
|
@attributes = nil
|
|
299
347
|
@attribute_cache = nil
|
|
@@ -305,6 +353,7 @@ module Moxml
|
|
|
305
353
|
# any children cache — recomputes on next read.
|
|
306
354
|
def invalidate_namespace_cache!
|
|
307
355
|
@namespaces = nil
|
|
356
|
+
@namespace_definitions = nil
|
|
308
357
|
@in_scope_namespaces = nil
|
|
309
358
|
context.bump_namespace_scope_generation
|
|
310
359
|
end
|
data/lib/moxml/node.rb
CHANGED
|
@@ -12,10 +12,16 @@ module Moxml
|
|
|
12
12
|
|
|
13
13
|
attr_reader :native, :context
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# adapter/node_type are primed by Node.wrap, which resolves both
|
|
16
|
+
# before choosing the wrapper class — a fresh wrapper would
|
|
17
|
+
# otherwise pay the context hop and the type probe again on its
|
|
18
|
+
# first access.
|
|
19
|
+
def initialize(native, context, adapter = nil, node_type = nil)
|
|
16
20
|
@context = context
|
|
17
21
|
@native = native
|
|
18
22
|
@parent_node = nil
|
|
23
|
+
@adapter = adapter
|
|
24
|
+
@node_type_cached = node_type
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# Update native reference after identity-changing operations
|
|
@@ -349,6 +355,18 @@ module Moxml
|
|
|
349
355
|
adapter.line_number(@native)
|
|
350
356
|
end
|
|
351
357
|
|
|
358
|
+
# Content-defined Merkle digest of this subtree (issue #173,
|
|
359
|
+
# companion to leptris#869): a u64 Integer where the backend
|
|
360
|
+
# computes one, nil everywhere else. Consumers gate on nil and
|
|
361
|
+
# fall back to walking. Equal digests imply subtree equivalence
|
|
362
|
+
# under the flag set; unequal digests imply nothing (descend).
|
|
363
|
+
# +drop_ws_text+ skips whitespace-only text nodes.
|
|
364
|
+
#
|
|
365
|
+
# @return [Integer, nil]
|
|
366
|
+
def digest(drop_ws_text: false)
|
|
367
|
+
adapter.digest(@native, drop_ws_text: drop_ws_text)
|
|
368
|
+
end
|
|
369
|
+
|
|
352
370
|
def outer_xml
|
|
353
371
|
to_xml
|
|
354
372
|
end
|
|
@@ -418,10 +436,12 @@ module Moxml
|
|
|
418
436
|
cached = context.wrapper_for(node)
|
|
419
437
|
return cached if cached
|
|
420
438
|
|
|
421
|
-
|
|
439
|
+
adapter = adapter(context)
|
|
440
|
+
type = adapter.node_type(node)
|
|
422
441
|
klass = node_type_map[type] || self
|
|
423
442
|
|
|
424
|
-
klass.new(node, context
|
|
443
|
+
klass.new(node, context, adapter, type)
|
|
444
|
+
.tap { |wrapper| context.register_wrapper(node, wrapper) }
|
|
425
445
|
end
|
|
426
446
|
|
|
427
447
|
# Internal: Set the parent node for cache invalidation tracking.
|
|
@@ -429,14 +449,14 @@ module Moxml
|
|
|
429
449
|
# relationships. Public to allow cross-class usage within Moxml internals.
|
|
430
450
|
attr_writer :parent_node
|
|
431
451
|
|
|
432
|
-
protected
|
|
433
|
-
|
|
434
452
|
def adapter
|
|
435
453
|
# A context's adapter object is fixed for its lifetime; the
|
|
436
454
|
# chain deref ran on every node access.
|
|
437
455
|
@adapter ||= context.config.adapter
|
|
438
456
|
end
|
|
439
457
|
|
|
458
|
+
protected
|
|
459
|
+
|
|
440
460
|
def self.adapter(context)
|
|
441
461
|
context.config.adapter
|
|
442
462
|
end
|
data/lib/moxml/text.rb
CHANGED
data/lib/moxml/version.rb
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Cross-adapter contract parity (issue #198): every bundled adapter
|
|
4
|
+
# runs the same behavioral battery, so parity is CI-enforced rather
|
|
5
|
+
# than discovered by consumers.
|
|
6
|
+
%w[nokogiri leptris ox oga rexml].each do |adapter_name|
|
|
7
|
+
begin
|
|
8
|
+
next unless Moxml::Adapter.load_available?(adapter_name.to_sym)
|
|
9
|
+
rescue StandardError
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec.describe "Contract parity: #{adapter_name}", :adapter do
|
|
14
|
+
around do |example|
|
|
15
|
+
Moxml.with_config(adapter_name.to_sym, true, "UTF-8") do
|
|
16
|
+
example.run
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:ctx) { Moxml.new(adapter_name.to_sym) }
|
|
21
|
+
|
|
22
|
+
describe "fragment parsing (issue #188)" do
|
|
23
|
+
it "returns the fragment's top-level nodes uniformly" do
|
|
24
|
+
nodes = ctx.parse_fragment(%(<a x="1">t1</a><b/>tail))
|
|
25
|
+
expect(nodes.map(&:class)).to eq(
|
|
26
|
+
[Moxml::Element, Moxml::Element, Moxml::Text],
|
|
27
|
+
)
|
|
28
|
+
expect(nodes[0]["x"]).to eq("1")
|
|
29
|
+
expect(nodes[0].text).to eq("t1")
|
|
30
|
+
expect(nodes[2].content).to eq("tail")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "round-trips entities inside fragments" do
|
|
34
|
+
nodes = ctx.parse_fragment(%(<p>café & more</p>))
|
|
35
|
+
# The moxml XML contract preserves entity REFERENCES in
|
|
36
|
+
# text reads (the entity-preservation design) — uniform
|
|
37
|
+
# across adapters, fragments included.
|
|
38
|
+
expect(nodes.first.content).to include("é")
|
|
39
|
+
expect(nodes.first.to_xml).to include("&")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "returns [] for an empty fragment" do
|
|
43
|
+
expect(ctx.parse_fragment("")).to eq([])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe "nil namespace clearing (issue #164 contract)" do
|
|
48
|
+
it "accepts namespace = nil without raising" do
|
|
49
|
+
doc = ctx.parse(%(<r xmlns="urn:d"><c>t</c></r>))
|
|
50
|
+
child = doc.root.children.first
|
|
51
|
+
expect { child.namespace = nil }.not_to raise_error
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "namespace adoption on append" do
|
|
56
|
+
it "keeps a subtree's own declarations when appended" do
|
|
57
|
+
doc = ctx.parse(%(<root/>))
|
|
58
|
+
nodes = ctx.parse_fragment(%(<p:s xmlns:p="urn:p">x</p:s>))
|
|
59
|
+
doc.root.add_child(nodes.first)
|
|
60
|
+
out = doc.to_xml
|
|
61
|
+
expect(out).to include("urn:p")
|
|
62
|
+
expect(out).to include("x")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe "qname create + namespace assignment (issue #208)" do
|
|
67
|
+
it "does not double the prefix when the name is already qualified" do
|
|
68
|
+
doc = ctx.parse(%(<r xmlns:p="urn:p"/>))
|
|
69
|
+
el = doc.create_element("p:c")
|
|
70
|
+
ns = doc.root.in_scope_namespaces.find { |n| n.prefix == "p" }
|
|
71
|
+
el.namespace = ns
|
|
72
|
+
doc.root.add_child(el)
|
|
73
|
+
out = doc.to_xml
|
|
74
|
+
expect(out).to include("p:c")
|
|
75
|
+
expect(out).not_to include("p:p:c")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe "namespaces contract (issue #198 comment)" do
|
|
80
|
+
it "returns the in-scope map including ancestor declarations" do
|
|
81
|
+
doc = ctx.parse(%(<root xmlns:p="urn:p" xmlns="urn:d"><p:c plain="1"/></root>))
|
|
82
|
+
child = doc.root.children.first
|
|
83
|
+
map = child.namespaces.map { |n| [n.prefix, n.uri.to_s] }
|
|
84
|
+
expect(map).to contain_exactly(["p", "urn:p"], [nil, "urn:d"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "namespace_definitions stays the element's own declarations" do
|
|
88
|
+
doc = ctx.parse(%(<root xmlns:p="urn:p"><p:c/></root>))
|
|
89
|
+
child = doc.root.children.first
|
|
90
|
+
expect(child.namespace_definitions).to be_empty
|
|
91
|
+
expect(doc.root.namespace_definitions.map(&:prefix)).to eq(["p"])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe "subtree digest channel (issue #173)" do
|
|
96
|
+
it "answers an Integer on digest-capable backends, nil elsewhere" do
|
|
97
|
+
doc = ctx.parse(%(<r><a x="1">t</a></r>))
|
|
98
|
+
digest = doc.root.digest
|
|
99
|
+
expect(digest).to be_nil.or be_a(Integer)
|
|
100
|
+
# Where non-nil, it is deterministic within the backend
|
|
101
|
+
doc2 = ctx.parse(%(<r><a x="1">t</a></r>))
|
|
102
|
+
expect(doc2.root.digest).to eq(digest) unless digest.nil?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "attribute channel semantics" do
|
|
107
|
+
it "reads and writes bare and prefixed attributes" do
|
|
108
|
+
doc = ctx.parse(%(<r xmlns:p="urn:p"><e a="1" p:b="2"/></r>))
|
|
109
|
+
e = doc.at_xpath("//e")
|
|
110
|
+
expect(e["a"]).to eq("1")
|
|
111
|
+
expect(e["p:b"]).to eq("2")
|
|
112
|
+
e["a"] = "changed"
|
|
113
|
+
expect(e["a"]).to eq("changed")
|
|
114
|
+
expect(e["p:b"]).to eq("2")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writes invalidate locally (no document-wide scope bump); a
|
|
118
|
+
# sibling's resolved reads must survive interleaved writes.
|
|
119
|
+
it "keeps sibling read caches coherent across interleaved writes" do
|
|
120
|
+
doc = ctx.parse(%(<r><a x="1"/><b x="2"/></r>))
|
|
121
|
+
first, second = doc.root.children.to_a
|
|
122
|
+
expect(first["x"]).to eq("1")
|
|
123
|
+
expect(second["x"]).to eq("2")
|
|
124
|
+
first["x"] = "one"
|
|
125
|
+
first["y"] = "fresh"
|
|
126
|
+
expect(second["x"]).to eq("2")
|
|
127
|
+
expect(first["x"]).to eq("one")
|
|
128
|
+
expect(first["y"]).to eq("fresh")
|
|
129
|
+
second["x"] = "two"
|
|
130
|
+
expect(first["x"]).to eq("one")
|
|
131
|
+
expect(first["y"]).to eq("fresh")
|
|
132
|
+
expect(second["x"]).to eq("two")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -134,7 +134,10 @@ RSpec.shared_examples "Moxml::Namespace" do
|
|
|
134
134
|
child.add_namespace("dc", "http://purl.org/dc/elements/1.1/")
|
|
135
135
|
root.add_child(child)
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# Own declarations (the #namespaces name moved to the
|
|
138
|
+
# in-scope Nokogiri contract — issue #198; this assertion
|
|
139
|
+
# pins own-declarations, which is what it always meant).
|
|
140
|
+
ns_defs = child.namespace_definitions
|
|
138
141
|
prefixes = ns_defs.map(&:prefix)
|
|
139
142
|
|
|
140
143
|
expect(prefixes).to contain_exactly("dc")
|
|
@@ -634,4 +634,57 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
634
634
|
expect(doc.at_xpath("//a").children.to_a.map(&:class)).to include(Moxml::EntityReference)
|
|
635
635
|
end
|
|
636
636
|
end
|
|
637
|
+
|
|
638
|
+
describe "subtree digest (issue #173, leptris#869)" do
|
|
639
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
640
|
+
|
|
641
|
+
it "answers equal integers for identical subtrees parsed separately" do
|
|
642
|
+
xml = %(<r xmlns:p="urn:p"><a x="1" p:y="2">t</a><b><c/></b></r>)
|
|
643
|
+
d1 = ctx.parse(xml)
|
|
644
|
+
d2 = ctx.parse(xml)
|
|
645
|
+
expect(d1.root.digest).to be_a(Integer)
|
|
646
|
+
expect(d1.root.digest).to eq(d2.root.digest)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
it "answers unequal integers when content differs" do
|
|
650
|
+
d1 = ctx.parse(%(<r><a x="1"/></r>))
|
|
651
|
+
d2 = ctx.parse(%(<r><a x="2"/></r>))
|
|
652
|
+
expect(d1.root.digest).not_to eq(d2.root.digest)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it "skips whitespace-only text with drop_ws_text" do
|
|
656
|
+
spaced = ctx.parse(%(<r>\n <a/>\n</r>))
|
|
657
|
+
tight = ctx.parse(%(<r><a/></r>))
|
|
658
|
+
expect(spaced.root.digest).not_to eq(tight.root.digest)
|
|
659
|
+
expect(spaced.root.digest(drop_ws_text: true))
|
|
660
|
+
.to eq(tight.root.digest(drop_ws_text: true))
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
it "hashes the prefix as well as the resolved namespace" do
|
|
664
|
+
same = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
|
|
665
|
+
mirror = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
|
|
666
|
+
renamed = ctx.parse(%(<r xmlns:q="urn:p"><q:a/></r>))
|
|
667
|
+
other_uri = ctx.parse(%(<r xmlns:p="urn:z"><p:a/></r>))
|
|
668
|
+
base = same.root.digest
|
|
669
|
+
expect(mirror.root.digest).to eq(base)
|
|
670
|
+
# prefix participates (leptris#869: hash(prefix, URI, local))
|
|
671
|
+
expect(renamed.root.digest).not_to eq(base)
|
|
672
|
+
expect(other_uri.root.digest).not_to eq(base)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it "answers nil for nodes without a C handle" do
|
|
676
|
+
doc = ctx.parse(%(<r a="1"><!-- c --><p/><?p instr?></r>))
|
|
677
|
+
expect(doc.digest).to be_nil
|
|
678
|
+
expect(doc.root.attributes.first.digest).to be_nil
|
|
679
|
+
doc.children.select(&:declaration?).each do |decl|
|
|
680
|
+
expect(decl.digest).to be_nil
|
|
681
|
+
end
|
|
682
|
+
# comments and PIs hash in C
|
|
683
|
+
kinds = doc.root.children.map { |n| [n.class, n.digest] }
|
|
684
|
+
comment = kinds.find { |n, _| n == Moxml::Comment }
|
|
685
|
+
pi = kinds.find { |n, _| n == Moxml::ProcessingInstruction }
|
|
686
|
+
expect(comment[1]).to be_a(Integer)
|
|
687
|
+
expect(pi[1]).to be_a(Integer)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
637
690
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moxml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.34
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Moxml is a unified XML manipulation library that provides a common API
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- LICENSE.md
|
|
39
39
|
- README.adoc
|
|
40
40
|
- Rakefile
|
|
41
|
+
- benchmark/pipeline_bench.rb
|
|
41
42
|
- benchmarks/.gitignore
|
|
42
43
|
- benchmarks/generate_report.rb
|
|
43
44
|
- bin/console
|
|
@@ -395,6 +396,7 @@ files:
|
|
|
395
396
|
- spec/fixtures/xmldsig/sign3-result.xml
|
|
396
397
|
- spec/integration/README.md
|
|
397
398
|
- spec/integration/all_adapters_spec.rb
|
|
399
|
+
- spec/integration/contract_parity_spec.rb
|
|
398
400
|
- spec/integration/headed_ox_integration_spec.rb
|
|
399
401
|
- spec/integration/sax_parity_spec.rb
|
|
400
402
|
- spec/integration/shared_examples/edge_cases.rb
|