moxml 0.5.9 → 0.5.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 011d9a8566b2697d8c01a17a1a4f702ace315361dae3b246f8ed5ad5fb79b3e8
4
- data.tar.gz: 286839c9aca15e4027d4b01bcdfb432c2036f10234fb408cf10f88e5dcbeac0d
3
+ metadata.gz: 15a0dd834bc1c9637a8d2284173d4994b1717b1bd2d8d9461686a56c679942e6
4
+ data.tar.gz: 4635e66b998d8fa371dd1617d0f960fc31b8a886627095061f945aa8b29cae41
5
5
  SHA512:
6
- metadata.gz: f53698ba5eee597fb6bda3355c1f7dfe0a33442cd3e67a6aedc73a155a23e80f049f2fc63aed6871b6cb2e85723719d7e7f318cc140ef178e5dde4bcd493a095
7
- data.tar.gz: 44d1afe3880ebc55dd214a0a7f2b48c69151504b6c5a16646afd88cf4a885184132db9a590552c2531bfda8ea84a92e8fac50a7cfae75379e215582acf0f5bac
6
+ metadata.gz: 6eace14b6adb980bc4c14c6f628b231961f47fab55e05a0bf809591b4203a6581f0cb333dc358ec3bebeb1c239f7ecd65fc68ca34ee70fb1bf71fbf8c00f278c
7
+ data.tar.gz: f03e39569fd299ac6a7e8dc266d97a5499fea6f89b166f64e46944649286df8a6ceb4fd4732b6a67cac160f2318b0ed4c79fc8d782184b3a090b9c3feb65be1e
@@ -198,6 +198,14 @@ namespace_validation_mode: :strict)
198
198
  false
199
199
  end
200
200
 
201
+ # Deterministic native-memory release for adapters backed by
202
+ # C trees (issue #134). GC-managed engines no-op; released
203
+ # documents raise the engine's use-after-free error on
204
+ # further access.
205
+ def free_document(_native)
206
+ nil
207
+ end
208
+
201
209
  # Check if the native document has an XML declaration
202
210
  # @param native_doc the native document object
203
211
  # @param wrapper [Moxml::Document] the wrapper with has_xml_declaration flag
@@ -19,6 +19,15 @@ module Moxml
19
19
  # programmatic DOCTYPE, so those live in CustomizedLeptris value
20
20
  # objects stored through NativeAttachment.
21
21
  class Leptris < Base
22
+ # libleptris 1.9.7 (leptris-ruby 1.9.26): Document#node exposes
23
+ # the libxml2-model document node — prolog/epilog parts in
24
+ # document order. Older bindings keep the flat pre-root PI path.
25
+ DOC_NODE_SUPPORTED = ::Leptris::XML::Document.method_defined?(:node)
26
+ # libleptris 1.9.8 (leptris-ruby 1.9.30): plain parse excludes
27
+ # DTD ATTLIST defaults, matching libxml2/Nokogiri semantics;
28
+ # ParseOptions::DTDATTR opts in (leptris/leptris#606).
29
+ DTDATTR_SUPPORTED = ::Leptris::XML::ParseOptions.const_defined?(:DTDATTR)
30
+
22
31
  class << self
23
32
  def attachments
24
33
  @attachments ||= Moxml::NativeAttachment.new
@@ -37,8 +46,14 @@ module Moxml
37
46
  # readonly: true (issue #133): the binding memoizes reads and
38
47
  # refuses mutations — the parse-and-read lifecycle for
39
48
  # multi-pass consumers (comparison, diff, signature).
49
+ # dtdattr: true opts into DTD ATTLIST default materialization
50
+ # (off by default since libleptris 1.9.8, matching libxml2).
40
51
  native_doc = begin
41
- ::Leptris::XML::Document.parse(processed, readonly: options[:readonly] == true)
52
+ ::Leptris::XML::Document.parse(
53
+ processed,
54
+ readonly: options[:readonly] == true,
55
+ options: dtdattr_parse_options(options),
56
+ )
42
57
  rescue ::Leptris::XML::ParseError => e
43
58
  # libleptris has no recovery mode that survives unclosed
44
59
  # tags; non-strict callers get an empty document, matching
@@ -56,6 +71,30 @@ module Moxml
56
71
  doc
57
72
  end
58
73
 
