moxml 0.5.30 → 0.5.32

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.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Marker for an adapter's native node-set result that NodeSet can
5
+ # hold without materializing the natives. The leptris binding's
6
+ # NodeSet mints one Ruby wrapper per result node in #to_a — the
7
+ # whole 900-node result set is built even when the caller only
8
+ # asks .size or .first — so the adapter hands the native set
9
+ # through instead. Contract: responds to length, empty?, [],
10
+ # each, to_a (yielding natives).
11
+ module LazyNodeSet
12
+ def size
13
+ length
14
+ end
15
+ end
16
+ end
@@ -25,8 +25,13 @@ module Moxml
25
25
  @monitor = Monitor.new
26
26
  end
27
27
 
28
+ # Reads are lock-free: MRI Hash#[] is atomic under the GVL, and
29
+ # the only mid-write race (set assigns the inner hash before its
30
+ # key) resolves to a miss — the same answer as "not set yet",
31
+ # which every reader treats conservatively. This method sits on
32
+ # per-element hot paths (entity_bearing? on every serialize).
28
33
  def get(native, key)
29
- @monitor.synchronize { @data[native.object_id]&.[](key) }
34
+ @data[native.object_id]&.[](key)
30
35
  end
31
36
 
32
37
  def set(native, key, value)
@@ -39,7 +44,8 @@ module Moxml
39
44
  end
40
45
 
41
46
  def key?(native, key)
42
- @monitor.synchronize { @data[native.object_id]&.key?(key) || false }
47
+ h = @data[native.object_id]
48
+ !h.nil? && h.key?(key)
43
49
  end
44
50
 
45
51
  def delete(native, key)
data/lib/moxml/node.rb CHANGED
@@ -35,6 +35,7 @@ module Moxml
35
35
  def clear_native_memo!
36
36
  @name = nil
37
37
  @attribute_cache = nil
38
+ @entity_bearing_gen = nil
38
39
  end
39
40
 
40
41
  def document
@@ -64,9 +65,12 @@ module Moxml
64
65
  def add_child(node)
65
66
  node = prepare_node(node)
66
67
  adapter.add_child(@native, node.native)
67
- # Refresh native in case adapter changed identity (e.g., LibXML doc.root=)
68
- refreshed = adapter.actual_native(node.native, @native)
69
- node.refresh_native!(refreshed) if refreshed && refreshed != node.native
68
+ # Refresh native in case adapter changed identity (e.g., LibXML
69
+ # doc.root=); stable-identity adapters skip the round trip.
70
+ unless adapter.native_identity_stable?
71
+ refreshed = adapter.actual_native(node.native, @native)
72
+ node.refresh_native!(refreshed) if refreshed && refreshed != node.native
73
+ end
70
74
  node.parent_node = self
71
75
  # The adopted subtree's in-scope namespaces changed
72
76
  node.invalidate_namespace_cache!
@@ -113,23 +117,43 @@ module Moxml
113
117
  # Determine if we should include XML declaration
114
118
  # For Document nodes: check native then wrapper, unless explicitly overridden
115
119
  # For other nodes: default to no declaration unless explicitly set
116
- serialize_options = default_options.merge(options)
117
- serialize_options[:no_declaration] = !should_include_declaration?(options)
120
+ serialize_options = if options.empty? && !is_a?(Document)
121
+ context.default_element_serialize_options
122
+ else
123
+ merged = context.default_serialize_options.merge(options)
124
+ merged[:no_declaration] = !should_include_declaration?(options)
125
+ merged
126
+ end
118
127
 
119
128
  result = adapter.serialize(@native, serialize_options)
120
129
  result = apply_line_ending(result, serialize_options[:line_ending])
121
130
 
122
131
  # Restore entity markers to named entity references; skipped
123
132
  # when the adapter knows the document carries no markers.
124
- result = adapter.restore_entities(result) if adapter.entity_bearing?(@native)
133
+ result = adapter.restore_entities(result) if entity_bearing?
125
134
  result
126
135
  end
127
136
 
137
+ # Memoized against the adapter's serialize generation — the
138
+ # entity-marker flag flips at parse and entity-reference mint,
139
+ # both adapter-level, and the generation bump is the invalidation
140
+ # signal. Adapters with static answers (base class) never bump.
141
+ def entity_bearing?
142
+ gen = adapter.serialize_generation
143
+ if @entity_bearing_gen == gen
144
+ @entity_bearing
145
+ else
146
+ @entity_bearing_gen = gen
147
+ @entity_bearing = adapter.entity_bearing?(@native)
148
+ end
149
+ end
150
+
128
151
  def xpath(expression, namespaces = {})
