moxml 0.1.14 → 0.1.16

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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +117 -66
  3. data/Gemfile +1 -0
  4. data/README.adoc +11 -9
  5. data/Rakefile +34 -1
  6. data/TODO.remaining/1-entity-reference-adapter-support.md +157 -0
  7. data/TODO.remaining/2-entity-restoration-model-driven.md +169 -0
  8. data/TODO.remaining/3-entity-reference-test-coverage.md +170 -0
  9. data/TODO.remaining/4-lenient-entities-mode.md +106 -0
  10. data/TODO.remaining/5-fixture-integrity.md +65 -0
  11. data/TODO.remaining/6-ox-element-ordering-bug.md +36 -0
  12. data/TODO.remaining/7-headed-ox-limitations.md +95 -0
  13. data/TODO.remaining/8-xpath-predicate-gaps.md +68 -0
  14. data/TODO.remaining/9-cleanup-hygiene.md +42 -0
  15. data/TODO.remaining/README.md +54 -0
  16. data/benchmarks/generate_report.rb +1 -1
  17. data/docs/_pages/configuration.adoc +22 -19
  18. data/docs/_tutorials/namespace-handling.adoc +5 -5
  19. data/lib/moxml/adapter/base.rb +22 -3
  20. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -1
  21. data/lib/moxml/adapter/customized_libxml/entity_reference.rb +23 -0
  22. data/lib/moxml/adapter/customized_libxml.rb +18 -0
  23. data/lib/moxml/adapter/customized_oga.rb +10 -0
  24. data/lib/moxml/adapter/customized_ox/entity_reference.rb +25 -0
  25. data/lib/moxml/adapter/customized_ox.rb +12 -0
  26. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +19 -0
  27. data/lib/moxml/adapter/customized_rexml/formatter.rb +44 -20
  28. data/lib/moxml/adapter/customized_rexml.rb +11 -0
  29. data/lib/moxml/adapter/headed_ox.rb +37 -14
  30. data/lib/moxml/adapter/libxml.rb +233 -119
  31. data/lib/moxml/adapter/nokogiri.rb +22 -11
  32. data/lib/moxml/adapter/oga.rb +64 -25
  33. data/lib/moxml/adapter/ox.rb +198 -42
  34. data/lib/moxml/adapter/rexml.rb +64 -13
  35. data/lib/moxml/attribute.rb +3 -0
  36. data/lib/moxml/builder.rb +78 -24
  37. data/lib/moxml/config.rb +24 -7
  38. data/lib/moxml/declaration.rb +4 -2
  39. data/lib/moxml/document.rb +8 -1
  40. data/lib/moxml/document_builder.rb +44 -37
  41. data/lib/moxml/element.rb +18 -5
  42. data/lib/moxml/entity_registry.rb +51 -1
  43. data/lib/moxml/native_attachment.rb +65 -0
  44. data/lib/moxml/node.rb +39 -50
  45. data/lib/moxml/node_set.rb +43 -15
  46. data/lib/moxml/version.rb +1 -1
  47. data/lib/moxml/xml_utils.rb +1 -1
  48. data/lib/moxml/xpath/compiler.rb +4 -1
  49. data/lib/moxml.rb +1 -0
  50. data/scripts/format_xml.rb +16 -0
  51. data/scripts/pretty_format_xml.rb +14 -0
  52. data/spec/consistency/round_trip_spec.rb +3 -30
  53. data/spec/integration/all_adapters_spec.rb +1 -0
  54. data/spec/integration/headed_ox_integration_spec.rb +0 -2
  55. data/spec/integration/shared_examples/edge_cases.rb +7 -4
  56. data/spec/integration/shared_examples/integration_workflows.rb +3 -3
  57. data/spec/integration/shared_examples/node_wrappers/cdata_behavior.rb +1 -1
  58. data/spec/integration/shared_examples/node_wrappers/entity_reference_behavior.rb +224 -0
  59. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +1 -1
  60. data/spec/moxml/adapter/headed_ox_spec.rb +8 -8
  61. data/spec/moxml/adapter/oga_spec.rb +46 -0
  62. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +1 -12
  63. data/spec/moxml/allocation_benchmark_spec.rb +96 -0
  64. data/spec/moxml/allocation_guard_spec.rb +282 -0
  65. data/spec/moxml/builder_spec.rb +256 -0
  66. data/spec/moxml/config_spec.rb +11 -11
  67. data/spec/moxml/doctype_spec.rb +41 -0
  68. data/spec/moxml/lazy_parse_spec.rb +115 -0
  69. data/spec/moxml/namespace_uri_validation_spec.rb +11 -3
  70. data/spec/moxml/node_cache_spec.rb +110 -0
  71. data/spec/moxml/node_set_cache_spec.rb +90 -0
  72. data/spec/moxml/xml_utils_spec.rb +32 -0
  73. data/spec/moxml/xpath/axes_spec.rb +1 -1
  74. data/spec/moxml/xpath/compiler_spec.rb +2 -2
  75. data/spec/moxml/xpath/functions/position_functions_spec.rb +5 -5
  76. data/spec/moxml/xpath/functions/special_functions_spec.rb +1 -1
  77. data/spec/performance/memory_usage_spec.rb +0 -4
  78. data/spec/support/allocation_helper.rb +165 -0
  79. data/spec/support/w3c_namespace_helpers.rb +2 -1
  80. metadata +29 -2
