moxml 0.5.148 → 0.5.150

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/nightly-fuzz.yml +80 -0
  5. data/.github/workflows/performance-release.yml +79 -0
  6. data/.github/workflows/performance.yml +59 -0
  7. data/Rakefile +23 -0
  8. data/benchmark/README.md +106 -0
  9. data/benchmark/scoreboard.json +115 -0
  10. data/benchmark/scoreboard.rb +556 -0
  11. data/bin/generate-model-coverage +21 -0
  12. data/docs/_pages/adapter-compatibility.adoc +3 -3
  13. data/docs/_pages/divergences.adoc +88 -39
  14. data/docs/_pages/ecosystem.adoc +62 -0
  15. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  16. data/docs/_pages/headed-ox.adoc +2 -2
  17. data/docs/_pages/index.adoc +4 -0
  18. data/docs/_pages/model-coverage.adoc +57 -0
  19. data/docs/_pages/performance.adoc +29 -0
  20. data/docs/_pages/why-moxml.adoc +87 -0
  21. data/docs/_references/processing-model.adoc +69 -3
  22. data/lib/compat/opal/moxml_boot.rb +4 -0
  23. data/lib/moxml/adapter/base.rb +73 -0
  24. data/lib/moxml/adapter/leptris.rb +293 -23
  25. data/lib/moxml/adapter/libxml.rb +179 -25
  26. data/lib/moxml/adapter/nokogiri.rb +10 -0
  27. data/lib/moxml/adapter/oga.rb +71 -9
  28. data/lib/moxml/adapter/ox.rb +34 -2
  29. data/lib/moxml/adapter/rexml.rb +82 -17
  30. data/lib/moxml/c14n/data_model.rb +8 -0
  31. data/lib/moxml/c14n/processor.rb +17 -8
  32. data/lib/moxml/context.rb +33 -1
  33. data/lib/moxml/document.rb +18 -4
  34. data/lib/moxml/node.rb +75 -8
  35. data/lib/moxml/node_set.rb +42 -1
  36. data/lib/moxml/reference/normalizer.rb +63 -0
  37. data/lib/moxml/reference/parser.rb +417 -0
  38. data/lib/moxml/reference/writer.rb +121 -0
  39. data/lib/moxml/reference.rb +205 -0
  40. data/lib/moxml/relaxng.rb +62 -0
  41. data/lib/moxml/sax/block_handler.rb +7 -0
  42. data/lib/moxml/sax/handler.rb +20 -0
  43. data/lib/moxml/version.rb +1 -1
  44. data/lib/moxml/xpath/compiler.rb +17 -2
  45. data/lib/moxml/xpath/ordering.rb +97 -0
  46. data/lib/moxml/xpath.rb +29 -0
  47. data/lib/moxml.rb +2 -0
  48. data/spec/consistency/adapter_parity_spec.rb +24 -0
  49. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  50. data/spec/consistency/differential_taxonomy_spec.rb +117 -0
  51. data/spec/consistency/divergence_register_spec.rb +9 -1
  52. data/spec/consistency/eol_normalization_spec.rb +2 -0
  53. data/spec/consistency/loud_degradation_spec.rb +2 -0
  54. data/spec/consistency/metamorphic_properties_spec.rb +195 -0
  55. data/spec/consistency/metamorphic_spec.rb +138 -0
  56. data/spec/consistency/model_coverage_audit_spec.rb +144 -61
  57. data/spec/consistency/published_docs_spec.rb +49 -0
  58. data/spec/consistency/reference_differential_spec.rb +186 -0
  59. data/spec/consistency/xpath_idioms_spec.rb +34 -5
  60. data/spec/consistency/xslt_spec.rb +2 -0
  61. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  62. data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
  63. data/spec/moxml/adapter/leptris_spec.rb +23 -3
  64. data/spec/moxml/adapter/ox_spec.rb +1 -1
  65. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  66. data/spec/moxml/attribute_resolver_spec.rb +35 -0
  67. data/spec/moxml/c14n/adapter_delegation_spec.rb +35 -6
  68. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  69. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  70. data/spec/moxml/c14n/subset_spec.rb +2 -12
  71. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  72. data/spec/moxml/doctype_spec.rb +7 -0
  73. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  74. data/spec/moxml/element_indent_spec.rb +34 -0
  75. data/spec/moxml/entity_spec.rb +2 -0
  76. data/spec/moxml/node_spec.rb +0 -3
  77. data/spec/moxml/plan_conformance_spec.rb +2 -0
  78. data/spec/moxml/processing_instruction_spec.rb +9 -0
  79. data/spec/moxml/reference_opal_spec.rb +29 -0
  80. data/spec/moxml/reference_spec.rb +162 -0
  81. data/spec/moxml/relax_ng_spec.rb +67 -0
  82. data/spec/moxml/sax_spec.rb +47 -74
  83. data/spec/moxml/text_spec.rb +2 -0
  84. data/spec/moxml/to_html_spec.rb +2 -0
  85. data/spec/moxml/xhtml_output_spec.rb +2 -0
  86. data/spec/moxml/xpath/axes_spec.rb +2 -2
  87. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  88. data/spec/moxml/xsd_spec.rb +38 -0
  89. data/spec/support/input_generator.rb +154 -0
  90. data/spec/support/input_taxonomy.rb +219 -0
  91. data/spec/support/model_coverage.rb +159 -0
  92. data/spec/support/xml_generator.rb +54 -0
  93. metadata +32 -2