74
+ # nil when DTDATTR is unsupported or not requested — the
75
+ # binding treats a nil options hash as plain defaults.
76
+ def dtdattr_parse_options(options)
77
+ return nil unless DTDATTR_SUPPORTED && options[:dtdattr] == true
78
+
79
+ ::Leptris::XML::ParseOptions.dtdattr
80
+ end
81
+
82
+ # Issue #134: deterministic release of the C tree. The binding
83
+ # clears its wrapper cache and raises UseAfterFreeError on
84
+ # later access; moxml-side attachments for the document are
85
+ # swept too (the context wrapper identity map self-cleans via
86
+ # its size valve).
87
+ DOCUMENT_ATTACHMENT_KEYS = %i[
88
+ entity_markers doc_pi_nodes declaration doctype
89
+ had_source_declaration document_text
90
+ ].freeze
91
+
92
+ def free_document(native)
93
+ DOCUMENT_ATTACHMENT_KEYS.each { |key| attachments.delete(native, key) }
94
+ native.free
95
+ nil
96
+ end
97
+
59
98
  def create_document(_native_doc = nil)
60
99
  ::Leptris::XML::Document.create
61
100
  end
@@ -243,24 +282,33 @@ module Moxml
243
282
  # ER expansion); the bulk path has no marker handling.
244
283
  return nil if doc.nil? || attachments.get(doc, :entity_markers)
245
284
 
246
- root = native.is_a?(::Leptris::XML::Document) ? native.root : native
285
+ # leptris 1.9.28+'s traverse follows the document chain; from
286
+ # an arbitrary element it can visit following siblings. Use
287
+ # the bulk path only for document materialization, and filter
288
+ # the stream to the document element's subtree (issue #140).
289
+ return nil unless native.is_a?(::Leptris::XML::Document)
290
+
291
+ root = doc.root
247
292
  return nil if root.nil?
248
293
 
249
294
  depth_memo = {}.compare_by_identity
250
295
  root.traverse do |node|
296
+ depth = material_depth_in_subtree(node, root, depth_memo)
297
+ next if depth.nil?
298
+
251
299
  record = case node
252
300
  when ::Leptris::XML::Element
253
- element_material_record(node, depth_memo)
301
+ element_material_record(node, depth)
254
302
  when ::Leptris::XML::CDATA
255
303
  # CDATA < Text in the binding: this arm must come
256
304
  # first or CDATA content reports as text.
257
- text_material_record(:cdata, node.content, node, depth_memo)
305
+ text_material_record(:cdata, node.content, depth)
258
306
  when ::Leptris::XML::Text
259
- text_material_record(:text, node.content, node, depth_memo)
307
+ text_material_record(:text, node.content, depth)
260
308
  when ::Leptris::XML::Comment
261
- text_material_record(:comment, node.content, node, depth_memo)
309
+ text_material_record(:comment, node.content, depth)
262
310
  when ::Leptris::XML::ProcessingInstruction
263
- text_material_record(:processing_instruction, node.content, node, depth_memo)
311
+ text_material_record(:processing_instruction, node.content, depth)
264
312
  .merge(qname: node.target)
265
313
  end
266
314
  yield(record) if record
@@ -268,7 +316,7 @@ module Moxml
268
316
  true
269
317
  end
270
318
 
271
- def element_material_record(node, depth_memo)
319
+ def element_material_record(node, depth)
272
320
  attributes = node.each_attribute.map do |attr|
273
321
  # Local name + separate prefix, matching the generic
274
322
  # path's resolver semantics (moxml's canonical shape).
@@ -283,32 +331,51 @@ module Moxml
283
331
  qname: node.name,
284
332
  prefix: node.prefix,
285
333
  namespace_uri: ns&.href,
334
+ # Own declarations only — same shape as the generic
335
+ # path's Element#declared_namespaces (issue #138).
336
+ namespaces: begin
337
+ decls = node.namespace_definitions.map { |d| [d.prefix, d.href] }
338
+ decls.empty? ? Materializer::EMPTY_ATTRIBUTES : decls
339
+ end,
286
340
  attributes: attributes,
287
341
  text: nil,
288
- depth: material_depth(node, depth_memo),
342
+ depth: depth,
289
343
  }
290
344
  end
291
345
 
292
- def text_material_record(kind, text, node, depth_memo)
346
+ def text_material_record(kind, text, depth)
293
347
  {
294
348
  kind: kind,
295
349
  qname: nil,
296
350
  prefix: nil,
297
351
  namespace_uri: nil,
352
+ namespaces: Materializer::EMPTY_ATTRIBUTES,
298
353
  attributes: Materializer::EMPTY_ATTRIBUTES,
299
354
  text: text,
300
- depth: material_depth(node, depth_memo),
355
+ depth: depth,
301
356
  }