@@ -3,9 +3,7 @@
3
3
  require_relative "base"
4
4
  require "ox"
5
5
  require "stringio"
6
- require_relative "customized_ox/text"
7
- require_relative "customized_ox/attribute"
8
- require_relative "customized_ox/namespace"
6
+ require_relative "customized_ox"
9
7
 
10
8
  # insert :parent methods to all Ox classes inherit the Node class
11
9
  Ox::Node.attr_accessor :parent
@@ -13,20 +11,27 @@ module Moxml
13
11
  module Adapter
14
12
  class Ox < Base
15
13
  class << self
14
+ def attachments
15
+ @attachments ||= Moxml::NativeAttachment.new
16
+ end
17
+
16
18
  def set_root(doc, element)
17
19
  replace_children(doc, [element])
18
20
  end
19
21
 
20
- def parse(xml, _options = {}, _context = nil)
22
+ def parse(xml, options = {}, _context = nil)
21
23
  native_doc = begin
22
24
  result = ::Ox.parse(xml)
23
25
 
24
26
  # result can be either Document or Element
25
27
  if result.is_a?(::Ox::Document)
28
+ assign_parents(result)
29
+ validate_single_root(result) if options[:strict]
26
30
  result
27
31
  else
28
32
  doc = ::Ox::Document.new
29
33
  doc << result
34
+ assign_parents(doc)
30
35
  doc
31
36
  end
32
37
  rescue ::Ox::ParseError => e
@@ -37,7 +42,7 @@ module Moxml
37
42
  end
38
43
 
39
44
  ctx = _context || Context.new(:ox)
40
- DocumentBuilder.new(ctx).build(native_doc)
45
+ Document.new(native_doc, ctx)
41
46
  end
42
47
 
43
48
  # SAX parsing implementation for Ox
@@ -50,7 +55,7 @@ module Moxml
50
55
  bridge = OxSAXBridge.new(handler)
51
56
 
52
57
  # Parse using Ox's SAX parser
53
- xml_string = xml.respond_to?(:read) ? xml.read : xml.to_s
58
+ xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
54
59
 
55
60
  begin
56
61
  ::Ox.sax_parse(bridge, StringIO.new(xml_string))
@@ -69,7 +74,6 @@ module Moxml
69
74
 
70
75
  def create_native_element(name, _owner_doc = nil)
71
76
  element = ::Ox::Element.new(name)
72
- element.instance_variable_set(:@attributes, {})
73
77
  element
74
78
  end
75
79
 
@@ -77,6 +81,14 @@ module Moxml
77
81
  content
78
82
  end
79
83
 