@@ -7,23 +7,26 @@ require "libxml-ruby"
7
7
  module Moxml
8
8
  module Adapter
9
9
  class Libxml < Base
10
- # DOCTYPE prolog sniffer; see parse for the index-then-match
11
- # strategy that keeps this off the hot path.
12
- DOCTYPE_RE = /<!DOCTYPE\s+([^\s>]+)(?:\s+PUBLIC\s+"([^"]+)"\s+"([^"]+)"|\s+SYSTEM\s+"([^"]+)")?\s*>/i
13
- private_constant :DOCTYPE_RE
10
+ # DOCTYPE prolog scanning is manual (index/char scan): a regex
11
+ # over hostile prolog text backtracks polynomially on inputs
12
+ # with repeated <!DOCTYPE prefixes or unterminated subsets
13
+ # (CodeQL rb/polynomial-redos). See doctype_parts.
14
+ DOCTYPE_OPEN_SIZE = 9
15
+ QUOTES = ['"', "'"].freeze
14
16
 
15
17
  autoload :EntityRefRegistry, "moxml/adapter/libxml/entity_ref_registry"
16
18
 
17
19
  # Wrapper class to store DOCTYPE information
18
20
  class DoctypeWrapper
19
21
  attr_reader :native_doc
20
- attr_accessor :name, :external_id, :system_id
22
+ attr_accessor :name, :external_id, :system_id, :internal_subset
21
23
 
22
- def initialize(doc, name, external_id, system_id)
24
+ def initialize(doc, name, external_id, system_id, internal_subset = nil)
23
25
  @native_doc = doc
24
26
  @name = name
25
27
  @external_id = external_id
26
28
  @system_id = system_id
29
+ @internal_subset = internal_subset
27
30
  end
28
31
 
29
32
  # Provide native method to match adapter pattern
@@ -32,7 +35,8 @@ module Moxml
32
35
  end
33
36
 
34
37
  def to_xml
35
- XmlEmitter.doctype_xml(@name, @external_id, @system_id)
38
+ XmlEmitter.doctype_xml(@name, @external_id, @system_id,
39
+ @internal_subset)
36
40
  end
37
41
  end
38
42
 
@@ -83,6 +87,108 @@ module Moxml
83
87
  false
84
88
  end
85
89
 