302
357
  end
303
358
 
304
- # Binding wrappers are address-stable and #parent is memoized,
305
- # so each edge is fetched once; depths resolve through the
359
+ # Depth relative to the subtree root, or nil when the node
360
+ # lies outside it (traverse follows the document chain since
361
+ # leptris 1.9.28, so epilog siblings can appear in the
362
+ # stream). Binding wrappers are address-stable and #parent is
363
+ # memoized, so each edge resolves once through the
306
364
  # identity-keyed memo.
307
- def material_depth(node, memo)
308
- memo[node] ||= begin
309
- parent = node.parent
310
- parent.nil? || parent.is_a?(::Leptris::XML::Document) ? 0 : material_depth(parent, memo) + 1
311
- end
365
+ def material_depth_in_subtree(node, root, memo)
366
+ memo[node] ||= if node.equal?(root)
367
+ 0
368
+ else
369
+ parent = node.parent
370
+ if parent.nil? || parent.is_a?(::Leptris::XML::Document)
371
+ nil
372
+ else
373
+
374
+ parent_depth = material_depth_in_subtree(parent, root, memo)
375
+ parent_depth.nil? ? nil : parent_depth + 1
376
+
377
+ end
378
+ end
312
379
  end
313
380
 
314
381
  def node_type(node)
@@ -971,9 +1038,22 @@ module Moxml
971
1038
  native = native_doctype_xml(doc)
972
1039
  parts << native << "\n" if native
973
1040
 
974
- document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
1041
+ if DOC_NODE_SUPPORTED
1042
+ # The libxml2-model document node: prolog PIs/comments,
1043
+ # the root, epilog PIs/comments — in document order, so
1044
+ # epilog parts serialize after the root (issue #130).
1045
+ doc_children = document_node_children(doc)
1046
+ if doc_children
1047
+ doc_children.each { |child| parts << raw_serialize(child, options) << "\n" }
1048
+ else
1049
+ document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
1050
+ parts << raw_serialize(doc.root, options) << "\n" if doc.root
1051
+ end
1052
+ else
1053
+ document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
975
1054
 
976
- parts << raw_serialize(doc.root, options) << "\n" if doc.root
1055
+ parts << raw_serialize(doc.root, options) << "\n" if doc.root
1056
+ end
977
1057
 
978
1058
  texts = attachments.get(doc, :document_text)
979
1059
  texts&.each { |text| parts << XmlEmitter.escape_text(text.content.to_s) }
@@ -1000,7 +1080,8 @@ module Moxml
1000
1080
  dt = doc.doctype
1001
1081
  return nil unless dt
1002
1082
 
1003
- XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id)
1083
+ subset = dt.internal_subset if dt.class.method_defined?(:internal_subset)
1084
+ XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id, subset)
1004
1085
  end
1005
1086
 
1006
1087
  def sax_parse(xml, handler)
@@ -1046,6 +1127,20 @@ module Moxml
1046
1127
  # the same objects, so wrapper mutations round-trip. Build the
1047
1128
  # cache BEFORE appending a PI with add_pi, or the C-side
1048
1129
  # addition would be double-counted.
1130
+ # The document node's children, or nil when the node does not
1131
+ # reflect reality: parsed documents always list the root
1132
+ # element among their children, but programmatically built
1133
+ # ones do not (binding gap) — those keep the legacy parts
1134
+ # path.
1135
+ def document_node_children(doc)
1136
+ doc_children = doc.children.to_a
1137
+ has_root = doc_children.any?(::Leptris::XML::Element)
1138
+ return doc_children if has_root
1139
+ return doc_children if doc.root.nil?
1140
+
1141
+ nil
1142
+ end
1143
+
1049
1144
  def document_pi_nodes(doc)
1050
1145
  attachments.get(doc, :doc_pi_nodes) || begin
1051
1146
  nodes = doc.processing_instructions.map do |(target, data)|
@@ -1065,16 +1160,29 @@ module Moxml
1065
1160
  doctype_wrapper = attachments.get(doc, :doctype)
1066
1161
  children << doctype_wrapper if doctype_wrapper
1067
1162
 