129
152
  result = adapter.xpath(@native, expression, namespaces)
130
- # Adapter contract: Array<native> | scalar. Scalars (count(),
131
- # string-length(), booleans) pass through unwrapped.
132
- result.is_a?(Array) ? NodeSet.new(result, context) : result
153
+ # Adapter contract: Array<native> | LazyNodeSet | scalar.
154
+ # Scalars (count(), string-length(), booleans) pass through
155
+ # unwrapped; the set forms wrap lazily.
156
+ result.is_a?(Array) || result.is_a?(LazyNodeSet) ? NodeSet.new(result, context) : result
133
157
  end
134
158
 
135
159
  # Flattened post-order records for this subtree without
@@ -459,19 +483,6 @@ module Moxml
459
483
  end
460
484
  end
461
485
 
462
- def default_options
463
- {
464
- encoding: context.config.default_encoding,
465
- indent: context.config.default_indent,
466
- line_ending: context.config.default_line_ending,
467
- # The short format of empty tags in Oga and Nokogiri isn't configurable
468
- # Oga: <empty /> (with a space)
469
- # Nokogiri: <empty/> (without a space)
470
- # The expanded format is enforced to avoid this conflict
471
- expand_empty: true,
472
- }
473
- end
474
-
475
486
  def should_include_declaration?(options)
476
487
  return options[:declaration] if options.key?(:declaration)
477
488
  return options.fetch(:declaration, false) unless is_a?(Document)
@@ -6,10 +6,14 @@ module Moxml
6
6
 
7
7
  attr_reader :context
8
8
 
9
+ # nodes: Array of natives, or an adapter's LazyNodeSet — the
10
+ # native set is held unmaterialized until an operation needs an
11
+ # Array (#+, #<<, #delete, Range slices).
9
12
  def initialize(nodes, context, parent_node = nil)
10
- @nodes = Array(nodes)
13
+ @nodes = nodes.is_a?(Array) ? nodes : nil
14
+ @lazy = @nodes ? nil : nodes
11
15
  @context = context
12
- @wrapped = Array.new(@nodes.size)
16
+ @wrapped = nil
13
17
  @parent_node = parent_node
14
18
  end
15
19
 
@@ -17,15 +21,17 @@ module Moxml
17
21
  # The public surface is wrapper-only: every Enumerable access
18
22
  # goes through #each/#[]/#to_a, which wrap.
19
23
  def native_nodes
20
- @nodes
24
+ return @nodes unless @nodes.nil?
25
+
26
+ @nodes = @lazy.to_a
21
27
  end
22
28
 
23
29
  def each
24
30
  return to_enum(:each) unless block_given?
25
31
 
26
- wrapped = @wrapped
32
+ wrapped = wrapped_buffer
27
33
  index = 0
28
- @nodes.each do |node|
34
+ (@nodes || @lazy).each do |node|
29
35
  wrapper = wrapped[index]
30
36
  unless wrapper
31
37
  wrapper = wrap_with_parent(node)
@@ -40,51 +46,56 @@ module Moxml
40
46
  def [](index)
41
47
  case index
42
48
  when Integer
43
- actual = index.negative? ? @nodes.size + index : index
44
- return nil unless actual >= 0 && actual < @nodes.size
49
+ actual = index.negative? ? native_size + index : index
50
+ return nil unless actual >= 0 && actual < native_size
45
51
 
46
- @wrapped[actual] ||= wrap_with_parent(@nodes[actual])
52
+ wrapped_buffer[actual] ||= wrap_with_parent((@nodes || @lazy)[actual])
47
53
  when Range
48
- self.class.new(@nodes[index], @context)
54
+ self.class.new(native_nodes[index], @context)
49
55
  end
50
56
  end
51
57
 
52
58
  def first(n = nil)
53
59
  if n.nil?
54
- @nodes.empty? ? nil : self[0]
60
+ native_size.zero? ? nil : self[0]
55
61
  else
56
62
  n.times.filter_map { |i| self[i] }
57
63
  end
58
64
  end
59
65
 