90
+ def add_sibling_returns_inserted?
91
+ true
92
+ end
93
+
94
+ # Linear DOCTYPE prolog scanner. Returns
95
+ # {name:, external_id:, system_id:, internal_subset:} or nil.
96
+ # Single pass, quote- and bracket-aware: no backtracking, so
97
+ # hostile prolog text cannot make parsing superlinear.
98
+ def doctype_parts(source, from = 0)
99
+ start = source.index("<!DOCTYPE", from)
100
+ return unless start
101
+
102
+ pos = skip_doctype_space(source, start + DOCTYPE_OPEN_SIZE)
103
+ name_start = pos
104
+ pos += 1 while pos < source.length &&
105
+ !" \t\r\n>".include?(source[pos])
106
+ name = source[name_start...pos]
107
+ return if name.empty?
108
+
109
+ pos = skip_doctype_space(source, pos)
110
+ external_id = nil
111
+ system_id = nil
112
+ keyword = source[pos, 6].to_s.upcase
113
+ if keyword == "PUBLIC"
114
+ pos = skip_doctype_space(source, pos + 6)
115
+ pair = scan_doctype_quoted(source, pos)
116
+ return unless pair
117
+
118
+ external_id, pos = pair
119
+ pos = skip_doctype_space(source, pos)
120
+ pair = scan_doctype_quoted(source, pos)
121
+ return unless pair
122
+
123
+ system_id, pos = pair
124
+ elsif keyword == "SYSTEM"
125
+ pos = skip_doctype_space(source, pos + 6)
126
+ pair = scan_doctype_quoted(source, pos)
127
+ return unless pair
128
+
129
+ system_id, pos = pair
130
+ end
131
+
132
+ pos = skip_doctype_space(source, pos)
133
+ internal_subset = nil
134
+ if source[pos] == "["
135
+ subset_start = pos + 1
136
+ pos = matching_subset_end(source, pos + 1)
137
+ return unless pos
138
+
139
+ internal_subset = source[subset_start...pos]
140
+ pos += 1
141
+ end
142
+
143
+ pos = skip_doctype_space(source, pos)
144
+ return unless source[pos] == ">"
145
+
146
+ {
147
+ name: name,
148
+ external_id: external_id,
149
+ system_id: system_id,
150
+ internal_subset: internal_subset,
151
+ }
152
+ end
153
+
154
+ def skip_doctype_space(source, pos)
155
+ pos += 1 while pos < source.length &&
156
+ " \t\r\n".include?(source[pos])
157
+ pos
158
+ end
159
+
160
+ def scan_doctype_quoted(source, pos)
161
+ quote = source[pos]
162
+ return unless QUOTES.include?(quote)
163
+
164
+ stop = source.index(quote, pos + 1)
165
+ return unless stop
166
+
167
+ [source[(pos + 1)...stop], stop + 1]
168
+ end
169
+
170
+ # Index just past the closing bracket at nesting depth zero,
171
+ # or nil when the subset is unterminated.
172
+ def matching_subset_end(source, pos)
173
+ depth = 1
174
+ quote = nil
175
+ while pos < source.length
176
+ char = source[pos]
177
+ if quote
178
+ quote = nil if char == quote
179
+ elsif QUOTES.include?(char)
180
+ quote = char
181
+ elsif char == "["
182
+ depth += 1
183
+ elsif char == "]"
184
+ depth -= 1
185
+ return pos if depth.zero?
186
+ end
187
+ pos += 1
188
+ end
189
+ nil
190
+ end
191
+
86
192
  def parse(xml, options = {}, _context = nil)
87
193
  # LibXML doesn't preserve DOCTYPE during parsing, so we need to extract it manually
88
194
  xml_string = if xml.is_a?(String)
@@ -99,19 +205,24 @@ module Moxml
99
205
  xml_string = preprocess_entities(xml_string)
100
206
 
101
207
  # Extract DOCTYPE before parsing. Locate the literal first:
102
- # a plain memscan is ~2x cheaper than the regex's literal
103
- # prefix scan on doctype-less documents (the common case),
104
- # and matching from the found offset keeps captures
105
- # identical.
208
+ # a plain memscan keeps doctype-less documents (the common
209
+ # case) off the scanner path; the scan itself is linear.
106
210
  doctype_idx = xml_string.index("<!DOCTYPE")
107
- doctype_match = if doctype_idx
108
- xml_string.match(DOCTYPE_RE, doctype_idx)
109
- end
211
+ doctype = doctype_idx && doctype_parts(xml_string, doctype_idx)
110
212
 
111
213
  native_doc = begin