84
+ def create_native_entity_reference(name)
85
+ ::Moxml::Adapter::CustomizedOx::EntityReference.new(name)
86
+ end
87
+
88
+ def entity_reference_name(node)
89
+ node.name if node.is_a?(::Moxml::Adapter::CustomizedOx::EntityReference)
90
+ end
91
+
80
92
  def create_native_cdata(content, _owner_doc = nil)
81
93
  ::Ox::CData.new(content)
82
94
  end
@@ -121,11 +133,11 @@ module Moxml
121
133
  end
122
134
 
123
135
  def set_namespace(element, ns)
124
- return unless element.respond_to?(:name)
136
+ return unless element.is_a?(::Ox::Element) || element.is_a?(::Ox::Node)
125
137
 
126
138
  prefix = ns.prefix
127
139
  # attributes don't have attributes but can have a namespace prefix
128
- if element.respond_to?(:attributes)
140
+ if element.is_a?(::Ox::Element)
129
141
  set_attribute(element, ns.expanded_prefix,
130
142
  ns.uri)
131
143
  end
@@ -136,8 +148,7 @@ module Moxml
136
148
 
137
149
  def namespace(element)
138
150
  prefix =
139
- if element.respond_to?(:prefix)
140
- # attribute
151
+ if element.is_a?(::Moxml::Adapter::CustomizedOx::Attribute)
141
152
  element.prefix
142
153
  elsif element.name.include?(":")
143
154
  element.name.split(":").first
@@ -145,7 +156,7 @@ module Moxml
145
156
  attr_name = ["xmlns", prefix].compact.join(":")
146
157
 
147
158
  ([element] + ancestors(element)).each do |node|
148
- next unless node.respond_to?(:attributes) && node.attributes
159
+ next unless node.is_a?(::Ox::Element) && node.attributes
149
160
 
150
161
  if node[attr_name]
151
162
  return ::Moxml::Adapter::CustomizedOx::Namespace.new(
@@ -176,6 +187,7 @@ module Moxml
176
187
  when ::Ox::Instruct then :processing_instruction
177
188
  when ::Ox::Element then :element
178
189
  when ::Ox::DocType then :doctype
190
+ when ::Moxml::Adapter::CustomizedOx::EntityReference then :entity_reference
179
191
  when ::Moxml::Adapter::CustomizedOx::Namespace then :banespace
180
192
  when ::Moxml::Adapter::CustomizedOx::Attribute then :attribute
181
193
  else :unknown
@@ -194,10 +206,9 @@ module Moxml
194
206
  end
195
207
 
196
208
  def set_node_name(node, name)
197
- if node.respond_to?(:name=)
198
- node.name = name
199
- elsif node.respond_to?(:value=)
200
- node.value = name
209
+ case node
210
+ when ::Ox::Element then node.name = name
211
+ when ::Ox::Instruct then node.value = name
201
212
  end
202
213
  end
203
214
 
@@ -219,7 +230,7 @@ module Moxml
219
230
  else node
220
231
  end
221
232
 
222
- new_node.parent = parent if new_node.respond_to?(:parent)
233
+ new_node.parent = parent if new_node.is_a?(::Ox::Node)
223
234
 
224
235
  new_node
225
236
  end
@@ -231,18 +242,25 @@ module Moxml
231
242
  node.value]
232
243
  # when ::Moxml::Adapter::CustomizedOx::Attribute then { node.name => node.value }
233
244
  when ::Moxml::Adapter::CustomizedOx::Text then node.value
245
+ when ::Moxml::Adapter::CustomizedOx::EntityReference then node
234
246
  else node
235
247
  end
236
248
  end
237
249
 
238
250
  def children(node)
239
- return [] unless node.respond_to?(:nodes)
251
+ return [] unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
240
252
 
241
- node.nodes || []
253
+ result = node.nodes || []
254
+ # Ox doesn't set parent references during parsing.
255
+ # Set them here so parent/sibling navigation works.
256
+ result.each do |child|
257
+ child.parent = node if child.respond_to?(:parent=)
258
+ end
259
+ result
242
260
  end
