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.
@@ -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
- adapter.restore_entities(val)
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
- context.bump_namespace_scope_generation
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
- @parent_node.invalidate_attribute_cache!
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.context.config.adapter
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.context.config.adapter
109
- if name == "xmlns" || name.start_with?("xmlns:")
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.invalidate_attribute_cache!
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.invalidate_attribute_cache!
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.context.config.adapter
147
- if name == "xmlns" || name.start_with?("xmlns:")
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.invalidate_attribute_cache!
161
+ element.invalidate_local_attribute_cache!
158
162
  attr
159
163
  end
160
164
 
@@ -139,7 +139,7 @@ module Moxml
139
139
 
140
140
  def bindings_declared_on(element)
141
141
  bindings = {}
142
- element.namespaces.each do |ns|
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. Adapters whose natives are recreated per
35
- # access (libxml mints fresh Ruby objects for the same C node)
36
- # opt out so the map does not accumulate dead entries; the
37
- # default is opt-in.
38
- return if @config&.adapter&.wrappers_recyclable? == false
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 @wrappers.is_a?(Hash) && @wrappers.size >= 65_536
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
@@ -4,7 +4,7 @@ module Moxml
4
4
  class Document < Node
5
5
  attr_accessor :has_xml_declaration
6
6
 
7
- def initialize(native, context)
7
+ def initialize(native, context, adapter = nil, node_type = nil)
8
8
  super
9
9
  @has_xml_declaration = false
10
10
  end
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
- return adapter.bare_attr_value(@native, key)
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
- if @attribute_cache_generation != generation
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
- @namespaces ||= adapter.namespace_definitions(@native).map do |ns|
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
- adapter.restore_entities(val)
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
- # Called by Attribute#remove and the attribute mutators. Clears
295
- # the attribute list and the resolved-read cache, and bumps the
296
- # context generation so cross-wrapper reads recompute.
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
- def initialize(native, context)
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
- type = adapter(context).node_type(node)
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).tap { |wrapper| context.register_wrapper(node, wrapper) }
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
@@ -4,7 +4,7 @@ module Moxml
4
4
  class Text < Node
5
5
  def content
6
6
  text = raw_content
7
- adapter.restore_entities(text)
7
+ entity_bearing? ? adapter.restore_entities(text) : text
8
8
  end
9
9
 
10
10
  # Returns raw content without entity marker restoration.
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.32"
4
+ VERSION = "0.5.34"
5
5
  end
@@ -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&eacute; &amp; 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("&eacute;")
39
+ expect(nodes.first.to_xml).to include("&amp;")
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
- ns_defs = child.namespaces
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.32
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-13 00:00:00.000000000 Z
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