112
214
  # Handle both string and file inputs
113
215
  parser = ::LibXML::XML::Parser.string(xml_string)
114
216
  parser.parse
217
+ rescue ::ArgumentError => e
218
+ # Invalid byte sequences raise ArgumentError from Ruby's
219
+ # encoding checks before libxml sees the bytes; the
220
+ # facade names them (taxonomy differential, malformed
221
+ # class).
222
+ raise Moxml::ParseError.new(
223
+ e.message,
224
+ source: xml.is_a?(String) ? xml[0..100] : nil,
225
+ )
115
226
  rescue ::LibXML::XML::Error => e
116
227
  if options[:strict]
117
228
  line = e.line
@@ -127,16 +238,17 @@ module Moxml
127
238
  end
128
239
 
129
240
  # Store DOCTYPE if found
130
- if doctype_match
131
- name = doctype_match[1]
132
- external_id = doctype_match[2]
133
- system_id = doctype_match[3] || doctype_match[4]
241
+ if doctype
242
+ name = doctype[:name]
243
+ external_id = doctype[:external_id]
244
+ system_id = doctype[:system_id]
134
245
 
135
246
  doctype_wrapper = DoctypeWrapper.new(
136
247
  native_doc,
137
248
  name,
138
249
  external_id,
139
250
  system_id,
251
+ doctype[:internal_subset],
140
252
  )
141
253
  attachments.set(native_doc, :doctype, doctype_wrapper)
142
254
  end
@@ -672,7 +784,8 @@ module Moxml
672
784
  return unless node && sibling
673
785
 
674
786
  native_node = unpatch_node(node)
675
- native_sibling = unpatch_node(sibling)
787
+ native_sibling = import_for_attachment(native_node,
788
+ unpatch_node(sibling))
676
789
 
677
790
  # Special handling for document-level processing instructions
678
791
  # When adding a PI as sibling to root element, store it on document
@@ -686,14 +799,35 @@ module Moxml
686
799
  end
687
800
 
688
801
  native_node.prev = native_sibling
802
+ native_sibling
689
803
  end
690
804
 
691
805
  def add_next_sibling(node, sibling)
692
806
  return unless node && sibling
693
807
 
694
808
  native_node = unpatch_node(node)
695
- native_sibling = unpatch_node(sibling)
809
+ native_sibling = import_for_attachment(native_node,
810
+ unpatch_node(sibling))
696
811
  native_node.next = native_sibling
812
+ native_sibling
813
+ end
814
+
815
+ # A markup fragment parsed by the facade lives in its own
816
+ # document; libxml refuses cross-document attachment. Import
817
+ # (deep-copy) into the target document and hand back the copy —
818
+ # the caller refreshes its wrapper to this native.
819
+ def import_for_attachment(native_node, native_sibling)
820
+ return native_sibling unless
821
+ native_node.is_a?(::LibXML::XML::Node) &&
822
+ native_sibling.is_a?(::LibXML::XML::Node)
823
+
824
+ target = native_node.doc
825
+ return native_sibling if target.nil?
826
+
827
+ source = native_sibling.doc
828
+ return native_sibling if source.equal?(target)
829
+
830
+ target.import(native_sibling)
697
831
  end
698
832
 
699
833
  def remove(node)
@@ -855,7 +989,8 @@ module Moxml
855
989
 
856
990
  def processing_instruction_content(node)
857
991
  # XML 1.0 §2.6: PI content is verbatim — no entity resolution.
858
- unpatch_node(node)&.content
992
+ # Empty data is the empty string, never nil (uniform Kind face).
993
+ unpatch_node(node)&.content.to_s
859
994
  end
860
995
 
861
996
  def set_processing_instruction_content(node, content)
@@ -936,12 +1071,12 @@ module Moxml
936
1071
 
937
1072
  # libxml-ruby 5.x exposes no external/system ID accessor on
938
1073
  # the DTD node — the DOCTYPE source text is the only source,
939
- # so re-derive from it (same DOCTYPE_RE as the parse path).
1074
+ # so re-derive from it (same linear scanner as the parse path).
940
1075
  def doctype_external_id(native)
