moxml 0.1.26 → 0.3.0

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +6 -1
  6. data/lib/moxml/adapter/base.rb +53 -56
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +137 -117
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +139 -55
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +64 -51
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/builder.rb +0 -18
  35. data/lib/moxml/config.rb +29 -5
  36. data/lib/moxml/context.rb +33 -0
  37. data/lib/moxml/document.rb +1 -1
  38. data/lib/moxml/element.rb +29 -12
  39. data/lib/moxml/entity/reference.rb +28 -0
  40. data/lib/moxml/entity/restorer.rb +95 -0
  41. data/lib/moxml/entity.rb +126 -0
  42. data/lib/moxml/entity_registry.rb +1 -1
  43. data/lib/moxml/error.rb +0 -42
  44. data/lib/moxml/node.rb +135 -10
  45. data/lib/moxml/node_set.rb +1 -1
  46. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  47. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  48. data/lib/moxml/version.rb +1 -1
  49. data/lib/moxml/xml_emitter.rb +71 -0
  50. data/lib/moxml/xpath/compiler.rb +143 -8
  51. data/lib/moxml/xpath/parser.rb +22 -15
  52. data/lib/moxml.rb +3 -3
  53. data/spec/examples/readme_examples_spec.rb +0 -4
  54. data/spec/examples/xpath_examples_spec.rb +0 -11
  55. data/spec/integration/all_adapters_spec.rb +18 -1
  56. data/spec/integration/sax_parity_spec.rb +58 -0
  57. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  58. data/spec/integration/shared_examples/high_level/{document_builder_behavior.rb → parse_behavior.rb} +1 -2
  59. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  60. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  61. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  62. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +15 -8
  63. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  64. data/spec/moxml/adapter/libxml_internals_spec.rb +1 -1
  65. data/spec/moxml/adapter/ox_spec.rb +20 -6
  66. data/spec/moxml/adapter/platform_spec.rb +15 -2
  67. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  68. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  69. data/spec/moxml/config_spec.rb +0 -1
  70. data/spec/moxml/context_spec.rb +22 -0
  71. data/spec/moxml/doctype_spec.rb +1 -1
  72. data/spec/moxml/entity_spec.rb +76 -0
  73. data/spec/moxml/error_spec.rb +0 -50
  74. data/spec/moxml/node_spec.rb +104 -0
  75. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  76. data/spec/moxml/xml_emitter_spec.rb +64 -0
  77. data/spec/moxml/xpath/axes_spec.rb +112 -0
  78. data/spec/moxml/xpath/parser_spec.rb +6 -0
  79. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  80. data/spec/performance/benchmark_spec.rb +13 -6
  81. data/spec/performance/thread_safety_spec.rb +0 -4
  82. data/spec/spec_helper.rb +5 -1
  83. metadata +23 -6
  84. data/lib/moxml/adapter/libxml/entity_restorer.rb +0 -94
  85. data/lib/moxml/document_builder.rb +0 -167
  86. data/spec/moxml/document_builder_spec.rb +0 -30
@@ -8,7 +8,6 @@ module Moxml
8
8
  module Adapter
9
9
  class Libxml < Base
10
10
  autoload :EntityRefRegistry, "moxml/adapter/libxml/entity_ref_registry"
11
- autoload :EntityRestorer, "moxml/adapter/libxml/entity_restorer"
12
11
 
13
12
  # Wrapper class to store DOCTYPE information
14
13
  class DoctypeWrapper
@@ -28,15 +27,7 @@ module Moxml
28
27
  end
29
28
 
30
29
  def to_xml
31
- output = "<!DOCTYPE #{@name}"
32
- if @external_id && !@external_id.empty?
33
- output << " PUBLIC \"#{@external_id}\""
34
- output << " \"#{@system_id}\"" if @system_id
35
- elsif @system_id && !@system_id.empty?
36
- output << " SYSTEM \"#{@system_id}\""
37
- end
38
- output << ">"
39
- output
30
+ XmlEmitter.doctype_xml(@name, @external_id, @system_id)
40
31
  end
41
32
  end
42
33
 
@@ -57,6 +48,7 @@ module Moxml
57
48
 