1068
- # Document-level PIs live outside the element tree in
1069
- # libleptris (a flat list; its serializer emits them before
1070
- # the root). Listed after the DOCTYPE to match the order
1071
- # serialize_document emits, so children and serialization
1072
- # agree. Epilog anchoring is not representable in the C
1073
- # model — a known divergence from the Nokogiri-shaped
1074
- # contract the other adapters expose.
1075
- children.concat(document_pi_nodes(doc))
1163
+ if DOC_NODE_SUPPORTED
1164
+ # The libxml2-model document node lists prolog PIs/comments,
1165
+ # the root, and epilog PIs/comments in document order —
1166
+ # the Nokogiri-shaped contract, epilog anchoring included
1167
+ # (issue #130). Built (programmatic) documents are not yet
1168
+ # fully reflected by the node (binding gap: an attached
1169
+ # root does not appear); document_node_children answers
1170
+ # nil there for the legacy parts path.
1171
+ doc_children = document_node_children(doc)
1172
+ if doc_children
1173
+ children.concat(doc_children)
1174
+ else
1175
+ children.concat(document_pi_nodes(doc))
1176
+ children << doc.root if doc.root
1177
+ end
1178
+ else
1179
+ # Legacy path: document-level PIs live outside the element
1180
+ # tree in a flat pre-root list (libleptris < 1.9.7 C
1181
+ # model); epilog anchoring is not representable there.
1182
+ children.concat(document_pi_nodes(doc))
1076
1183
 
1077
- children << doc.root if doc.root
1184
+ children << doc.root if doc.root
1185
+ end
1078
1186
 
1079
1187
  texts = attachments.get(doc, :document_text)
1080
1188
  children.concat(texts) if texts
@@ -31,6 +31,17 @@ module Moxml
31
31
  root&.materialize(&block)
32
32
  end
33
33
 
34
+ # Deterministically release the adapter's native memory for this
35
+ # document (issue #134) — batch workloads parsing thousands of
36
+ # documents otherwise hold C trees until GC finalizers run.
37
+ # GC-managed engines no-op. Further access raises the engine's
38
+ # use-after-free error; ordinary garbage-collected documents keep
39
+ # working via the finalizer either way.
40
+ def free
41
+ adapter.free_document(@native)
42
+ nil
43
+ end
44
+
34
45
  def create_element(name)
35
46
  Element.new(adapter.create_element(name, owner_doc: @native), context)
36
47
  end
data/lib/moxml/element.rb CHANGED
@@ -119,6 +119,16 @@ module Moxml
119
119
  end
120
120
  alias namespace_definitions namespaces
121
121
 
122
+ # The element's OWN namespace declarations as [prefix, uri]
123
+ # pairs (nil prefix = default namespace) — not the inherited
124
+ # scope. The shape materialize records carry (issue #138).
125
+ def declared_namespaces
126
+ adapter.namespace_definitions(@native).map do |ns|
127
+ wrapper = Namespace.new(ns, context)
128
+ [wrapper.prefix, wrapper.uri]
129
+ end
130
+ end
131
+
122
132
  # Returns all namespaces in scope for this element,
123
133
  # including those inherited from ancestor elements.
124
134
  def in_scope_namespaces
@@ -69,6 +69,10 @@ module Moxml
69
69
  qname: element.name,
70
70
  prefix: element.namespace_prefix,
71
71
  namespace_uri: ns&.uri,
72
+ # The element's OWN namespace declarations ([prefix, uri]
73
+ # pairs; nil prefix = default), not the in-scope set — enough
74
+ # for a consumer to rebuild scope while walking (issue #138).
75
+ namespaces: element.declared_namespaces,
72
76
  attributes: attributes,
73
77
  text: nil,
74
78
  depth: depth,
@@ -81,6 +85,7 @@ module Moxml
81
85
  qname: nil,
82
86
  prefix: nil,
83
87
  namespace_uri: nil,
88
+ namespaces: EMPTY_ATTRIBUTES,
84
89
  attributes: EMPTY_ATTRIBUTES,
85
90
  text: text,
86
91
  depth: depth,
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.9"
4
+ VERSION = "0.5.11"
5
5
  end
@@ -56,8 +56,9 @@ module Moxml
56
56
  output << "?>"
57
57
  end
58
58
 
59
- # @return [String] a DOCTYPE with its external identifier
60
- def doctype_xml(name, public_id, system_id)
59
+ # @return [String] a DOCTYPE with its external identifier and
60
+ # optional internal subset
61
+ def doctype_xml(name, public_id, system_id, internal_subset = nil)
61
62
  output = "<!DOCTYPE #{name}"
62
63
  if public_id && !public_id.to_s.empty?
63
64
  output << %( PUBLIC "#{public_id}")
@@ -65,6 +66,9 @@ module Moxml
65
66
  elsif system_id && !system_id.to_s.empty?