941
1076
  if native.is_a?(DoctypeWrapper)
942
1077
  native.external_id
943
1078
  else
944
- native.to_s[DOCTYPE_RE, 2]
1079
+ Libxml.doctype_parts(native.to_s)&.dig(:external_id)
945
1080
  end
946
1081
  end
947
1082
 
@@ -949,7 +1084,7 @@ module Moxml
949
1084
  if native.is_a?(DoctypeWrapper)
950
1085
  native.system_id
951
1086
  else
952
- native.to_s[DOCTYPE_RE, 3] || native.to_s[DOCTYPE_RE, 4]
1087
+ Libxml.doctype_parts(native.to_s)&.dig(:system_id)
953
1088
  end
954
1089
  end
955
1090
 
@@ -957,13 +1092,21 @@ module Moxml
957
1092
  native_node = unpatch_node(node)
958
1093
  return [] unless native_node
959
1094
 
1095
+ if empty_default_namespace_binding?(namespaces)
1096
+ return Moxml::XPath.evaluate_natives(native_node, expression,
1097
+ namespaces,
1098
+ adapter: :libxml)
1099
+ end
1100
+
960
1101
  # libxml-ruby's Document#find returns EMPTY for relative
961
1102
  # paths (nokogiri/libxml2 evaluate them fine): a relative
962
1103
  # expression from the document node is equivalent to the
963
1104
  # absolute form — the document's only name-testable child
964
1105
  # is the root — so rewrite instead of losing the match.
1106
+ # Function calls and unions are not name-test paths: a
1107
+ # "/count(...)" rewrite is invalid XPath (scalar arm).
965
1108
  if native_node.is_a?(::LibXML::XML::Document) &&
966
- expression.match?(/\A[A-Za-z_*@]/)
1109
+ relative_name_path?(expression)
967
1110
  expression = "/#{expression}"
968
1111
  end
969
1112
 
@@ -997,6 +1140,16 @@ module Moxml
997
1140
  results&.first
998
1141
  end
999
1142
 
1143
+ # A relative name-test path (the only shape the document-context
1144
+ # rewrite is for): a name/`*`/`@` start, no function call (the
1145
+ # scalar arm), no union.
1146
+ def relative_name_path?(expression)
1147
+ return false unless expression.match?(/\A[A-Za-z_*@]/)
1148
+ return false if expression.match?(/\A[A-Za-z][\w.-]*\s*\(/)
1149
+
1150
+ !expression.include?("|")
1151
+ end
1152
+
1000
1153
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
1001
1154
  # but NOT its descendants (deep duplication composes this).
1002
1155
  # walks the source tree and re-adds children one at a time via
@@ -1019,6 +1172,7 @@ module Moxml
1019
1172
  node.name,
1020
1173
  node.external_id,
1021
1174
  node.system_id,
1175
+ node.internal_subset,
1022
1176
  )
1023
1177
  else
1024
1178
  node
@@ -58,6 +58,10 @@ module Moxml
58
58
  true
59
59
  end
60
60
 
61
+ def element_indent_supported?
62
+ true
63
+ end
64
+
61
65
  # --- XSLT (Moxml::XSLT contract, libxslt-backed) ---
62
66
 
63
67
  def xslt_compile(sheet_source)
@@ -646,6 +650,12 @@ module Moxml
646
650
  end
647
651
 
648
652
  def xpath(node, expression, namespaces = nil)
653
+ if empty_default_namespace_binding?(namespaces)
654
+ return Moxml::XPath.evaluate_natives(node, expression,
655
+ namespaces,
656
+ adapter: :nokogiri)
657
+ end
658
+
649
659
  # An explicit bindings hash — even EMPTY — makes nokogiri
650
660
  # register a prefix context, which disables libxml2's
651
661
  # literal xmlns: default-namespace handling. Only pass the
@@ -28,8 +28,23 @@ module Moxml
28
28
  doc.children << element
29
29
  end
30
30
 