58
49
  WRAPPER_NODE_TYPE_MAP = {
59
50
  DoctypeWrapper => :doctype,
51
+ CustomizedLibxml::Declaration => :declaration,
60
52
  CustomizedLibxml::Element => :element,
61
53
  CustomizedLibxml::Text => :text,
62
54
  CustomizedLibxml::Cdata => :cdata,
@@ -91,7 +83,7 @@ module Moxml
91
83
  xml_string = preprocess_entities(xml_string)
92
84
 
93
85
  # Extract DOCTYPE before parsing
94
- doctype_match = xml_string.match(/<!DOCTYPE\s+(\S+)(?:\s+PUBLIC\s+"([^"]+)"\s+"([^"]+)"| \s+SYSTEM\s+"([^"]+)")?\s*>/i)
86
+ doctype_match = xml_string.match(/<!DOCTYPE\s+([^\s>]+)(?:\s+PUBLIC\s+"([^"]+)"\s+"([^"]+)"|\s+SYSTEM\s+"([^"]+)")?\s*>/i)
95
87
 
96
88
  native_doc = begin
97
89
  # Handle both string and file inputs
@@ -128,10 +120,7 @@ module Moxml
128
120
 
129
121
  ctx = _context || Context.new(:libxml)
130
122
  # Single parse path: wrap libxml's already-complete C-parsed tree
131
- # directly (same pattern as nokogiri/ox). The previous
132
- # DocumentBuilder.build path walked the entire parsed tree and
133
- # re-added every node to a fresh moxml-managed document, which
134
- # made parse O(N) Ruby work on top of an already-complete parse.
123
+ # directly (same pattern as the other adapters).
135
124
  # Doctype/declaration/PI attachments set above remain on
136
125
  # native_doc, so the serialize path still sees them.
137
126
  #
@@ -141,7 +130,7 @@ module Moxml
141
130
  # path; the restoration walk is just one of potentially several
142
131
  # post-processing steps and doesn't fork the construction.
143
132
  doc = Document.new(native_doc, ctx)
144
- EntityRestorer.new(doc).run if ctx.config.restore_entities
133
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
145
134
  doc
146
135
  end
147
136
 
@@ -226,7 +215,7 @@ module Moxml
226
215
  return :unknown unless node
227
216
 
228
217
  # Fast path: native libxml nodes are the vast majority during
229
- # parse traversal (DocumentBuilder visits raw libxml children).
218
+ # parse traversal (wrapping visits raw libxml children).
230
219
  # Skip the wrapper checks below for them.
231
220
  if node.is_a?(::LibXML::XML::Node)
232
221
  return NATIVE_NODE_TYPE_MAP[node.node_type] || :unknown
@@ -379,6 +368,11 @@ module Moxml
379
368
  native_doc&.root
380
369
  end
381
370
 
371
+ def line_number(node)
372
+ native = unpatch_node(node)
373
+ native.is_a?(::LibXML::XML::Node) ? native.line_num : nil
374
+ end
375
+
382
376
  def attributes(element)
383
377
  native_elem = unpatch_node(element)
384
378
  return [] unless native_elem
@@ -437,9 +431,13 @@ module Moxml
437
431
  native_elem.attributes.get_attribute(name_str)
438
432
  end
439
433
  else
440
- # Regular attribute without namespace
441
- native_elem[name_str] = value_str
442
- native_elem.attributes.get_attribute(name_str)
434
+ # node[]= is ambiguous when a namespace-bound attribute
435
+ # shares the local name (it clobbers that attribute);
436
+ # create or replace the no-namespace attribute explicitly.
437
+ native_elem.each_attr do |attr|
438
+ attr.remove! if !attr.ns && attr.name == name_str
439
+ end if native_elem.attributes?
440
+ ::LibXML::XML::Attr.new(native_elem, name_str, value_str)
443
441
  end
444
442
  end
445
443
 
@@ -467,30 +465,46 @@ module Moxml
467
465
  native_elem = unpatch_node(element)
468
466
  return nil unless native_elem
469
467
 
470
- # Try to get the attribute with the given name (handles namespaced attrs)
471
- value = native_elem[name.to_s]
472
- return value if value
473
-
474
- # If name contains ':', try to get as namespaced attribute
475
- if name.to_s.include?(":")
476
- prefix, local_name = name.to_s.split(":", 2)
468
+ name_s = name.to_s
469
+ if name_s.include?(":")
470
+ prefix, local_name = name_s.split(":", 2)
477
471
  # Try to find attribute by namespace
478
472
  if native_elem.attributes?
479
473
  native_elem.each_attr do |attr|
480
- if attr.name == local_name || attr.name == name.to_s
474
+ if [local_name, name_s].include?(attr.name)
481
475
  # Check if attribute's namespace matches the prefix
482
476
  if attr.ns && attr.ns.prefix == prefix
483
477
  return attr.value
484
- elsif attr.name == name.to_s
478
+ elsif attr.name == name_s
485
479
  # Fallback: attribute name includes the prefix
486
480
  return attr.value
487
481
  end
488
482
  end
489
483
  end
490
484
  end
485
+
486
+ return nil
491
487
  end
492
488
 
493
- nil
489
+ # Bare name: node[name_s] is ambiguous when a namespaced
490
+ # attribute shares the local name (issue #94), so prefer the
491
+ # namespace-less attribute explicitly.
492
+ if native_elem.attributes?
493
+ namespaced_value = nil
494
+ native_elem.each_attr do |attr|
495
+ next if attr.name.to_s.start_with?("xmlns")
496
+ next unless attr.name == name_s
497
+
498
+ if attr.ns.nil?
499
+ return attr.value
500
+ elsif namespaced_value.nil?
501
+ namespaced_value = attr.value
502
+ end
503
+ end
504
+ return namespaced_value unless namespaced_value.nil?
505
+ end
506
+
507
+ native_elem[name_s]
494
508
  end
495
509
 
496
510
  def remove_attribute(element, name)
@@ -502,6 +516,10 @@ module Moxml
502
516
  attr&.remove!
503
517
  end
504
518
 
519
+ def remove_attribute_native(attr)
520
+ attr&.remove!
521
+ end
522
+
505
523
  def set_attribute_name(attribute, new_name)
506
524
  # LibXML attributes cannot be renamed directly
507
525
  # We must work at the element level
@@ -730,26 +748,52 @@ module Moxml
730
748
 
731
749
  def set_text_content(node, content)
732
750
  native_node = unpatch_node(node)
733
- native_node.content = content.to_s if native_node
751
+ return unless native_node.is_a?(::LibXML::XML::Node)
752
+
753
+ # Text wrapper: swap with new_text to preserve verbatim storage.
754
+ # Element wrapper: let libxml manage the child text node.
755
+ if native_node.text?
756
+ replace_native_verbatim(node, ::LibXML::XML::Node.new_text(content.to_s))
757
+ else
758
+ native_node.content = content.to_s
759
+ end
734
760
  end
735
761
 
736
- def cdata_content(node)
737
- native_node = unpatch_node(node)
738
- content = native_node&.content
739
- # LibXML may HTML-escape CDATA content, un-escape it
740
- return nil unless content
762
+ # Replace `old_native` with `fresh`, preserving position. Both
763
+ # neighbor pointers are set explicitly to match `replace`'s
764
+ # semantics (see #replace above).
765
+ def swap_native_in_place(old_native, fresh)
766
+ parent = old_native.parent
767
+ return if parent.nil?
768
+
769
+ prev_sibling = old_native.prev
770
+ next_sibling = old_native.next
771
+ old_native.remove!
772
+ prev_sibling.next = fresh if prev_sibling
773
+ next_sibling.prev = fresh if next_sibling
774
+ parent << fresh unless prev_sibling || next_sibling
775
+ end
776
+ private :swap_native_in_place
777
+
778
+ # libxml-ruby's node.content= pre-escapes; building a fresh node via
779
+ # new_<kind> stores content verbatim. Swap in place and re-point the
780
+ # wrapper.
781
+ def replace_native_verbatim(node, fresh)
782
+ native = unpatch_node(node)
783
+ return unless native.is_a?(::LibXML::XML::Node)
784
+
785
+ swap_native_in_place(native, fresh)
786
+ node.replace_native!(fresh) if node.is_a?(CustomizedLibxml::Node)
787
+ end
788
+ private :replace_native_verbatim
741
789
 
742
- content.gsub("&quot;", '"')
743
- .gsub("&apos;", "'")
744
- .gsub("&lt;", "<")
745
- .gsub("&gt;", ">")
746
- .gsub("&amp;", "&")
790
+ def cdata_content(node)
791
+ # libxml stores CDATA payload verbatim; no decoding needed.
792
+ unpatch_node(node)&.content
747
793
  end
748
794
 
749
795
  def set_cdata_content(node, content)
750
- native_node = unpatch_node(node)
751
- # CDATA content should NOT be escaped
752
- native_node.content = content.to_s if native_node
796
+ replace_native_verbatim(node, ::LibXML::XML::Node.new_cdata(content.to_s))
753
797
  end
754
798
 
755
799
  def comment_content(node)
@@ -758,8 +802,7 @@ module Moxml
758
802
  end
759
803
 
760
804
  def set_comment_content(node, content)
761
- native_node = unpatch_node(node)
762
- native_node.content = content.to_s if native_node
805
+ replace_native_verbatim(node, ::LibXML::XML::Node.new_comment(content.to_s))
763
806
  end
764
807
 
765
808
  def processing_instruction_target(node)
@@ -768,22 +811,18 @@ module Moxml
768
811
  end
769
812
 
770
813
  def processing_instruction_content(node)
771
- native_node = unpatch_node(node)
772
- content = native_node&.content
773
- # LibXML may HTML-escape the content, un-escape it
774
- return nil unless content
775
-
776
- content.gsub("&quot;", '"')
777
- .gsub("&apos;", "'")
778
- .gsub("&lt;", "<")
779
- .gsub("&gt;", ">")
780
- .gsub("&amp;", "&")
814
+ # XML 1.0 §2.6: PI content is verbatim — no entity resolution.
815
+ unpatch_node(node)&.content
781
816
  end
782
817
 
783
818
  def set_processing_instruction_content(node, content)
784
819
  native_node = unpatch_node(node)
785
- # Store raw content - LibXML will escape it
786
- native_node.content = content.to_s if native_node
820
+ return unless native_node.is_a?(::LibXML::XML::Node)
821
+
822
+ replace_native_verbatim(
823
+ node,
824
+ ::LibXML::XML::Node.new_pi(native_node.name, content.to_s),
825
+ )
787
826
  end
788
827
 
789
828
  def create_native_namespace(element, prefix, uri)
@@ -804,9 +843,10 @@ module Moxml
804
843
 
805
844
  def set_namespace(element, ns)
806
845
  native_elem = unpatch_node(element)
807
- return unless native_elem && ns
846
+ return element unless native_elem
808
847
 
809
- native_elem.namespaces.namespace = ns
848
+ native_elem.namespaces.namespace = ns if ns
849
+ element
810
850
  end
811
851
 
812
852
  def namespace(element)
@@ -867,14 +907,18 @@ module Moxml
867
907
  # LibXML requires ALL prefixes in the XPath to be registered
868
908
  ns_context = build_xpath_namespaces(native_node, namespaces)
869
909
 
870
- results = if ns_context.empty?
871
- native_node.find(expression).to_a
872
- else
873
- native_node.find(expression, ns_context).to_a
874
- end
910
+ found = if ns_context.empty?
911
+ native_node.find(expression)
912
+ else
913
+ native_node.find(expression, ns_context)
914
+ end
915
+
916
+ # find returns an XPath::Object for node results, scalars for
917
+ # count()/string()/boolean functions (adapter contract)
918
+ return found if found.is_a?(Float) || found.is_a?(String) ||
919
+ [true, false].include?(found)
875
920
 
876
- # Wrap results
877
- results.map { |n| patch_node(n) }
921
+ found.to_a.map { |n| patch_node(n) }
878
922
  rescue ::LibXML::XML::Error => e
879
923
  raise Moxml::XPathError.new(
880
924
  e.message,
@@ -987,7 +1031,7 @@ module Moxml
987
1031
  end
988
1032
 
989
1033
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
990
- # but NOT its descendants. This is what DocumentBuilder needs — it
1034
+ # but NOT its descendants (deep duplication composes this).
991
1035
  # walks the source tree and re-adds children one at a time via
992
1036
  # add_child, so a deep copy here would be done only to be stripped
993
1037
  # by replace_children, then rebuilt — O(N²) waste on parse.
@@ -1013,7 +1057,8 @@ module Moxml
1013
1057
  node
1014
1058
  end
1015
1059
  when :element
1016
- shallow_duplicate_element(native_node)
1060
+ # Public dup contract: a full deep copy of the subtree
1061
+ deep_duplicate_node(native_node)
1017
1062
  when :text
1018
1063
  ::LibXML::XML::Node.new_text(native_node.content)
1019
1064
  when :cdata
@@ -1058,15 +1103,6 @@ module Moxml
1058
1103
  end
1059
1104
  end
1060
1105
 
1061
- def prepare_for_new_document(node, target_doc)
1062
- return node unless node && target_doc
1063
-
1064
- # For LibXML, we need to duplicate ALL nodes to avoid
1065
- # document ownership issues. Simply importing doesn't work
1066
- # because nodes from the parsed document still have references.
1067
- duplicate_node(node)
1068
- end
1069
-
1070
1106
  def has_declaration?(native_doc, wrapper)
1071
1107
  decl = attachments.get(native_doc, :declaration)
1072
1108
  if decl
@@ -1109,9 +1145,9 @@ module Moxml
1109
1145
 
1110
1146
  seen_ns[prefix] = true
1111
1147
  output << if prefix.nil? || prefix.empty?
1112
- " xmlns=\"#{escape_xml(uri)}\""
1148
+ " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
1113
1149
  else
1114
- " xmlns:#{prefix}=\"#{escape_xml(uri)}\""
1150
+ " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
1115
1151
  end
1116
1152
  end
1117
1153
  end
@@ -1127,7 +1163,7 @@ module Moxml
1127
1163
  else
1128
1164
  attr.name
1129
1165
  end
1130
- output << " #{attr_name}=\"#{escape_xml(attr.value)}\""
1166
+ output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
1131
1167
  end
1132
1168
  end
1133
1169
 
@@ -1167,7 +1203,7 @@ module Moxml
1167
1203
  when ::LibXML::XML::Node::ELEMENT_NODE
1168
1204
  serialize_element(node)
1169
1205
  when ::LibXML::XML::Node::TEXT_NODE
1170
- escape_text(node.content)
1206
+ XmlEmitter.escape_text(node.content)
1171
1207
  when ::LibXML::XML::Node::CDATA_SECTION_NODE
1172
1208
  "<![CDATA[#{node.content}]]>"
1173
1209
  when ::LibXML::XML::Node::COMMENT_NODE
@@ -1179,28 +1215,6 @@ module Moxml
1179
1215
  end
1180
1216
  end
1181
1217
 
1182
- def escape_text(text)
1183
- text.to_s
1184
- .gsub("&", "&amp;")
1185
- .gsub("<", "&lt;")
1186
- .gsub(">", "&gt;")
1187
- end
1188
-
1189
- ESCAPE_XML_RE = /[&<>"]/
1190
- ESCAPE_XML_MAP = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
1191
- '"' => "&quot;" }.freeze
1192
- private_constant :ESCAPE_XML_RE, :ESCAPE_XML_MAP
1193
-
1194
- def escape_xml(text)
1195
- # One gsub pass with a Hash replacement allocates a single new
1196
- # string. The previous chained gsubs allocated three throwaway
1197
- # strings on every call (very hot for attribute-heavy XML).
1198
- str = text.is_a?(String) ? text : text.to_s
1199
- return str unless str.match?(ESCAPE_XML_RE)
1200
-
1201
- str.gsub(ESCAPE_XML_RE, ESCAPE_XML_MAP)
1202
- end
1203
-
1204
1218
  def import_and_add(doc, element, child)
1205
1219
  return unless element && child
1206
1220
 
@@ -1357,9 +1371,9 @@ module Moxml
1357
1371
 
1358
1372
  def format_ns_declaration(prefix, uri)
1359
1373
  if prefix.nil? || prefix.empty?
1360
- " xmlns=\"#{escape_xml(uri)}\""
1374
+ " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
1361
1375
  else
1362
- " xmlns:#{prefix}=\"#{escape_xml(uri)}\""
1376
+ " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
1363
1377
  end
1364
1378
  end
1365
1379
 
@@ -1370,7 +1384,7 @@ module Moxml
1370
1384
  next if attr.name.start_with?("xmlns")
1371
1385
 
1372
1386
  attr_name = attr.ns&.prefix ? "#{attr.ns.prefix}:#{attr.name}" : attr.name
1373
- output << " #{attr_name}=\"#{escape_xml(attr.value)}\""
1387
+ output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
1374
1388
  end
1375
1389
  end
1376
1390
 
@@ -1510,11 +1524,6 @@ module Moxml
1510
1524
  end
1511
1525
  end
1512
1526
 
1513
- def remove_indentation(xml_string)
1514
- # Remove all newlines and extra spaces between tags
1515
- xml_string.gsub(/>\s+</, "><").gsub(/\n\s*/, "")
1516
- end
1517
-
1518
1527
  def collect_namespace_definitions(node)
1519
1528
  ns_defs = {}
1520
1529
 
@@ -1693,13 +1702,24 @@ module Moxml
1693
1702
  @handler.on_end_document
1694
1703
  end
1695
1704
 
1696
- def on_start_element(name, attributes)
1697
- attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
1698
- @handler.on_start_element(name.to_s, attr_hash, ns_hash)
1705
+ # libxml strips prefixes and namespace declarations from the
1706
+ # plain on_start_element callback; the _ns variants carry
1707
+ # them. The namespaces hash uses nil for the default
1708
+ # declaration — the same key convention as every other
1709
+ # bridge's split. Attribute name prefixes are lost by the
1710
+ # binding in both callbacks (documented limitation).
1711
+ def on_start_element_ns(name, attributes, prefix, _uri, namespaces)
1712
+ normalized = (attributes || {}).map { |k, v| [k.to_s, v] }
1713
+ attr_hash, = split_attributes_and_namespaces(normalized) do |v|
1714
+ Moxml::Adapter::Base.decode_entities(v)
1715
+ end
1716
+ qualified = prefix ? "#{prefix}:#{name}" : name.to_s
1717
+ @handler.on_start_element(qualified, attr_hash, namespaces || {})
1699
1718
  end
1700
1719
 
1701
- def on_end_element(name)
1702
- @handler.on_end_element(name.to_s)
1720
+ def on_end_element_ns(name, prefix, _uri)
1721
+ qualified = prefix ? "#{prefix}:#{name}" : name.to_s
1722
+ @handler.on_end_element(qualified)
1703
1723
  end
1704
1724
 
1705
1725
  def on_characters(chars)
@@ -144,6 +144,7 @@ module Moxml
144
144
 
145
145
  def set_namespace(element, ns)
146
146
  element.namespace = ns
147
+ element
147
148
  end
148
149
 
149
150
  def namespace(element)
@@ -216,21 +217,36 @@ module Moxml
216
217
  document.is_a?(::Nokogiri::XML::Document) ? document.root : document.children.first
217
218
  end
218
219
 
219
- def attribute_element(attr)
220
- attr.parent
220
+ def line_number(node)
221
+ node.line
221
222
  end
222
223
 
223
- def attributes(element)
224
- element.attributes.values
224
+ def attribute_element(attr)
225
+ attr.parent
225
226
  end
226
227
 
227
228
  def set_attribute(element, name, value)
228
229
  element[name.to_s] = value.to_s
229
230
  end
230
231
 
232
+ def attributes(element)
233
+ # attribute_nodes, not attributes.values: the attributes Hash
234
+ # is keyed by local name, so xmi:type and type collide and
235
+ # one is silently dropped (issue #94).
236
+ element.attribute_nodes
237
+ end
238
+
231
239
  def get_attribute(element, name)
232
- # attributes keys don't include attribute namespaces
233
- element.attributes[name.to_s]
240
+ name_s = name.to_s
241
+ if name_s.include?(":")
242
+ prefix, local = name_s.split(":", 2)
243
+ href = element.namespace_definitions
244
+ .find { |ns| ns.prefix == prefix }&.href
245
+ element.attribute_with_ns(local, href)
246
+ else
247
+ element.attribute_with_ns(name_s, nil) ||
248
+ element.attributes[name_s]
249
+ end
234
250
  end
235
251
 
236
252
  def get_attribute_value(element, name)
@@ -360,7 +376,10 @@ module Moxml
360
376
  end
361
377
 
362
378
  def xpath(node, expression, namespaces = nil)
363
- node.xpath(expression, namespaces).to_a
379
+ result = node.xpath(expression, namespaces)
380
+ # Adapter contract: Array<native> | scalar (count(),
381
+ # string-length(), boolean functions return scalars).
382
+ result.is_a?(Enumerable) ? result.to_a : result
364
383
  rescue ::Nokogiri::XML::XPath::SyntaxError => e
365
384
  raise Moxml::XPathError.new(
366
385
  e.message,
@@ -471,7 +490,9 @@ module Moxml
471
490
  end
472
491
 
473
492
  def start_element(name, attributes = [])
474
- attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
493
+ attr_hash, ns_hash = split_attributes_and_namespaces(attributes) do |v|
494
+ Moxml::Adapter::Base.decode_entities(v)
495
+ end
475
496
  @handler.on_start_element(name, attr_hash, ns_hash)
476
497
  end
477
498