moxml 0.5.30 → 0.5.31

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.
@@ -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.31"
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,216 @@ 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 "round-trips a parsed tree through mutation" do
378
+ doc = ctx.parse_html(%(<ul><li>a<li>b</ul>))
379
+ doc.at_xpath("//ul").add_child(doc.create_element("li"))
380
+ expect(doc.xpath("//li").map(&:name)).to eq(%w[li li li])
381
+ end
382
+ end
383
+
384
+ describe "iterparse streaming" do
385
+ let(:ctx) { Moxml.new(:leptris) }
386
+ let(:xml) do
387
+ %(<catalog>#{Array.new(3) { |i| %(<record id="r#{i}"><field name="f">v#{i} &amp; x</field></record>) }.join}</catalog>)
388
+ end
389
+
390
+ it "yields completed top-level children with readable attributes" do
391
+ seen = []
392
+ ctx.iterparse(xml) { |e| seen << [e.name, e["id"]] }
393
+ expect(seen).to eq([["record", "r0"], ["record", "r1"], ["record", "r2"]])
394
+ end
395
+
396
+ it "full_document yields every element in completion order" do
397
+ seen = []
398
+ ctx.iterparse(xml, mode: :full_document) { |e| seen << e.name }
399
+ expect(seen).to eq(%w[field record field record field record catalog])
400
+ end
401
+
402
+ it "reads children, text, and serializes inside the block" do
403
+ outs = []
404
+ ctx.iterparse(xml) do |e|
405
+ outs << [e.children.first["name"], e.children.first.text,
406
+ Nokogiri::XML(e.to_xml, &:strict).root["id"]]
407
+ end
408
+ expect(outs.map(&:first)).to all(eq("f"))
409
+ expect(outs.map { |row| row[1] }).to all(match(/v\d & x/))
410
+ expect(outs.map { |row| row[2] }).to eq(%w[r0 r1 r2])
411
+ end
412
+
413
+ it "streams from a file" do
414
+ require "tmpdir"
415
+ Dir.mktmpdir do |dir|
416
+ path = File.join(dir, "doc.xml")
417
+ File.write(path, xml)
418
+ seen = []
419
+ ctx.iterparse_file(path) { |e| seen << e["id"] }
420
+ expect(seen).to eq(%w[r0 r1 r2])
421
+ end
422
+ end
423
+
424
+ it "answers subqueries on yielded elements via the Ruby engine" do
425
+ # Parentless elements have no document handle for the compiled
426
+ # native eval — the gate must route them, not crash.
427
+ seen = []
428
+ ctx.iterparse(xml) do |e|
429
+ seen << [e.at_xpath(".//field")["name"], e.xpath("count(.//field)")]
430
+ end
431
+ expect(seen).to eq([["f", 1.0]] * 3)
432
+ end
433
+
434
+ it "requires a block" do
435
+ expect { ctx.iterparse(xml) }.to raise_error(ArgumentError, /block/)
436
+ end
437
+ end
438
+
439
+ describe "C14n native delegation" do
440
+ it "matches the Ruby reference byte-for-byte on the default path" do
441
+ # Whether the NATIVE_C14N_BYTE_SAFE probe is armed (fixed
442
+ # engine builds delegate to the C canonicalizer) or not, the
443
+ # default-path output must equal the Ruby reference — this is
444
+ # the safety net that lets the probe auto-adopt future builds.
445
+ 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>)
446
+ ctx = Moxml.new(:leptris)
447
+ root = ctx.parse(xml).root
448
+ expect(Moxml::C14n.canonicalize(root))
449
+ .to eq(Moxml::C14n::Inclusive10.new.canonicalize(root))
450
+ end
451
+ end
452
+
453
+ describe "wrapper lifecycle" do
454
+ it "releases wrappers when documents are dropped (WeakMap registry)" do
455
+ # The identity map must hold wrappers weakly: parse-and-drop
456
+ # workloads otherwise pin wrappers, natives, and (via the
457
+ # binding finalizer never running) the C subtrees.
458
+ xml = %(<r>#{Array.new(50) { |i| "<e id=\"i#{i}\">x</e>" }.join}</r>)
459
+ ctx = Moxml.new(:leptris)
460
+ count = -> {
461
+ n = 0
462
+ ObjectSpace.each_object(Moxml::Element) { n += 1 }
463
+ n
464
+ }
465
+ walk = ->(doc) { doc.root.children.to_a }
466
+
467
+ GC.start
468
+ before = count.call
469
+ 20.times do
470
+ doc = ctx.parse(xml)
471
+ walk.(doc)
472
+ nil
473
+ end
474
+ 5.times { GC.start }
475
+
476
+ # The binding retains a constant one-document wrapper set of
477
+ # its own; moxml must not retain beyond a couple of dropped
478
+ # documents' worth (was: all 20 pinned under the strong map).
479
+ expect(count.call - before).to be < 2 * 51
480
+ end
481
+
482
+ it "keeps wrapper identity while a document is alive" do
483
+ ctx = Moxml.new(:leptris)
484
+ doc = ctx.parse(%(<r><a/></r>))
485
+ expect(doc.root.children.first).to equal(doc.root.children.first)
486
+ GC.start
487
+ expect(doc.root.children.first).to equal(doc.root.children.first)
488
+ end
489
+ end
490
+
491
+ describe "lazy xpath result sets" do
492
+ let(:ctx) { Moxml.new(:leptris) }
493
+ let(:doc) do
494
+ ctx.parse(%(<r>#{Array.new(50) { |i| "<li>#{i}</li>" }.join}</r>))
495
+ end
496
+
497
+ it "answers size, first, and indexing without materializing" do
498
+ set = doc.xpath("//li")
499
+ expect(set.size).to eq(50)
500
+ expect(set.first.text).to eq("0")
501
+ expect(set[10].text).to eq("10")
502
+ expect(set[-1].text).to eq("49")
503
+ expect(set.empty?).to be(false)
504
+ end
505
+
506
+ it "enumerates and wraps on demand" do
507
+ expect(doc.xpath("//li").each.to_a.size).to eq(50)
508
+ expect(doc.xpath("//li").to_a.map(&:name).uniq).to eq(%w[li])
509
+ end
510
+
511
+ it "keeps the mutating set operations working" do
512
+ set = doc.xpath("//li")
513
+ expect((set + set).size).to eq(100)
514
+ expect(set.uniq_by_native.size).to eq(50)
515
+ set << doc.create_element("li")
516
+ expect(set.size).to eq(51)
517
+ expect(set.last.name).to eq("li")
518
+ end
519
+
520
+ it "slices ranges" do
521
+ expect(doc.xpath("//li")[0..2].size).to eq(3)
522
+ expect(doc.xpath("//li")[5...8].map(&:text)).to eq(%w[5 6 7])
523
+ end
524
+
525
+ it "returns scalars and at_xpath firsts unwrapped-set" do
526
+ expect(doc.xpath("count(//li)")).to eq(50.0)
527
+ expect(doc.at_xpath("//li").text).to eq("0")
528
+ expect(doc.at_xpath("//nope")).to be_nil
529
+ end
530
+ end
531
+
317
532
  describe "DTD ATTLIST defaults" do
318
533
  # libleptris 1.9.8: plain parse excludes ATTLIST defaults,
319
534
  # matching libxml2/Nokogiri/REXML; dtdattr: true opts in.
@@ -12,6 +12,23 @@ RSpec.describe Moxml::Adapter::Nokogiri do
12
12
 
13
13
  it_behaves_like "xml adapter"
14
14
 
15
+ describe "HTML parsing" do
16
+ let(:ctx) { Moxml.new(:nokogiri) }
17
+
18
+ it "synthesizes the html/body structure with lowercased names" do
19
+ doc = ctx.parse_html(%(<DIV CLASS="x">t</DIV>))
20
+ expect(doc.root.name).to eq("html")
21
+ body = doc.root.children.find(&:element?)
22
+ expect(body.name).to eq("body")
23
+ expect(body.at_xpath(".//div")["class"]).to eq("x")
24
+ end
25
+
26
+ it "implies end tags and decodes HTML entities" do
27
+ doc = ctx.parse_html(%(<ul><li>caf&eacute;</ul>))
28
+ expect(doc.xpath("//li").map(&:text)).to eq(["caf\u00e9"])
29
+ end
30
+ end
31
+
15
32
  describe "recover-mode error channel" do
16
33
  it "exposes recoverable syntax errors as parse_errors" do
17
34
  doc = Moxml.new(:nokogiri).parse("<root><unclosed>", strict: false)
@@ -13,6 +13,19 @@ RSpec.describe Moxml::Context do
13
13
  end
14
14
  end
15
15
 
16
+ describe "#parse_html" do
17
+ it "delegates to the adapter's HTML mode" do
18
+ ctx = Moxml.new(:nokogiri)
19
+ doc = ctx.parse_html(%(<p>x</p>))
20
+ expect(doc.root.name).to eq("html")
21
+ end
22
+
23
+ it "raises Moxml::AdapterError on adapters without an HTML mode" do
24
+ ctx = Moxml.new(:ox)
25
+ expect { ctx.parse_html(%(<p>x</p>)) }.to raise_error(Moxml::AdapterError, /not supported/)
26
+ end
27
+ end
28
+
16
29
  describe "#config" do
17
30
  it "has a configuration" do
18
31
  expect(context.config).to be_a(Moxml::Config)
@@ -140,4 +140,29 @@ RSpec.describe Moxml::Element do
140
140
  end
141
141
  end
142
142
  end
143
+
144
+ describe "#append_xml" do
145
+ it "appends a fragment's top-level nodes with entities round-tripped" do
146
+ %w[leptris nokogiri oga rexml].each do |adapter|
147
+ ctx = Moxml.new(adapter.to_sym)
148
+ doc = ctx.parse(%(<root/>))
149
+ result = doc.root.append_xml(%(<a x="1">t1 &amp; u</a><b/>tail))
150
+ expect(result).to equal(doc.root)
151
+ out = doc.to_xml
152
+ expect(out).to include(%(<a x="1">t1 &amp; u</a>))
153
+ expect(out).to include("<b></b>")
154
+ expect(out).to include("tail")
155
+ end
156
+ end
157
+
158
+ it "raises AdapterError on the ox adapter" do
159
+ doc = Moxml.new(:ox).parse(%(<root/>))
160
+ expect { doc.root.append_xml(%(<a/>)) }.to raise_error(Moxml::AdapterError, /Ox/)
161
+ end
162
+
163
+ it "raises ParseError for non-well-formed fragments" do
164
+ doc = Moxml.new(:leptris).parse(%(<root/>))
165
+ expect { doc.root.append_xml(%(<a><b></a>)) }.to raise_error(Moxml::ParseError)
166
+ end
167
+ end
143
168
  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.30
4
+ version: 0.5.31
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-04 00:00:00.000000000 Z
11
+ date: 2026-09-12 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
@@ -205,6 +205,7 @@ files:
205
205
  - lib/moxml/entity_registry.rb
206
206
  - lib/moxml/entity_registry_opal_data.rb
207
207
  - lib/moxml/error.rb
208
+ - lib/moxml/lazy_node_set.rb
208
209
  - lib/moxml/materializer.rb
209
210
  - lib/moxml/namespace.rb
210
211
  - lib/moxml/native_attachment.rb