31
+ # The UTF-8 BOM is an encoding signature, not content (model,
32
+ # Parsing); Oga otherwise leaves it in the tree as a
33
+ # document-level text node.
34
+ def strip_bom(xml)
35
+ return xml unless xml.is_a?(String)
36
+
37
+ if defined?(Encoding) && xml.encoding == Encoding::BINARY
38
+ xml.byteslice(0, 3) == "\xEF\xBB\xBF".b ? xml.byteslice(3..) : xml
39
+ elsif xml.start_with?("")
40
+ xml[1..]
41
+ else
42
+ xml
43
+ end
44
+ end
45
+
31
46
  def parse(xml, options = {}, _context = nil)
32
- processed_xml = preprocess_entities(xml)
47
+ processed_xml = strip_bom(preprocess_entities(xml))
33
48
 
34
49
  native_doc = begin
35
50
  ::Oga.parse_xml(processed_xml, strict: options[:strict])
@@ -40,6 +55,18 @@ module Moxml
40
55
  )
41
56
  end
42
57
 
58
+ # Rootless parses (empty or declaration-only input) are
59
+ # malformed: XML requires exactly one root. Oga returns a
60
+ # bare document where nokogiri/leptris raise — the facade
61
+ # names it instead (loudness differential, TODO.sota/01).
62
+ if options[:strict] && native_doc.respond_to?(:root_node) &&
63
+ native_doc.children.none?(::Oga::XML::Element)
64
+ raise Moxml::ParseError.new(
65
+ "document has no root element",
66
+ source: xml.is_a?(String) ? xml[0..100] : nil,
67
+ )
68
+ end
69
+
43
70
  # Oga parses multiple root elements leniently; the moxml
44
71
  # contract requires a single root. to_a because the vendored
45
72
  # fork's NodeSet#count ignores its block.
@@ -191,7 +218,15 @@ module Moxml
191
218
 
192
219
  def namespace(element)
193
220
  case element
194
- when ::Oga::XML::Element, ::Oga::XML::Attribute
221
+ when ::Oga::XML::Attribute
222
+ # Unprefixed attributes are in no namespace, always
223
+ # (Namespaces 1.0 §5.2). Oga resolves them against the
224
+ # element's default namespace, which is wrong and makes
225
+ # bare-name reads miss under a default namespace.
226
+ return nil if element.namespace_name.nil?
227
+
228
+ element.namespace
229
+ when ::Oga::XML::Element
195
230
  element.namespace
196
231
  end
197
232
  rescue NoMethodError
@@ -235,10 +270,10 @@ module Moxml
235
270
  def children(node, **_entity_bearing)
236
271
  all_children = []
237
272
 
238
- if node.is_a?(::Oga::XML::Document)
239
- all_children += [node.xml_declaration,
240
- node.doctype].compact
241
- end
273
+ # The XML declaration is document state, not a child node
274
+ # (model, Object model): it never appears in children listings.
275
+ # The doctype follows the Nokogiri contract and is listed.
276
+ all_children << node.doctype if node.is_a?(::Oga::XML::Document) && node.doctype
242
277
 
243
278
  return all_children unless node.is_a?(::Oga::XML::Node) || node.is_a?(::Oga::XML::Document)
244
279
 
@@ -499,7 +534,13 @@ module Moxml
499
534
  end
500
535
 
501
536
  def processing_instruction_content(node)
502
- node.text
537
+ # Oga keeps the target/content separator space inside the
538
+ # PI's text; the model's content is the data AFTER the
539
+ # separator. Separator whitespace is parser syntax, and
540
+ # leaving it in makes canonicalization non-idempotent (the
541
+ # serializer re-adds the separator each round — TODO.sota/02,
542
+ # M2).
543
+ node.text.to_s.sub(/\A\s+/, "")
503
544
  end
504
545
 
505
546
  def set_processing_instruction_content(node, content)
@@ -547,10 +588,31 @@ module Moxml
547
588
  end
548
589
 
549
590
  def xpath(node, expression, namespaces = nil)
591
+ # oga's engine has no xmlns: default-namespace idiom
592
+ # (D-01); the reference engine implements it (model,
593
+ # Names and namespaces).
594
+ if expression.include?("xmlns:")
595
+ return Moxml::XPath.evaluate_natives(
596
+ node, expression, namespaces || {}, adapter: :oga
597
+ )
598
+ end
599
+
550
600
  result = node.xpath(expression, {},
551
601
  namespaces: namespaces&.transform_keys(&:to_s))