60
66
  def last
61
- @nodes.empty? ? nil : self[@nodes.size - 1]
67
+ native_size.zero? ? nil : self[native_size - 1]
62
68
  end
63
69
 
64
70
  def empty?
65
- @nodes.empty?
71
+ native_size.zero?
66
72
  end
67
73
 
68
74
  def size
69
- @nodes.size
75
+ native_size
70
76
  end
71
77
  alias length size
72
78
 
73
79
  def to_a
74
- @nodes.each_with_index do |_node, i|
75
- @wrapped[i] ||= wrap_with_parent(@nodes[i])
80
+ i = 0
81
+ wrapped = wrapped_buffer
82
+ (@nodes || @lazy).each do |node|
83
+ wrapped[i] ||= wrap_with_parent(node)
84
+ i += 1
76
85
  end
77
- @wrapped.compact
86
+ wrapped.compact
78
87
  end
79
88
 
80
89
  def +(other)
81
- self.class.new(@nodes + other.native_nodes, @context, @parent_node)
90
+ self.class.new(native_nodes + other.native_nodes, @context, @parent_node)
82
91
  end
83
92
 
84
93
  def <<(node)
85
94
  native_node = node.is_a?(Node) ? node.native : node
86
- @nodes << native_node
87
- @wrapped << nil
95
+ # Materialize the buffer first so it allocates at the pre-append
96
+ # size and stays in lockstep with the natives.
97
+ wrapped_buffer << nil
98
+ native_nodes << native_node
88
99
  self
89
100
  end
90
101
  alias push <<
@@ -94,7 +105,7 @@ module Moxml
94
105
  # which may yield the same native node multiple times
95
106
  def uniq_by_native
96
107
  seen = {}
97
- unique_natives = @nodes.select do |native|
108
+ unique_natives = native_nodes.select do |native|
98
109
  id = native.object_id
99
110
  if seen[id]
100
111
  false
@@ -109,7 +120,7 @@ module Moxml
109
120
  def ==(other)
110
121
  self.class == other.class &&
111
122
  length == other.length &&
112
- @nodes.each_with_index.all? do |_node, index|
123
+ native_nodes.each_with_index.all? do |_node, index|
113
124
  self[index] == other[index]
114
125
  end
115
126
  end
@@ -127,18 +138,30 @@ module Moxml
127
138
  # Accepts both wrapped Moxml nodes and native nodes
128
139
  def delete(node)
129
140
  native_node = node.is_a?(Node) ? node.native : node
130
- idx = @nodes.index(native_node)
141
+ idx = native_nodes.index(native_node)
131
142
  if idx
132
- @nodes.delete_at(idx)
133
- @wrapped.delete_at(idx)
143
+ native_nodes.delete_at(idx)
144
+ wrapped_buffer.delete_at(idx) if @wrapped
134
145
  else
135
- @nodes.delete(native_node)
146
+ native_nodes.delete(native_node)
136
147
  end
137
148
  self
138
149
  end
139
150
 
140
151
  private
141
152
 
153
+ # Allocated on first wrapped access — .size/.empty? consumers of
154
+ # large result sets never pay for the slot array.
155
+ def wrapped_buffer
156
+ return @wrapped unless @wrapped.nil?
157
+
158
+ @wrapped = Array.new(native_size)
159
+ end
160
+
161
+ def native_size
162
+ @nodes ? @nodes.size : @lazy.length
163
+ end
164
+
142
165
  def wrap_with_parent(native_node)
143
166
  wrapped = Moxml::Node.wrap(native_node, @context)
144
167
  if @parent_node && wrapped
@@ -139,24 +139,28 @@ module Moxml
139
139
  @handlers[:start_document]&.call
140
140
  end
141
141
 
142
+ # The per-event hash lookup fires for every node in the
143
+ # document; the DSL is fixed after construction, so the
144
+ # resolved block memoizes into an ivar on first fire (nil
145
+ # short-circuits the same way &. does).
142
146
  # @private
143
147
  def on_end_document
144
- @handlers[:end_document]&.call
148
+ (@end_document_block ||= @handlers[:end_document])&.call
145
149
  end
146
150
 
147
151
  # @private
148
152
  def on_start_element(name, attributes = {}, namespaces = {})