243
261
 
244
262
  def parent(node)
245
- node.parent if node.respond_to?(:parent)
263
+ node.parent if node.is_a?(::Ox::Node)
246
264
  end
247
265
 
248
266
  def next_sibling(node)
@@ -272,9 +290,7 @@ module Moxml
272
290
  end
273
291
 
274
292
  def attributes(element)
275
- unless element.respond_to?(:attributes) && element.attributes
276
- return []
277
- end
293
+ return [] unless element.is_a?(::Ox::Element) && element.attributes
278
294
 
279
295
  element.attributes.filter_map do |name, value|
280
296
  next if name.to_s.start_with?("xmlns")
@@ -324,7 +340,7 @@ module Moxml
324
340
  end
325
341
 
326
342
  def get_attribute(element, name)
327
- return unless element.respond_to?(:attributes) && element.attributes
343
+ return unless element.is_a?(::Ox::HasAttrs) && element.attributes
328
344
  unless element.attributes.key?(name.to_s) || element.attributes.key?(name.to_s.to_sym)
329
345
  return
330
346
  end
@@ -342,7 +358,7 @@ module Moxml
342
358
  end
343
359
 
344
360
  def remove_attribute(element, name)
345
- return unless element.respond_to?(:attributes) && element.attributes
361
+ return unless element.is_a?(::Ox::HasAttrs) && element.attributes
346
362
 
347
363
  element.attributes.delete(name.to_s)
348
364
  element.attributes.delete(name.to_s.to_sym)
@@ -367,15 +383,24 @@ module Moxml
367
383
  end
368
384
  end
369
385
 
370
- child.parent = element if child.respond_to?(:parent)
386
+ child.parent = element if child.is_a?(::Ox::Node)
371
387
  element.nodes ||= []
372
388
  element.nodes << child
389
+
390
+ # Mark document if EntityReference is added (avoids tree scan in serialize)
391
+ if child.is_a?(::Moxml::Adapter::CustomizedOx::EntityReference)
392
+ root = element
393
+ while root.is_a?(::Ox::Node) && root.parent
394
+ root = root.parent
395
+ end
396
+ attachments.set(root, :has_entity_refs, true) if root
397
+ end
373
398
  end
374
399
 
375
400
  def add_previous_sibling(node, sibling)
376
401
  return unless (parent = parent(node))
377
402
 
378
- if sibling.respond_to?(:parent)
403
+ if sibling.is_a?(::Ox::Node)
379
404
  sibling.parent&.nodes&.delete(sibling)
380
405
  sibling.parent = parent
381
406
  end
@@ -386,7 +411,7 @@ module Moxml
386
411
  def add_next_sibling(node, sibling)
387
412
  return unless (parent = parent(node))
388
413
 
389
- if sibling.respond_to?(:parent)
414
+ if sibling.is_a?(::Ox::Node)
390
415
  sibling.parent&.nodes&.delete(sibling)
391
416
  sibling.parent = parent
392
417
  end
@@ -421,7 +446,7 @@ module Moxml
421
446
 
422
447
  return unless (parent = parent(node))
423
448
 
424
- new_node.parent = parent if new_node.respond_to?(:parent)
449
+ new_node.parent = parent if new_node.is_a?(::Ox::Node)
425
450
  idx = parent.nodes.index(node)
426
451
  parent.nodes[idx] = new_node if idx
427
452
  end
@@ -429,20 +454,40 @@ module Moxml
429
454
  def replace_children(node, new_children)
430
455
  node.remove_children_by_path("*")
431
456
  new_children.each do |child|
432
- child.parent = node if child.respond_to?(:parent)
457
+ child.parent = node if child.is_a?(::Ox::Node)
433
458
  node << child
434
459
  end
435
460
  node
436
461
  end
437
462
 