66
67
  output << %( SYSTEM "#{system_id}")
67
68
  end
69
+ if internal_subset && !internal_subset.empty?
70
+ output << " [" << internal_subset << "]"
71
+ end
68
72
  output << ">"
69
73
  end
70
74
  end
@@ -85,37 +85,51 @@ RSpec.describe Moxml::Adapter::Leptris do
85
85
  let(:ctx) { Moxml.new(:leptris) }
86
86
 
87
87
  it "lists document PIs as children with the root" do
88
- doc = ctx.parse('<?xml version="1.0"?><?pi-prolog before?><root/><?pi-epilog after?>')
88
+ doc = ctx.parse('<?xml version="1.0"?><?pi-prolog before?><root/><?pi-epilog after?><!-- tail -->')
89
89
  kids = doc.children.to_a
90
90
 
91
91
  expect(kids.map(&:class)).to eq(
92
- [Moxml::ProcessingInstruction, Moxml::ProcessingInstruction, Moxml::Element],
92
+ [Moxml::ProcessingInstruction, Moxml::Element,
93
+ Moxml::ProcessingInstruction, Moxml::Comment],
93
94
  )
94
95
  expect(kids.select(&:processing_instruction?).map(&:target)).to eq(%w[pi-prolog pi-epilog])
95
96
  expect(kids[0].content).to eq("before")
97
+ expect(doc.to_xml.index("pi-epilog")).to be > doc.to_xml.index("</root>")
98
+ expect(doc.to_xml.index("<!-- tail -->")).to be > doc.to_xml.index("</root>")
96
99
  end
97
100
 
98
- it "round-trips mutations and additions through serialization" do
99
- doc = ctx.parse("<?pi-original x?><root/>")
100
- pi = doc.children.to_a[0]
101
+ it "round-trips tree-level PI mutations through serialization" do
102
+ doc = ctx.parse("<root><a><?pi-original x?></a></root>")
103
+ pi = doc.at_xpath("//a").children.to_a.find(&:processing_instruction?)
101
104
  pi.target = "renamed"
102
105
  pi.content = "changed"
103
106
  expect(doc.to_xml).to include("<?renamed changed?>")
107
+ end
104
108
 
109
+ it "adds document PIs and lists them as children" do
110
+ doc = ctx.parse("<root/>")
105
111
  doc.add_child(doc.create_processing_instruction("added", "now"))
106
112
  expect(doc.to_xml).to include("<?added now?>")
107
- expect(doc.children.to_a.select(&:processing_instruction?).map(&:target)).to eq(%w[renamed added])
113
+ expect(doc.children.to_a.select(&:processing_instruction?).map(&:target)).to eq(%w[added])
114
+ end
115
+
116
+ it "reports document-level PI mutation as unsupported on the native path" do
117
+ # libleptris 1.9.7 exposes document PIs read-only: target=/data=
118
+ # and unlink are rejected for nodes outside the element tree.
119
+ doc = ctx.parse("<?pi x?><root/>")
120
+ pi = doc.children.to_a[0]
121
+
122
+ expect { pi.target = "renamed" }.to raise_error(Leptris::XML::Error)
108
123
  end
109
124
 
110
125
  it "serializes children and document output in agreement" do
111
126
  # libleptris stores document PIs as one flat pre-root list (no
112
127
  # epilog anchoring); children and to_xml must at least agree.
113
128
  doc = ctx.parse('<?xml version="1.0"?><?pi-a 1?><root/><?pi-b 2?>')
114
- from_children = doc.children.to_a.select(&:processing_instruction?)
115
- .map(&:to_xml).join("\n")
129
+ parts = doc.children.to_a.map { |c| "#{c.to_xml}\n" }.join
116
130
  from_document = doc.to_xml.sub(%r{\A<\?xml[^>]*\?>\n}, "")
117
131
 
118
- expect(from_document).to start_with(from_children)
132
+ expect(from_document).to eq(parts)
119
133
  end
120
134
 
121
135
  it "matches raw Nokogiri byte-for-byte for pretty-printing (issue #129)" do
@@ -139,6 +153,31 @@ RSpec.describe Moxml::Adapter::Leptris do
139
153
  end
140
154
  end
141
155
 