149
- @handlers[:start_element]&.call(name, attributes, namespaces)
153
+ (@start_element_block ||= @handlers[:start_element])&.call(name, attributes, namespaces)
150
154
  end
151
155
 
152
156
  # @private
153
157
  def on_end_element(name)
154
- @handlers[:end_element]&.call(name)
158
+ (@end_element_block ||= @handlers[:end_element])&.call(name)
155
159
  end
156
160
 
157
161
  # @private
158
162
  def on_characters(text)
159
- @handlers[:characters]&.call(text)
163
+ (@characters_block ||= @handlers[:characters])&.call(text)
160
164
  end
161
165
 
162
166
  # @private
@@ -12,22 +12,31 @@ module Moxml
12
12
  # @yieldparam value [Object] raw attribute/namespace value
13
13
  # @yieldreturn [Object] transformed value to store
14
14
  # @return [Array(Hash, Hash)] [regular_attrs, namespaces]
15
+ # Shared for the overwhelmingly common no-declaration element:
16
+ # one Hash allocation per start_element event saved. Frozen —
17
+ # event hashes are read-only data, not scratch.
18
+ EMPTY_NAMESPACES = {}.freeze
19
+
15
20
  def split_attributes_and_namespaces(attributes)
16
21
  attrs = {}
17
- ns = {}
22
+ ns = nil
18
23
 
19
24
  each_attribute(attributes) do |name, value|
20
25
  name_s = name.to_s
21
- v = block_given? ? yield(value) : value
22
- if name_s == "xmlns" || name_s.start_with?("xmlns:")
23
- prefix = name_s == "xmlns" ? nil : name_s.sub("xmlns:", "")
24
- ns[prefix] = v
26
+ if name_s.start_with?("xmlns")
27
+ if name_s == "xmlns"
28
+ (ns ||= {})[nil] = block_given? ? yield(value) : value
29
+ elsif name_s.bytesize > 5 && name_s.getbyte(5) == 58 # ":"
30
+ (ns ||= {})[name_s[6..]] = block_given? ? yield(value) : value
31
+ else
32
+ attrs[name_s] = block_given? ? yield(value) : value
33
+ end
25
34
  else
26
- attrs[name_s] = v
35
+ attrs[name_s] = block_given? ? yield(value) : value
27
36
  end
28
37
  end
29
38
 
30
- [attrs, ns]
39
+ [attrs, ns || EMPTY_NAMESPACES]
31
40
  end
32
41
 
33
42
  private
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.30"
4
+ VERSION = "0.5.32"
5
5
  end
@@ -42,8 +42,19 @@ module Moxml
42
42
  raise ValidationError, "XML comment cannot contain double hyphens (--)"
43
43
  end
44
44
 
45
+ # Real documents reuse a handful of distinct element names; the
46
+ # memo turns the per-create regex into a hash probe after the
47
+ # first sight of a name. Capped — hostile vocabularies fall back
48
+ # to the regex.
49
+ VALID_ELEMENT_NAMES = {}.compare_by_identity
50
+
45
51
  def validate_element_name(name)
46
- return if name.is_a?(String) && name.match?(/^[a-zA-Z_][\w\-.:]*$/)
52
+ return if name.is_a?(String) && VALID_ELEMENT_NAMES.key?(name)
53
+
54
+ if name.is_a?(String) && name.match?(/^[a-zA-Z_][\w\-.:]*$/)
55
+ VALID_ELEMENT_NAMES[name] = true if VALID_ELEMENT_NAMES.size < 1024
56
+ return
57
+ end
47
58
 
48
59
  raise ValidationError, "Invalid XML element name: #{name}"
49
60
  end
data/lib/moxml.rb CHANGED
@@ -68,6 +68,7 @@ module Moxml
68
68
  autoload :Context, "moxml/context"
69
69
  autoload :Node, "moxml/node"
70
70
  autoload :NodeSet, "moxml/node_set"
71
+ autoload :LazyNodeSet, "moxml/lazy_node_set"
71
72
  autoload :Document, "moxml/document"
72
73
  autoload :Element, "moxml/element"
73
74
  autoload :Text, "moxml/text"
@@ -52,10 +52,15 @@ RSpec.describe Moxml::Adapter::Leptris do
52
52
  expect(doc.xpath("//item[@p:kind='a']").map { |n| n["id"] }).to eq(["1"])
53
53
  end
54
54
 