463
+ def assign_parents(node, parent = nil)
464
+ node.parent = parent if node.respond_to?(:parent=) && parent
465
+ return unless node.respond_to?(:nodes)
466
+
467
+ node.nodes&.each do |child|
468
+ assign_parents(child, node)
469
+ end
470
+ end
471
+
472
+ def validate_single_root(document)
473
+ elements = document.nodes&.grep(::Ox::Element) || []
474
+ return unless elements.size > 1
475
+
476
+ raise Moxml::ParseError.new(
477
+ "Multiple root elements found",
478
+ source: nil,
479
+ )
480
+ end
481
+
438
482
  def text_content(node)
439
483
  return "" if node.nil?
440
484
 
441
485
  case node
442
486
  when String then node.to_s
443
487
  when ::Moxml::Adapter::CustomizedOx::Text then node.value
488
+ when ::Moxml::Adapter::CustomizedOx::EntityReference then ""
444
489
  else
445
- return "" unless node.respond_to?(:nodes)
490
+ return "" unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
446
491
 
447
492
  node.nodes.map do |n|
448
493
  text_content(n)
@@ -451,7 +496,7 @@ module Moxml
451
496
  end
452
497
 
453
498
  def inner_text(node)
454
- return "" unless node.respond_to?(:nodes)
499
+ return "" unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
455
500
 
456
501
  node.nodes.grep(String).join
457
502
  end
@@ -499,7 +544,7 @@ module Moxml
499
544
 
500
545
  def namespace_definitions(node)
501
546
  ([node] + ancestors(node)).reverse.each_with_object({}) do |n, namespaces|
502
- next unless n.respond_to?(:attributes) && n.attributes
547
+ next unless n.is_a?(::Ox::Element) && n.attributes
503
548
 
504
549
  n.attributes.each do |name, value|
505
550
  next unless name.to_s.start_with?("xmlns")
@@ -575,18 +620,35 @@ module Moxml
575
620
  end
576
621
 
577
622
  def serialize(node, options = {})
623
+ # Fast path: skip EntityReference scan for documents (most common case)
624
+ if node.is_a?(::Ox::Document) &&
625
+ !attachments.get(node, :has_entity_refs)
626
+ return serialize_standard(node, options)
627
+ end
628
+
629
+ if tree_has_entity_references?(node)
630
+ serialize_custom(node, options)
631
+ else
632
+ serialize_standard(node, options)
633
+ end
634
+ end
635
+
636
+ def has_declaration?(native_doc, _wrapper)
637
+ # Ox stores declaration in document attributes
638
+ native_doc[:version] || native_doc[:encoding] || native_doc[:standalone]
639
+ end
640
+
641
+ private
642
+
643
+ def serialize_standard(node, options = {})
578
644
  output = ""
579
645
  if node.is_a?(::Ox::Document)
580
- # Check if we should include declaration
581
- # Priority: explicit option > document attributes
582
646
  should_include_decl = if options.key?(:no_declaration)
583
647
  !options[:no_declaration]
584
648
  else
585
- # Check if document has declaration attributes
586
649
  node[:version] || node[:encoding] || node[:standalone]
587
650
  end
588
651
 
589
- # Only add declaration if should_include_decl is true
590
652
  if should_include_decl
591
653
  version = node[:version] || "1.0"
592
654
  encoding = options[:encoding] || node[:encoding]
@@ -598,8 +660,7 @@ module Moxml
598
660
  end
599
661
 