156
+ describe "DTD ATTLIST defaults" do
157
+ # libleptris 1.9.8: plain parse excludes ATTLIST defaults,
158
+ # matching libxml2/Nokogiri/REXML; dtdattr: true opts in.
159
+ let(:ctx) { Moxml.new(:leptris) }
160
+ let(:dtd_xml) do
161
+ %(<?xml version="1.0"?><!DOCTYPE doc [<!ATTLIST e9 attr CDATA "default">]><doc><e9/></doc>)
162
+ end
163
+
164
+ it "excludes ATTLIST defaults on plain parse" do
165
+ skip "requires leptris with no-DTDATTR semantics" unless Moxml::Adapter::Leptris::DTDATTR_SUPPORTED
166
+
167
+ doc = ctx.parse(dtd_xml)
168
+ expect(doc.at_xpath("//e9")["attr"]).to be_nil
169
+ expect(doc.to_xml).not_to include("attr=")
170
+ end
171
+
172
+ it "materializes ATTLIST defaults with dtdattr: true" do
173
+ skip "requires leptris with ParseOptions::DTDATTR" unless Moxml::Adapter::Leptris::DTDATTR_SUPPORTED
174
+
175
+ doc = ctx.parse(dtd_xml, dtdattr: true)
176
+ expect(doc.at_xpath("//e9")["attr"]).to eq("default")
177
+ expect(doc.to_xml).to include(%(attr="default"))
178
+ end
179
+ end
180
+
142
181
  describe "entity-marker tracking" do
143
182
  let(:ctx) { Moxml.new(:leptris) }
144
183
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Document#free" do
6
+ let(:xml) { '<r><a x="1">t</a></r>' }
7
+
8
+ it "frees native memory deterministically (issue #134)" do
9
+ doc = Moxml.new(:leptris).parse(xml)
10
+ expect(doc.root.name).to eq("r")
11
+
12
+ expect(doc.free).to be_nil
13
+ expect { doc.root }.to raise_error(Leptris::XML::UseAfterFreeError)
14
+ end
15
+
16
+ it "is a no-op on GC-managed engines" do
17
+ doc = Moxml.new(:nokogiri).parse(xml)
18
+
19
+ expect(doc.free).to be_nil
20
+ expect(doc.root.name).to eq("r")
21
+ end
22
+ end
@@ -36,6 +36,10 @@ RSpec.describe Moxml::Materializer do
36
36
  expect(book[:namespace_uri]).to eq("urn:c")
37
37
  expect(book[:text]).to be_nil
38
38
 
39
+ catalog = records.last
40
+ expect(catalog[:namespaces]).to eq([[nil, "urn:c"], ["p", "urn:p"]])
41
+ expect(book[:namespaces]).to eq([])
42
+
39
43
  title_text = records.find { |r| r[:kind] == :text && r[:text] == "T" }
40
44
  expect(title_text[:attributes]).to eq([])
41
45
  end
@@ -56,6 +60,17 @@ RSpec.describe Moxml::Materializer do
56
60
  )
57
61
  end
58
62
 
63
+ it "scopes the document stream to the root subtree (issue #140)" do
64
+ scoped = %(<?xml version="1.0"?><!-- prolog c --><?pi b?><root>t</root><!-- epilog c --><?pi2 a?>)
65
+ leptris = Moxml.new(:leptris).materialize(scoped).to_a
66
+ nokogiri = Moxml.new(:nokogiri).materialize(scoped).to_a
67
+
68
+ expect(leptris).to eq(nokogiri)
69
+ expect(leptris.map { |r| [r[:kind], r[:depth]] }).to eq(
70
+ [[:text, 1], [:element, 0]],
71
+ )
72
+ end
73
+
59
74
  it "materializes from any element subtree" do
60
75
  doc = Moxml.new(:nokogiri).parse(xml)
61
76
  title = doc.at_xpath("//*[local-name()='title']")
@@ -77,6 +92,6 @@ RSpec.describe Moxml::Materializer do
77
92
 
78
93
  # Records themselves allocate (hash + FFI strings); the wrapper
79
94
  # tree does not: far fewer allocations than 2 wrappers per node.
80
- expect(allocated).to be < node_count * 20
95
+ expect(allocated).to be < node_count * 24
81
96
  end
82
97
  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.9
4
+ version: 0.5.11
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-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-28 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
@@ -447,6 +447,7 @@ files:
447
447
  - spec/moxml/declaration_spec.rb
448
448
  - spec/moxml/doctype_spec.rb
449
449
  - spec/moxml/document_builder_spec.rb
450
+ - spec/moxml/document_free_spec.rb
450
451
  - spec/moxml/document_spec.rb
451
452
  - spec/moxml/element_spec.rb
452
453
  - spec/moxml/entity_preservation_spec.rb