552
- # Adapter contract: Array<native> | scalar
553
- result.is_a?(Enumerable) ? result.to_a : result
602
+ # Adapter contract: Array<native> | scalar. Oga's engine
603
+ # answers node-sets in evaluation order, not document order
604
+ # (//* on <r><a><b/></a><c/></r> answers a,c,b); path results
605
+ # are document-ordered by the model — re-order here
606
+ # (TODO.sota/02, M3).
607
+ if result.is_a?(Enumerable)
608
+ nodes = result.to_a
609
+ ctx = Context.new(:oga)
610
+ wrapped = nodes.map { |n| Moxml::Node.wrap(n, ctx) }
611
+ ordered = Moxml::XPath::Ordering.document_order(wrapped)
612
+ ordered.map(&:native)
613
+ else
614
+ result
615
+ end
554
616
  rescue ::LL::ParserError => e
555
617
  raise Moxml::XPathError.new(
556
618
  e.message,
@@ -60,13 +60,28 @@ module Moxml
60
60
  assign_parents(doc)
61
61
  doc
62
62
  end
63
- rescue ::Ox::ParseError => e
63
+ rescue ::Ox::ParseError, ::RuntimeError => e
64
+ # Ox leaks raw RuntimeErrors from some malformed shapes
65
+ # (e.g. declaration-only input reaches Element#<<); the
66
+ # facade names every parse-time engine failure.
64
67
  raise Moxml::ParseError.new(
65
68
  e.message,
66
69
  source: xml.is_a?(String) ? xml[0..100] : nil,
67
70
  )
68
71
  end
69
72
 
73
+ # Rootless parses (empty or declaration-only input) are
74
+ # malformed: XML requires exactly one root. Ox answers a
75
+ # bare document — the facade names it (uniform with the
76
+ # rexml/oga arms; loudness differential, TODO.sota/01).
77
+ if options[:strict] && native_doc.is_a?(::Ox::Document) &&
78
+ native_doc.nodes.none?(::Ox::Element)
79
+ raise Moxml::ParseError.new(
80
+ "document has no root element",
81
+ source: xml.is_a?(String) ? xml[0..100] : nil,
82
+ )
83
+ end
84
+
70
85
  ctx = _context || Context.new(context_adapter_name)
71
86
  Wrappers::Document.new(native_doc, ctx)
72
87
  end
@@ -93,6 +108,14 @@ module Moxml
93
108
  end
94
109
  end
95
110
 
111
+ # Ox's SAX parser reports text only: CDATA, comments and PIs
112
+ # are folded into text or dropped (D-10). Subscribing to those
113
+ # families raises the named error instead of silently answering
114
+ # an incomplete stream.
115
+ def sax_event_family_supported?(family)
116
+ !%i[cdata comment processing_instruction].include?(family)
117
+ end
118
+
96
119
  def create_document(native_doc = nil)
97
120
  attrs = native_doc&.attributes || {}
98
121
  ::Ox::Document.new(**attrs)
@@ -627,7 +650,11 @@ module Moxml
627
650
  end
628
651
 
629
652
  def processing_instruction_content(node)
630
- node.content.to_s
653
+ # Ox keeps the target/content separator space inside the
654
+ # PI content; the model's content is the data AFTER the
655
+ # separator. Leaving it in makes canonicalization
656
+ # non-idempotent (TODO.sota/02, M2).
657
+ node.content.to_s.sub(/\A\s+/, "")
631
658
  end
632
659
 
633
660
  def set_processing_instruction_content(node, content)
@@ -708,6 +735,11 @@ module Moxml
708
735
  end)
709
736
  when NodeSet
710
737
  dedupe_natives(result.to_a.map(&:native))
738
+ when Moxml::Node
739
+ # The engine answers a lone node for the context node
740
+ # (`.`); the adapter contract is a node-set for node
741
+ # results.
742
+ [result.native]
711
743
  else
712
744
  result
713
745
  end