600
662
  ox_options = {
601
- indent: -1, # options[:indent] || -1, # indent is a beast
602
- # with_xml: true,
663
+ indent: -1,
603
664
  with_instructions: true,
604
665
  encoding: options[:encoding],
605
666
  no_empty: options[:expand_empty],
@@ -607,7 +668,102 @@ module Moxml
607
668
  output + ::Ox.dump(node, ox_options)
608
669
  end
609
670
 
610
- private
671
+ def tree_has_entity_references?(node)
672
+ case node
673
+ when ::Moxml::Adapter::CustomizedOx::EntityReference
674
+ true
675
+ when ::Ox::Element
676
+ node.nodes&.any? do |child|
677
+ tree_has_entity_references?(child)
678
+ end || false
679
+ when ::Ox::Document
680
+ node.nodes&.any? do |child|
681
+ tree_has_entity_references?(child)
682
+ end || false
683
+ else
684
+ false
685
+ end
686
+ end
687
+
688
+ def serialize_custom(node, options = {})
689
+ output = +""
690
+ if node.is_a?(::Ox::Document)
691
+ should_include_decl = if options.key?(:no_declaration)
692
+ !options[:no_declaration]
693
+ else
694
+ node[:version] || node[:encoding] || node[:standalone]
695
+ end
696
+ if should_include_decl
697
+ version = node[:version] || "1.0"
698
+ encoding = options[:encoding] || node[:encoding]
699
+ standalone = node[:standalone]
700
+ output << "<?xml version=\"#{version}\""
701
+ output << " encoding=\"#{encoding}\"" if encoding
702
+ output << " standalone=\"#{standalone}\"" if standalone
703
+ output << "?>"
704
+ end
705
+ (node.nodes || []).each do |child|
706
+ output << serialize_node_custom(child)
707
+ end
708
+ else
709
+ output << serialize_node_custom(node)
710
+ end
711
+ output
712
+ end
713
+
714
+ def serialize_node_custom(node)
715
+ case node
716
+ when ::Ox::Element then serialize_element_custom(node)
717
+ when String then escape_xml_text(node)
718
+ when ::Moxml::Adapter::CustomizedOx::Text then escape_xml_text(node.value)
719
+ when ::Moxml::Adapter::CustomizedOx::EntityReference then "&#{node.name};"
720
+ when ::Ox::CData then "<![CDATA[#{node.value}]]>"
721
+ when ::Ox::Comment then "<!--#{node.value}-->"
722
+ when ::Ox::Instruct then "<?#{node.target} #{node.value || ''}?>"
723
+ when ::Ox::DocType then "<!DOCTYPE #{node.value}>"
724
+ else ""
725
+ end
726
+ end
727
+
728
+ def serialize_element_custom(elem)
729
+ output = "<#{elem.name}"
730
+ elem.attributes.each do |name, value|
731
+ output << " #{name}=\"#{escape_xml_attribute(value.to_s)}\""
732
+ end
733
+
734
+ if elem.nodes.nil? || elem.nodes.empty?
735
+ output << "/>"
736
+ return output
737
+ end
738
+
739
+ output << ">"
740
+ elem.nodes.each do |child|
741
+ output << serialize_node_custom(child)
742
+ end
743
+ output << "</#{elem.name}>"
744
+ output
745
+ end
746
+
747
+ def escape_xml_text(text)
748
+ text.to_s.gsub(/[<>&]/) do |match|
749
+ case match
750
+ when "<" then "&lt;"
751
+ when ">" then "&gt;"
752
+ when "&" then "&amp;"
753
+ end
754
+ end
755
+ end
756
+
757
+ def escape_xml_attribute(value)
758
+ value.to_s.gsub(/[<>&"]/) do |match|
759
+ case match
760
+ when "<" then "&lt;"
761
+ when ">" then "&gt;"
762
+ when "&" then "&amp;"
763
+ when '"' then "&quot;"
764
+ end
765
+ end
766
+ end
611
767
 
612
768
  # Translate a subset of XPath to Ox locate() syntax
613
769
  # Supports: //element, /path/to/element, .//element, element[@attr]
@@ -659,7 +815,7 @@ module Moxml
659
815
 
660
816
  result = nil
661
817
  traverse(root) do |node|
662
- next unless node.respond_to?(:nodes)
818
+ next unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
663
819
 
664
820
  node.nodes&.each do |child|
665
821
  if child.equal?(target_node)
@@ -676,7 +832,7 @@ module Moxml
676
832
  return unless node
677
833
 
678
834
  yield node
679
- return unless node.respond_to?(:nodes)
835
+ return unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
680
836
 
681
837
  node.nodes&.each { |child| traverse(child, &block) }
682
838
  end
@@ -4,12 +4,16 @@ require_relative "base"
4
4
  require "rexml/document"
5
5
  require "rexml/xpath"
6
6
  require "set"
7
- require_relative "customized_rexml/formatter"
7
+ require_relative "customized_rexml"
8
8
 
9
9
  module Moxml
10
10
  module Adapter
11
11
  class Rexml < Base
12
12
  class << self
13
+ def attachments
14
+ @attachments ||= Moxml::NativeAttachment.new
15
+ end
16
+
13
17
  def parse(xml, options = {}, _context = nil)
14
18
  # Handle frozen strings by creating a mutable copy
15
19
  processed_xml = if xml.frozen?
@@ -54,7 +58,7 @@ module Moxml
54
58
 
55
59
  bridge = REXMLSAX2Bridge.new(handler)
56
60
 
57
- xml_string = xml.respond_to?(:read) ? xml.read : xml.to_s
61
+ xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
58
62
  source = ::REXML::IOSource.new(StringIO.new(xml_string))
59
63
 
60
64
  parser = ::REXML::Parsers::SAX2Parser.new(source)
@@ -77,6 +81,14 @@ module Moxml
77
81
  ::REXML::Text.new(content.to_s, true, nil)
78
82
  end
79
83
 
84
+ def create_native_entity_reference(name)
85
+ ::Moxml::Adapter::CustomizedRexml::EntityReference.new(name)
86
+ end
87
+
88
+ def entity_reference_name(node)
89
+ node.name if node.is_a?(::Moxml::Adapter::CustomizedRexml::EntityReference)
90
+ end
91
+
80
92
  def create_native_cdata(content, _owner_doc = nil)
81
93
  ::REXML::CData.new(content.to_s)
82
94
  end
@@ -124,6 +136,7 @@ module Moxml
124
136
  when ::REXML::Instruction then :processing_instruction
125
137
  when ::REXML::DocType then :doctype
126
138
  when ::REXML::XMLDecl then :declaration
139
+ when ::Moxml::Adapter::CustomizedRexml::EntityReference then :entity_reference
127
140
  else :unknown
128
141
  end
129
142
  end
@@ -149,13 +162,15 @@ module Moxml
149
162
  end
150
163
 
151
164
  def duplicate_node(node)
152
- # Make a complete duplicate of the node
153
- # https://stackoverflow.com/questions/23878384/why-the-original-element-got-changed-when-i-modify-the-copy-created-by-dup-meth
154
- Marshal.load(Marshal.dump(node))
165
+ if node.respond_to?(:deep_clone)
166
+ node.deep_clone
167
+ else
168
+ Marshal.load(Marshal.dump(node))
169
+ end
155
170
  end
156
171
 
157
172
  def children(node)
158
- return [] unless node.respond_to?(:children)
173
+ return [] unless node.is_a?(::REXML::Parent)
159
174
 
160
175
  # Get all children and filter out empty text nodes between elements
161
176
  result = node.children.reject do |child|
@@ -164,6 +179,10 @@ module Moxml
164
179
  !(child.next_sibling.nil? && child.previous_sibling.nil?)
165
180
  end
166
181
 
182
+ # Include any EntityReference wrappers stored alongside native children
183
+ entity_refs = attachments.get(node, :entity_refs)
184
+ result.concat(entity_refs) if entity_refs
185
+
167
186
  # Ensure uniqueness by object_id to prevent duplicates
168
187
  result.uniq(&:object_id)
169
188
  end
@@ -229,7 +248,7 @@ module Moxml
229
248
  end
230
249
 
231
250
  def attributes(element)
232
- return [] unless element.respond_to?(:attributes)
251
+ return [] unless element.is_a?(::REXML::Element)
233
252
 
234
253
  # Only return non-namespace attributes
235
254
  element.attributes.values
@@ -273,18 +292,33 @@ module Moxml
273
292
  def add_child(element, child)
274
293
  # Special handling for declarations on REXML documents
275
294
  if element.is_a?(::REXML::Document) && child.is_a?(::REXML::XMLDecl)
276
- # Set document's xml_decl directly
277
- element.instance_variable_set(:@xml_declaration, child)
295
+ # Track declaration state in attachment map
296
+ attachments.set(element, :xml_declaration, child)
278
297
  end
279
298
 
280
299
  case child
281
300
  when String
282
301
  element.add_text(child)
302
+ append_child_sequence(element, :native)
303
+ when ::Moxml::Adapter::CustomizedRexml::EntityReference
304
+ # REXML doesn't support custom node types in its tree.
305
+ # Store alongside native children via attachment map.
306
+ refs = attachments.get(element, :entity_refs) || []
307
+ refs << child
308
+ attachments.set(element, :entity_refs, refs)
309
+ append_child_sequence(element, :eref)
283
310
  else
284
311
  element.add(child)
312
+ append_child_sequence(element, :native)
285
313
  end
286
314
  end
287
315
 
316
+ def append_child_sequence(element, type)
317
+ seq = attachments.get(element, :child_sequence) || []
318
+ seq << type
319
+ attachments.set(element, :child_sequence, seq)
320
+ end
321
+
288
322
  def add_previous_sibling(node, sibling)
289
323
  parent = node.parent
290
324
  # caveat: Rexml fails if children belong to the same parent and are already in a correct order
@@ -303,8 +337,8 @@ module Moxml
303
337
  def remove(node)
304
338
  # Special handling for declarations on REXML documents
305
339
  if node.is_a?(::REXML::XMLDecl) && node.parent.is_a?(::REXML::Document)
306
- # Clear document's xml_declaration when removing declaration
307
- node.parent.instance_variable_set(:@xml_declaration, nil)
340
+ # Clear declaration state in attachment map
341
+ attachments.set(node.parent, :xml_declaration, nil)
308
342
  end
309
343
 
310
344
  node.remove
@@ -373,6 +407,8 @@ module Moxml
373
407
  case node
374
408
  when ::REXML::Text, ::REXML::CData
375
409
  node.value.to_s
410
+ when ::Moxml::Adapter::CustomizedRexml::EntityReference
411
+ ""
376
412
  when ::REXML::Element
377
413
  # Extract text recursively from all children to match other adapters
378
414
  extract_text_recursively(node)
@@ -428,7 +464,7 @@ module Moxml
428
464
  # add a namespace prefix to the element name AND a namespace definition
429
465
  def set_namespace(element, ns)
430
466
  prefix = ns.name.to_s.empty? ? "xmlns" : ns.name.to_s
431
- if element.respond_to?(:add_namespace)
467
+ if element.is_a?(::REXML::Element)
432
468
  element.add_namespace(prefix,
433
469
  ns.value)
434
470
  end
@@ -556,11 +592,26 @@ module Moxml
556
592
  output.strip
557
593
  end
558
594
 
595
+ def has_declaration?(native_doc, wrapper)
596
+ xml_decl = attachments.get(native_doc, :xml_declaration)
597
+ if xml_decl.nil?
598
+ # Attachment key doesn't exist - check native doc or wrapper flag
599
+ if attachments.key?(native_doc, :xml_declaration)
600
+ # Explicitly set to nil (was removed)
601
+ false
602
+ else
603
+ wrapper.has_xml_declaration
604
+ end
605
+ else
606
+ true
607
+ end
608
+ end
609
+
559
610
  private
560
611
 
561
612
  def write_with_formatter(node, output, indent = 2)
562
613
  formatter = ::Moxml::Adapter::CustomizedRexml::Formatter.new(
563
- indentation: indent, self_close_empty: false,
614
+ indentation: indent, self_close_empty: false, adapter: self,
564
615
  )
565
616
  formatter.write(node, output)
566
617
  end
@@ -45,6 +45,9 @@ module Moxml
45
45
 
46
46
  def remove
47
47
  adapter.remove_attribute(element, name)
48
+ if @parent_node.is_a?(Moxml::Element)
49
+ @parent_node.invalidate_attribute_cache!
50
+ end
48
51
  self
49
52
  end
50
53