55
- it "falls back to the Ruby engine for attribute-node results" do
55
+ it "evaluates attribute-node queries with proper wrappers" do
56
+ # Native since 1.9.105 (leptris-ruby#153: ResultAttr with
57
+ # name/value); earlier bindings returned generic nodes whose
58
+ # #name raised, so the Ruby engine owned them.
59
+ skip "requires native attr results (leptris 1.9.105+)" unless described_class::ATTR_RESULT_NATIVE
56
60
  attrs = doc.xpath("//item/@id")
57
61
  expect(attrs.map(&:name)).to eq(%w[id id])
58
62
  expect(attrs.map(&:value)).to eq(%w[1 2])
63
+ expect(attrs.first).to be_a(Moxml::Attribute)
59
64
  end
60
65
 
61
66
  it "evaluates element-context queries on the native engine" do
@@ -314,6 +319,247 @@ RSpec.describe Moxml::Adapter::Leptris do
314
319
  end
315
320
  end
316
321
 
322
+ describe "HTML parsing (leptris/leptris#659)" do
323
+ before do
324
+ skip "requires leptris 1.9.80+ (HTML engine mode)" unless described_class::HTML_PARSE_SUPPORTED
325
+ end
326
+
327
+ let(:ctx) { Moxml.new(:leptris) }
328
+
329
+ it "synthesizes the html/body structure with lowercased names" do
330
+ doc = ctx.parse_html(%(<DIV CLASS="x">t</DIV>))
331
+ expect(doc.root.name).to eq("html")
332
+ body = doc.root.children.find(&:element?)
333
+ expect(body.name).to eq("body")
334
+ div = body.at_xpath(".//div")
335
+ expect(div["class"]).to eq("x")
336
+ expect(div.text).to eq("t")
337
+ end
338
+
339
+ it "implies end tags for list items" do
340
+ doc = ctx.parse_html(%(<ul><li>one<li>two</ul>))
341
+ expect(doc.xpath("//li").map(&:text)).to eq(%w[one two])
342
+ end
343
+
344
+ it "keeps void elements as empty elements" do
345
+ doc = ctx.parse_html(%(<br><img src="x.png">))
346
+ expect(doc.xpath("//br").size).to eq(1)
347
+ expect(doc.xpath("//img").first["src"]).to eq("x.png")
348
+ end
349
+
350
+ it "decodes HTML named entities into text" do
351
+ doc = ctx.parse_html(%(<p>caf&eacute; &nbsp;&copy;</p>))
352
+ expect(doc.at_xpath("//p").text).to eq("caf\u00e9 \u00a0\u00a9")
353
+ end
354
+
355
+ it "materializes boolean attributes" do
356
+ doc = ctx.parse_html(%(<a href="/x" disabled>t</a>))
357
+ link = doc.at_xpath("//a")
358
+ expect(link["href"]).to eq("/x")
359
+ expect(link["disabled"]).to eq("")
360
+ end
361
+
362
+ it "reads script content as raw text" do
363
+ doc = ctx.parse_html(%(<script>if (a < b) { x("</div>"); }</script>))
364
+ expect(doc.at_xpath("//script").text).to include("a < b")
365
+ expect(doc.at_xpath("//script").text).to include("</div>")
366
+ end
367
+
368
+ it "serializes to well-formed XML that reparses strictly" do
369
+ doc = ctx.parse_html(%(<p>a &amp; b < c</p><script>y = "</q>";</script>))
370
+ out = doc.to_xml
371
+ expect(out).to include("&lt;")
372
+ reparsed = Nokogiri::XML(out, &:strict)
373
+ expect(reparsed).to be_a(Nokogiri::XML::Document)
374
+ expect(reparsed.errors).to be_empty
375
+ end
376
+
377
+ it "preserves foreign content (SVG/MathML) in HTML documents" do
378
+ doc = ctx.parse_html(%(<p>a</p><svg viewBox="0 0 1 1"><circle r="1"/></svg><math><mi>a</mi></math>))
379
+ body = doc.root.children.find(&:element?)
380
+ names = body.children.select(&:element?).map(&:name)
381
+ expect(names).to include("svg", "math")
382
+ expect(body.at_xpath(".//circle")["r"]).to eq("1")
383
+ expect(body.at_xpath(".//mi").text).to eq("a")
384
+ end
385
+
386
+ it "preserves foreignObject content Nokogiri drops (name lowercased)" do
387
+ # WHATWG keeps foreign-content camelCase (foreignObject,
388
+ # viewBox); the engine currently lowercases like it does HTML
389
+ # names, but preserves the subtree — Nokogiri drops it
390
+ # entirely. Pins current behavior; engine conformance note
391
+ # filed for the adjust-tables.
392
+ doc = ctx.parse_html(%(<svg><foreignObject><p>x</p></foreignObject></svg>))
393
+ out = doc.to_xml
394
+ expect(out).to include("<foreignobject>")
395
+ expect(out).to include("<p>x</p>")
396
+ end
397
+
398
+ it "round-trips an HTML doctype with external identifiers" do
399
+ doc = ctx.parse_html(%(<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "x.dtd"><p>a</p>))
400
+ expect(doc.to_xml).to include(%(<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "x.dtd">))
401
+ end
402
+
403
+ it "preserves template element placement" do
404
+ doc = ctx.parse_html(%(<div><template><p>t</p></template></div>))
405
+ expect(doc.at_xpath("//template/p")&.text).to eq("t")
406
+ end
407
+
408
+ it "round-trips a parsed tree through mutation" do
409
+ doc = ctx.parse_html(%(<ul><li>a<li>b</ul>))
410
+ doc.at_xpath("//ul").add_child(doc.create_element("li"))
411
+ expect(doc.xpath("//li").map(&:name)).to eq(%w[li li li])
412
+ end
413
+ end
414
+
415
+ describe "iterparse streaming" do
416
+ let(:ctx) { Moxml.new(:leptris) }
417
+ let(:xml) do
418
+ %(<catalog>#{Array.new(3) { |i| %(<record id="r#{i}"><field name="f">v#{i} &amp; x</field></record>) }.join}</catalog>)
419
+ end
420
+
421
+ it "yields completed top-level children with readable attributes" do
422
+ seen = []
423
+ ctx.iterparse(xml) { |e| seen << [e.name, e["id"]] }
424
+ expect(seen).to eq([["record", "r0"], ["record", "r1"], ["record", "r2"]])
425
+ end
426
+
427
+ it "full_document yields every element in completion order" do
428
+ seen = []
429
+ ctx.iterparse(xml, mode: :full_document) { |e| seen << e.name }
430
+ expect(seen).to eq(%w[field record field record field record catalog])
431
+ end
432
+
433
+ it "reads children, text, and serializes inside the block" do
434
+ outs = []
435
+ ctx.iterparse(xml) do |e|
436
+ outs << [e.children.first["name"], e.children.first.text,
437
+ Nokogiri::XML(e.to_xml, &:strict).root["id"]]
438
+ end
439
+ expect(outs.map(&:first)).to all(eq("f"))
440
+ expect(outs.map { |row| row[1] }).to all(match(/v\d & x/))
441
+ expect(outs.map { |row| row[2] }).to eq(%w[r0 r1 r2])
442
+ end
443
+
444
+ it "streams from a file" do
445
+ require "tmpdir"
446
+ Dir.mktmpdir do |dir|
447
+ path = File.join(dir, "doc.xml")
448
+ File.write(path, xml)
449
+ seen = []
450
+ ctx.iterparse_file(path) { |e| seen << e["id"] }
451
+ expect(seen).to eq(%w[r0 r1 r2])
452
+ end
453
+ end
454
+
455
+ it "answers subqueries on yielded elements via the Ruby engine" do
456
+ # Parentless elements have no document handle for the compiled
457
+ # native eval — the gate must route them, not crash.
458
+ seen = []
459
+ ctx.iterparse(xml) do |e|
460
+ seen << [e.at_xpath(".//field")["name"], e.xpath("count(.//field)")]
461
+ end
462
+ expect(seen).to eq([["f", 1.0]] * 3)
463
+ end
464
+
465
+ it "requires a block" do
466
+ expect { ctx.iterparse(xml) }.to raise_error(ArgumentError, /block/)
467
+ end
468
+ end
469
+
470
+ describe "C14n native delegation" do
471
+ it "matches the Ruby reference byte-for-byte on the default path" do
472
+ # Whether the NATIVE_C14N_BYTE_SAFE probe is armed (fixed
473
+ # engine builds delegate to the C canonicalizer) or not, the
474
+ # default-path output must equal the Ruby reference — this is
475
+ # the safety net that lets the probe auto-adopt future builds.
476
+ xml = %(<?xml version="1.0"?><doc xmlns:p="urn:p" xmlns="urn:d" b="2" a="1"><e p:x="v" z="w">t &amp; u</e><!-- c --></doc>)
477
+ ctx = Moxml.new(:leptris)
478
+ root = ctx.parse(xml).root
479
+ expect(Moxml::C14n.canonicalize(root))
480
+ .to eq(Moxml::C14n::Inclusive10.new.canonicalize(root))
481
+ end
482
+ end
483
+
484
+ describe "wrapper lifecycle" do
485
+ it "releases wrappers when documents are dropped (WeakMap registry)" do
486
+ # The identity map must hold wrappers weakly: parse-and-drop
487
+ # workloads otherwise pin wrappers, natives, and (via the
488
+ # binding finalizer never running) the C subtrees.
489
+ xml = %(<r>#{Array.new(50) { |i| "<e id=\"i#{i}\">x</e>" }.join}</r>)
490
+ ctx = Moxml.new(:leptris)
491
+ count = -> {
492
+ n = 0
493
+ ObjectSpace.each_object(Moxml::Element) { n += 1 }
494
+ n
495
+ }
496
+ walk = ->(doc) { doc.root.children.to_a }
497
+
498
+ GC.start
499
+ before = count.call
500
+ 20.times do
501
+ doc = ctx.parse(xml)
502
+ walk.(doc)
503
+ nil
504
+ end
505
+ 5.times { GC.start }
506
+
507
+ # The binding retains a constant one-document wrapper set of
508
+ # its own; moxml must not retain beyond a couple of dropped
509
+ # documents' worth (was: all 20 pinned under the strong map).
510
+ expect(count.call - before).to be < 2 * 51
511
+ end
512
+
513
+ it "keeps wrapper identity while a document is alive" do
514
+ ctx = Moxml.new(:leptris)
515
+ doc = ctx.parse(%(<r><a/></r>))
516
+ expect(doc.root.children.first).to equal(doc.root.children.first)
517
+ GC.start
518
+ expect(doc.root.children.first).to equal(doc.root.children.first)
519
+ end
520
+ end
521
+
522
+ describe "lazy xpath result sets" do
523
+ let(:ctx) { Moxml.new(:leptris) }
524
+ let(:doc) do
525
+ ctx.parse(%(<r>#{Array.new(50) { |i| "<li>#{i}</li>" }.join}</r>))
526
+ end
527
+
528
+ it "answers size, first, and indexing without materializing" do
529
+ set = doc.xpath("//li")
530
+ expect(set.size).to eq(50)
531
+ expect(set.first.text).to eq("0")
532
+ expect(set[10].text).to eq("10")
533
+ expect(set[-1].text).to eq("49")
534
+ expect(set.empty?).to be(false)
535
+ end
536
+
537
+ it "enumerates and wraps on demand" do
538
+ expect(doc.xpath("//li").each.to_a.size).to eq(50)
539
+ expect(doc.xpath("//li").to_a.map(&:name).uniq).to eq(%w[li])
540
+ end
541
+
542
+ it "keeps the mutating set operations working" do
543
+ set = doc.xpath("//li")
544
+ expect((set + set).size).to eq(100)
545
+ expect(set.uniq_by_native.size).to eq(50)
546
+ set << doc.create_element("li")
547
+ expect(set.size).to eq(51)
548
+ expect(set.last.name).to eq("li")
549
+ end
550
+
551
+ it "slices ranges" do
552
+ expect(doc.xpath("//li")[0..2].size).to eq(3)
553
+ expect(doc.xpath("//li")[5...8].map(&:text)).to eq(%w[5 6 7])
554
+ end
555
+
556
+ it "returns scalars and at_xpath firsts unwrapped-set" do
557
+ expect(doc.xpath("count(//li)")).to eq(50.0)
558
+ expect(doc.at_xpath("//li").text).to eq("0")
559
+ expect(doc.at_xpath("//nope")).to be_nil
560
+ end
561
+ end
562
+
317
563
  describe "DTD ATTLIST defaults" do
318
564
  # libleptris 1.9.8: plain parse excludes ATTLIST defaults,
319
565
  # matching libxml2/Nokogiri/REXML; dtdattr: true opts in.