moxml 0.5.149 → 0.5.151

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 (63) 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/performance-release.yml +79 -0
  5. data/.github/workflows/performance.yml +59 -0
  6. data/Rakefile +23 -0
  7. data/benchmark/README.md +106 -0
  8. data/benchmark/scoreboard.json +115 -0
  9. data/benchmark/scoreboard.rb +556 -0
  10. data/bin/generate-model-coverage +21 -0
  11. data/docs/_pages/adapter-compatibility.adoc +3 -3
  12. data/docs/_pages/divergences.adoc +32 -41
  13. data/docs/_pages/ecosystem.adoc +62 -0
  14. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  15. data/docs/_pages/headed-ox.adoc +2 -2
  16. data/docs/_pages/index.adoc +4 -0
  17. data/docs/_pages/model-coverage.adoc +57 -0
  18. data/docs/_pages/performance.adoc +29 -0
  19. data/docs/_pages/why-moxml.adoc +87 -0
  20. data/docs/_references/processing-model.adoc +42 -3
  21. data/lib/moxml/adapter/base.rb +80 -0
  22. data/lib/moxml/adapter/leptris.rb +238 -4
  23. data/lib/moxml/adapter/libxml.rb +34 -2
  24. data/lib/moxml/adapter/nokogiri.rb +10 -0
  25. data/lib/moxml/adapter/oga.rb +39 -7
  26. data/lib/moxml/adapter/ox.rb +8 -0
  27. data/lib/moxml/adapter/rexml.rb +17 -2
  28. data/lib/moxml/attribute_resolver.rb +4 -0
  29. data/lib/moxml/c14n/data_model.rb +8 -0
  30. data/lib/moxml/c14n/processor.rb +17 -8
  31. data/lib/moxml/context.rb +53 -2
  32. data/lib/moxml/document.rb +28 -4
  33. data/lib/moxml/element.rb +13 -1
  34. data/lib/moxml/node.rb +85 -11
  35. data/lib/moxml/node_set.rb +31 -1
  36. data/lib/moxml/relaxng.rb +62 -0
  37. data/lib/moxml/sax/block_handler.rb +7 -0
  38. data/lib/moxml/sax/handler.rb +20 -0
  39. data/lib/moxml/version.rb +1 -1
  40. data/lib/moxml/xpath.rb +28 -0
  41. data/lib/moxml.rb +1 -0
  42. data/spec/consistency/adapter_parity_spec.rb +22 -0
  43. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  44. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  45. data/spec/consistency/published_docs_spec.rb +49 -0
  46. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  47. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  48. data/spec/moxml/adapter/leptris_spec.rb +113 -3
  49. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  50. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  51. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  52. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  53. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  54. data/spec/moxml/c14n/subset_spec.rb +2 -12
  55. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  56. data/spec/moxml/element_indent_spec.rb +34 -0
  57. data/spec/moxml/node_spec.rb +0 -3
  58. data/spec/moxml/relax_ng_spec.rb +67 -0
  59. data/spec/moxml/sax_spec.rb +45 -74
  60. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  61. data/spec/support/input_taxonomy.rb +7 -0
  62. data/spec/support/model_coverage.rb +159 -0
  63. metadata +17 -2
@@ -71,6 +71,13 @@ module Moxml
71
71
  defined?(::Leptris::XML::NativeNode) &&
72
72
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.162.6")
73
73
 
74
+ # leptris-ruby#460: set-text by document+address, kind-dispatched
75
+ # in C — no binding twin mint per write. Face-probed, not
76
+ # version-gated (the floor admits bindings without it).
77
+ NATIVE_SET_BINDING_TEXT =
78
+ defined?(::Leptris::XML::Native) &&
79
+ ::Leptris::XML::Native.respond_to?(:set_binding_text)
80
+
74
81
  # 1.9.163.2's native layer returns UTF-8, unfrozen strings
75
82
  # (1.9.162.x answered ASCII-8BIT — the reads retagged with a
76
83
  # dup+force_encoding per call).
@@ -234,6 +241,16 @@ module Moxml
234
241
 
235
242
  XSD_SUPPORTED = NATIVE_XSD
236
243
 
244
+ # RELAX NG (moxml RelaxNG contract): the binding ships the RNG
245
+ # facade over the engine's Jing-compatible validator.
246
+ RELAXNG_SUPPORTED = begin
247
+ require "leptris/xml/relaxng"
248
+ defined?(::Leptris::XML::RelaxNG) &&
249
+ ::Leptris::XML::RelaxNG.respond_to?(:parse_file)
250
+ rescue LoadError
251
+ false
252
+ end
253
+
237
254
  # Schema#release (moxml#362.3, binding 1.9.342+): the
238
255
  # deterministic drop face — disarms the AutoPointer's GC
239
256
  # finalizer and frees now. Face-probed with the version
@@ -319,6 +336,40 @@ module Moxml
319
336
  ::Leptris::XML::XSD.compile_file(path)
320
337
  end
321
338
 
339
+ def relaxng_compile(schema_source)
340
+ unless RELAXNG_SUPPORTED
341
+ raise Moxml::NotImplementedError,
342
+ "RELAX NG validation requires a leptris binding with the RNG face"
343
+ end
344
+
345
+ ::Leptris::XML::RelaxNG.parse(schema_source)
346
+ rescue ::Leptris::XML::Error => e
347
+ raise Moxml::ValidationError.new(
348
+ "RELAX NG schema parse failed: #{e.message}",
349
+ )
350
+ end
351
+
352
+ def relaxng_compile_file(path)
353
+ unless RELAXNG_SUPPORTED
354
+ raise Moxml::NotImplementedError,
355
+ "RELAX NG validation requires a leptris binding with the RNG face"
356
+ end
357
+
358
+ ::Leptris::XML::RelaxNG.parse_file(path)
359
+ rescue ::Leptris::XML::Error => e
360
+ raise Moxml::ValidationError.new(
361
+ "RELAX NG schema parse failed: #{e.message}",
362
+ )
363
+ end
364
+
365
+ def relaxng_validate_errors(handle, document)
366
+ handle.validate_errors(to_binding(document))
367
+ end
368
+
369
+ def relaxng_valid?(handle, document)
370
+ handle.valid?(to_binding(document))
371
+ end
372
+
322
373
  def xsd_builtin_valid?(builtin, lexical)
323
374
  unless XSD_SUPPORTED
324
375
  raise Moxml::NotImplementedError,
@@ -579,6 +630,21 @@ module Moxml
579
630
  bearing
580
631
  end
581
632
 
633
+ # The resolved verdict plus its generation stamp; the
634
+ # prewrapped traversal hands it down the tree so a fresh
635
+ # child skips its own document climb.
636
+ def doc_entity_stamp
637
+ doc_entity_bearing?
638
+ @doc_entity_stamp
639
+ end
640
+
641
+ def inherit_document_state(parent)
642
+ # The parent's own children call resolved the
643
+ # document-level verdict for this generation, and
644
+ # parent and child share the document.
645
+ @doc_entity_stamp = parent.doc_entity_stamp
646
+ end
647
+
582
648
  def [](key)
583
649
  if key.is_a?(String) && !key.include?(":")
584
650
  value = attr_read(key)
@@ -650,12 +716,17 @@ module Moxml
650
716
  if NN.method_defined?(:children_read)
651
717
  module ChildReads
652
718
  def children
653
- @children = nil if @children_gen != context.children_generation
654
- @children_gen = context.children_generation
719
+ ctx = @context || context
720
+ generation = ctx.children_generation
721
+ if @children_gen != generation
722
+ @children = nil
723
+ @children_gen = generation
724
+ end
655
725
  @children ||= if doc_entity_bearing?
656
726
  super
657
727
  else
658
- NodeSet.new(children_read, context, self)
728
+ NodeSet.new(children_read, ctx, self,
729
+ prewrapped: true)
659
730
  end
660
731
  end
661
732
  end
@@ -1115,6 +1186,72 @@ module Moxml
1115
1186
  @attachments ||= Moxml::NativeAttachment.new
1116
1187
  end
1117
1188
 
1189
+ # --- engine XPath index guard ---------------------------------
1190
+ # Sibling insertions do not invalidate the engine's document
1191
+ # index that the node-set XPath lane uses, so after one the
1192
+ # lane answers stale results (the tree and the first-only
1193
+ # entry are correct; upstream bug filed). Track the state per
1194
+ # binding document and route XPath to the reference Ruby
1195
+ # engine until a child insertion rebuilds the engine index.
1196
+ #
1197
+ # FIXED upstream in libleptris 1.9.378 (leptris/leptris#1802):
1198
+ # every splice path invalidates the index, so the guard is
1199
+ # dead weight on modern engines — and it sat on the builder
1200
+ # hot path (one Monitor-synchronized attachment set plus a
1201
+ # document hop per add_child). Retirement is probed, not
1202
+ # version-gated: any build that passes the #519 repro opts
1203
+ # itself out, no moxml release needed.
1204
+ XPATH_INDEX_STALE = :xpath_index_stale
1205
+
1206
+ def mark_xpath_index_stale(node)
1207
+ return if native_xpath_index_fixed?
1208
+
1209
+ doc = xpath_index_document(node)
1210
+ attachments.set(doc, XPATH_INDEX_STALE, true) if doc
1211
+ end
1212
+
1213
+ def clear_xpath_index_stale(node)
1214
+ return if native_xpath_index_fixed?
1215
+
1216
+ doc = xpath_index_document(node)
1217
+ attachments.set(doc, XPATH_INDEX_STALE, false) if doc
1218
+ end
1219
+
1220
+ def xpath_index_stale?(doc)
1221
+ # leptris-ruby#519 is fixed upstream: on builds whose
1222
+ # node-set index survives sibling insertion the stale mark
1223
+ # is moot, and post-insert XPath stays on the native lane.
1224
+ return false if native_xpath_index_fixed?
1225
+
1226
+ doc && attachments.get(doc, XPATH_INDEX_STALE)
1227
+ end
1228
+
1229
+ # Runs the #519 repro once at the binding level: populate the
1230
+ # node-set index, insert a sibling, and require the re-query
1231
+ # to see the inserted element. True on fixed builds, so the
1232
+ # guard auto-retires without a moxml release.
1233
+ def native_xpath_index_fixed?
1234
+ return @native_xpath_index_fixed unless @native_xpath_index_fixed.nil?
1235
+
1236
+ @native_xpath_index_fixed = begin
1237
+ native_doc = ::Leptris::XML::Document.parse("<r><a/><b/></r>")
1238
+ root = native_doc.root
1239
+ root.xpath("child::*")
1240
+ b = root.children.to_a[1]
1241
+ b.add_previous_sibling(native_doc.create_element("c"))
1242
+ names = root.xpath("child::*").to_a.map { |n| n.name.to_s }
1243
+ names == %w[a c b]
1244
+ rescue StandardError
1245
+ false
1246
+ end
1247
+ end
1248
+
1249
+ def xpath_index_document(node)
1250
+ return node if node.is_a?(::Leptris::XML::Document)
1251
+
1252
+ node.document
1253
+ end
1254
+
1118
1255
  def set_root(doc, element)
1119
1256
  to_binding(doc).root = to_binding(element)
1120
1257
  end
@@ -1135,13 +1272,23 @@ module Moxml
1135
1272
  true
1136
1273
  end
1137
1274
 
1275
+ # Canonical XML excludes the initial whitespace of PI data;
1276
+ # the C canonicalizer keeps the raw separator when the source
1277
+ # used more than one whitespace character, so collapse it to
1278
+ # the single canonical space (libxml2 oracle). In canonical
1279
+ # output a literal "<?" can only start a PI: "<" is escaped in
1280
+ # text and attribute values, and comments/DTD are not emitted
1281
+ # on this lane.
1282
+ NATIVE_C14N_PI_SEPARATOR = /<\?([^\s?]+)[ \t\r\n]+/
1283
+
1138
1284
  def native_inclusive10(native)
1139
1285
  return nil unless native_c14n_byte_safe?
1140
1286
 
1141
- to_binding(native).canonicalize(
1287
+ canonical = to_binding(native).canonicalize(
1142
1288
  ::Leptris::XML::FFI::C14N_1_0, nil,
1143
1289
  mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL
1144
1290
  )
1291
+ canonical.gsub(NATIVE_C14N_PI_SEPARATOR, '<?\1 ')
1145
1292
  end
1146
1293
 
1147
1294
  def expanded_attr_value(element, uri, local)
@@ -1184,6 +1331,14 @@ module Moxml
1184
1331
  true
1185
1332
  end
1186
1333
 
1334
+ # Our at_xpath falls back to the model's Ruby engine when the
1335
+ # native lane answers nothing — the engine's node-set XPath
1336
+ # index is stale after sibling insertion, so `at` must not be
1337
+ # routed through the node-set lane.
1338
+ def native_at_xpath?
1339
+ true
1340
+ end
1341
+
1187
1342
  # UTF-8 is the default effective encoding; a prolog declaring
1188
1343
  # another encoding hands byte validity to that encoding's lane.
1189
1344
  DECLARED_ENCODING_RE =
@@ -1619,6 +1774,23 @@ module Moxml
1619
1774
  (owner_doc || create_document).create_element(name.to_s)
1620
1775
  end
1621
1776
 
1777
+ # MINT_PRIMED: the compiled ext's create_element takes
1778
+ # optional (context, adapter) and pre-writes the contract
1779
+ # ivars in C — arity -1 vs the old 2. Self-gating: no
1780
+ # version floor, the probe reads the loaded ext.
1781
+ MINT_PRIMED = defined?(::Leptris::XML::NativeNode) &&
1782
+ ::Leptris::XML::NativeNode
1783
+ .method(:create_element).arity == -1
1784
+
1785
+ def mints_primed?
1786
+ MINT_PRIMED
1787
+ end
1788
+
1789
+ def create_primed_element(name, owner_doc:, context:, adapter:)
1790
+ validate_element_name(name)
1791
+ owner_doc.native_create_element(name.to_s, context, adapter)
1792
+ end
1793
+
1622
1794
  def create_native_text(content, owner_doc = nil)
1623
1795
  if NATIVE_MUTATIONS_COHERENT && owner_doc
1624
1796
  return owner_doc.native_create_text(content)
@@ -2265,6 +2437,7 @@ module Moxml
2265
2437
  # callers fall back to create_element + set_attribute +
2266
2438
  # add_child when absent.
2267
2439
  def create_element_with_attrs(parent, name, attrs)
2440
+ validate_element_name(name)
2268
2441
  binding_parent = to_binding(parent)
2269
2442
  doc = binding_parent.document or return nil
2270
2443
  return nil unless ::Leptris::XML::Native.respond_to?(:create_element_with_attrs)
@@ -2600,6 +2773,15 @@ module Moxml
2600
2773
  end
2601
2774
 
2602
2775
  def add_child(parent, child)
2776
+ result = add_child_impl(parent, child)
2777
+ # A successful child insertion rebuilds the engine's
2778
+ # document index; the upstream sibling-insert bug leaves it
2779
+ # stale (see native_xpath), so a rebuild clears the guard.
2780
+ clear_xpath_index_stale(parent) if result
2781
+ result
2782
+ end
2783
+
2784
+ def add_child_impl(parent, child)
2603
2785
  # Same-document Identity append — the builder hot path. One
2604
2786
  # document pair decides the verdict: the pin, the absorb
2605
2787
  # checks and the binding bridges only exist for
@@ -2712,6 +2894,12 @@ module Moxml
2712
2894
  end
2713
2895
 
2714
2896
  def add_previous_sibling(node, new_node)
2897
+ result = add_previous_sibling_impl(node, new_node)
2898
+ mark_xpath_index_stale(node)
2899
+ result
2900
+ end
2901
+
2902
+ def add_previous_sibling_impl(node, new_node)
2715
2903
  if NATIVE_READ_LAYER
2716
2904
  node = to_binding(node)
2717
2905
  new_node = to_binding(new_node)
@@ -2752,6 +2940,12 @@ module Moxml
2752
2940
  end
2753
2941
 
2754
2942
  def add_next_sibling(node, new_node)
2943
+ result = add_next_sibling_impl(node, new_node)
2944
+ mark_xpath_index_stale(node)
2945
+ result
2946
+ end
2947
+
2948
+ def add_next_sibling_impl(node, new_node)
2755
2949
  if NATIVE_READ_LAYER
2756
2950
  node = to_binding(node)
2757
2951
  new_node = to_binding(new_node)
@@ -2965,7 +3159,44 @@ module Moxml
2965
3159
  end.join
2966
3160
  end
2967
3161
 
3162
+ # SET_TEXT_NATIVE: bindings with the unshadowed C alias
3163
+ # (the add_child_native pattern — set_text_native aliases
3164
+ # content= on the TypedData self) take the one-frame write:
3165
+ # no doc_for climb, no address read, no module dispatch.
3166
+ # Self-gating by respond_to? — older exts fall through to
3167
+ # the set_binding_text face.
3168
+ SET_TEXT_NATIVE = defined?(::Leptris::XML::NativeNode) &&
3169
+ ::Leptris::XML::NativeNode.method_defined?(:set_text_native)
3170
+
2968
3171
  def set_text_content(node, content)
3172
+ if SET_TEXT_NATIVE && node.is_a?(::Leptris::XML::NativeNode)
3173
+ begin
3174
+ node.set_text_native(content.to_s)
3175
+ return
3176
+ rescue ::Leptris::XML::Error => e
3177
+ # Same #401 contract as the fallback below: a detached
3178
+ # (parentless) node re-raises as the named Moxml::Error.
3179
+ raise if node.parent
3180
+
3181
+ raise Moxml::Error,
3182
+ "cannot set text content on a detached " \
3183
+ "(parentless) node — attach it to a document " \
3184
+ "first (issue #401): #{e.message}"
3185
+ end
3186
+ end
3187
+
3188
+ # Native-layer fast path (leptris-ruby#460): the C setter
3189
+ # takes document+address directly — no binding twin mint
3190
+ # per write. The Identity wrapper's own content= would
3191
+ # re-enter the facade, so call the module face.
3192
+ if NATIVE_SET_BINDING_TEXT && node.is_a?(::Leptris::XML::NativeNode) &&
3193
+ (doc = doc_for(node))
3194
+ ::Leptris::XML::Native.set_binding_text(
3195
+ doc, node.address, content.to_s
3196
+ )
3197
+ return
3198
+ end
3199
+
2969
3200
  node = to_binding(node) if NATIVE_READ_LAYER
2970
3201
  case node
2971
3202
  when ::Leptris::XML::Document
@@ -3185,6 +3416,9 @@ module Moxml
3185
3416
  ::Leptris::XML::XPath.compile(expression)
3186
3417
  end
3187
3418
  document = node.is_a?(::Leptris::XML::Document) ? node : node.document
3419
+ # A sibling insertion left the engine index stale; the
3420
+ # reference engine walks the live tree (see the guard above).
3421
+ return nil if xpath_index_stale?(document)
3188
3422
 
3189
3423
  if namespaces && !namespaces.empty?
3190
3424
  # Namespace-bound evaluation has no raw-pointer entry —
@@ -87,6 +87,10 @@ module Moxml
87
87
  false
88
88
  end
89
89
 
90
+ def add_sibling_returns_inserted?
91
+ true
92
+ end
93
+
90
94
  # Linear DOCTYPE prolog scanner. Returns
91
95
  # {name:, external_id:, system_id:, internal_subset:} or nil.
92
96
  # Single pass, quote- and bracket-aware: no backtracking, so
@@ -780,7 +784,8 @@ module Moxml
780
784
  return unless node && sibling
781
785
 
782
786
  native_node = unpatch_node(node)
783
- native_sibling = unpatch_node(sibling)
787
+ native_sibling = import_for_attachment(native_node,
788
+ unpatch_node(sibling))
784
789
 
785
790
  # Special handling for document-level processing instructions
786
791
  # When adding a PI as sibling to root element, store it on document
@@ -794,14 +799,35 @@ module Moxml
794
799
  end
795
800
 
796
801
  native_node.prev = native_sibling
802
+ native_sibling
797
803
  end
798
804
 
799
805
  def add_next_sibling(node, sibling)
800
806
  return unless node && sibling
801
807
 
802
808
  native_node = unpatch_node(node)
803
- native_sibling = unpatch_node(sibling)
809
+ native_sibling = import_for_attachment(native_node,
810
+ unpatch_node(sibling))
804
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)
805
831
  end
806
832
 
807
833
  def remove(node)
@@ -1066,6 +1092,12 @@ module Moxml
1066
1092
  native_node = unpatch_node(node)
1067
1093
  return [] unless native_node
1068
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
+
1069
1101
  # libxml-ruby's Document#find returns EMPTY for relative
1070
1102
  # paths (nokogiri/libxml2 evaluate them fine): a relative
1071
1103
  # expression from the document node is equivalent to the
@@ -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])
@@ -203,7 +218,15 @@ module Moxml
203
218
 
204
219
  def namespace(element)
205
220
  case element
206
- 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
207
230
  element.namespace
208
231
  end
209
232
  rescue NoMethodError
@@ -247,10 +270,10 @@ module Moxml
247
270
  def children(node, **_entity_bearing)
248
271
  all_children = []
249
272
 
250
- if node.is_a?(::Oga::XML::Document)
251
- all_children += [node.xml_declaration,
252
- node.doctype].compact
253
- 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
254
277
 
255
278
  return all_children unless node.is_a?(::Oga::XML::Node) || node.is_a?(::Oga::XML::Document)
256
279
 
@@ -513,7 +536,7 @@ module Moxml
513
536
  def processing_instruction_content(node)
514
537
  # Oga keeps the target/content separator space inside the
515
538
  # PI's text; the model's content is the data AFTER the
516
- # separator (D-04). Separator whitespace is parser syntax, and
539
+ # separator. Separator whitespace is parser syntax, and
517
540
  # leaving it in makes canonicalization non-idempotent (the
518
541
  # serializer re-adds the separator each round — TODO.sota/02,
519
542
  # M2).
@@ -565,6 +588,15 @@ module Moxml
565
588
  end
566
589
 
567
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
+
568
600
  result = node.xpath(expression, {},
569
601
  namespaces: namespaces&.transform_keys(&:to_s))
570
602
  # Adapter contract: Array<native> | scalar. Oga's engine
@@ -108,6 +108,14 @@ module Moxml
108
108
  end
109
109
  end
110
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
+
111
119
  def create_document(native_doc = nil)
112
120
  attrs = native_doc&.attributes || {}
113
121
  ::Ox::Document.new(**attrs)
@@ -22,6 +22,10 @@ module Moxml
22
22
  true
23
23
  end
24
24
 
25
+ def element_indent_supported?
26
+ true
27
+ end
28
+
25
29
  def parse(xml, options = {}, _context = nil)
26
30
  xml = "" if xml.nil?
27
31
 
@@ -215,8 +219,10 @@ module Moxml
215
219
  def children(node, **_entity_bearing)
216
220
  return [] unless node.is_a?(::REXML::Parent)
217
221
 
218
- # Return all children preserving whitespace text nodes
219
- result = node.children.dup
222
+ # Return all children preserving whitespace text nodes. The XML
223
+ # declaration is document state, not a child node (model,
224
+ # Object model), so it is never listed.
225
+ result = node.children.grep_v(::REXML::XMLDecl)
220
226
 
221
227
  # Include any EntityReference wrappers stored alongside native
222
228
  # children. Native children are identity-unique; only this
@@ -636,6 +642,15 @@ module Moxml
636
642
  end
637
643
 
638
644
  def xpath(node, expression, namespaces = {})
645
+ # REXML's engine answers no text nodes for text() node
646
+ # tests (D-07, both axes); the reference engine does
647
+ # (model, XPath evaluation).
648
+ if expression.match?(/\btext\s*\(/)
649
+ return Moxml::XPath.evaluate_natives(
650
+ node, expression, namespaces || {}, adapter: :rexml
651
+ )
652
+ end
653
+
639
654
  # XPath.match for every expression: get_elements only
640
655
  # handles simple child-name paths — attribute terminals,
641
656
  # absolute paths and predicates silently degrade there.
@@ -109,6 +109,10 @@ module Moxml
109
109
  # over-invalidate (global bump instead of local) — a no-op
110
110
  # for correctness.
111
111
  if name.start_with?("xmlns")
112
+ # Introducing (or rewriting) a namespace declaration: mark the
113
+ # context BEFORE the invalidation, so this write's generation
114
+ # bump fans out to every wrapper's memoized scope reads.
115
+ element.context.mark_namespaces_active!
112
116
  adapter.set_attribute(element.native, name, value)
113
117
  element.invalidate_attribute_cache!
114
118
  return value
@@ -115,6 +115,14 @@ module Moxml
115
115
  seen_root = false
116
116
  idx = 0
117
117
  document.children.each do |child|
118
+ # Whitespace between document-level nodes is not part of
119
+ # the canonical form (C14N §2.1 renders the document
120
+ # element and the PIs/comments outside it only). rexml
121
+ # retains such text nodes at parse (D-15); every engine's
122
+ # form must still exclude them.
123
+ next if child.is_a?(::Moxml::Text) ||
124
+ child.is_a?(::Moxml::Cdata)
125
+
118
126
  if child.is_a?(::Moxml::Element)
119
127
  next if seen_root
120
128
 
@@ -21,6 +21,11 @@ module Moxml
21
21
  def process(root_node)
22
22
  output = (+"")
23
23
  process_node(root_node, output)
24
+ # Document-level PI/comment rendering uses a newline as a
25
+ # SEPARATOR between nodes (and around the root), not as a
26
+ # leading/trailing pad: the canonical form starts and ends
27
+ # with the first/last node itself (libxml2 oracle).
28
+ output.chomp! if root_node.node_type == :root
24
29
  output
25
30
  end
26
31
 
@@ -93,26 +98,30 @@ module Moxml
93
98
  return unless with_comments
94
99
  return unless node.in_node_set?
95
100
 
96
- # Comment outside the document element gets a line break before/after
97
- # to keep canonical output readable.
98
- if parent_element.nil? && output.length.positive?
99
- output << "\n"
100
- end
101
+ ensure_separator(output) if parent_element.nil?
101
102
  output << "<!--" << node.value << "-->"
102
103
  output << "\n" if parent_element.nil?
103
104
  end
104
105
 
105
106
  def process_pi_node(node, output, parent_element)
106
107
  return unless node.in_node_set?
108
+ # C14N never emits the XML declaration; the "xml" target is
109
+ # reserved and cannot appear as a PI in a well-formed document.
110
+ return if node.target.to_s.casecmp("xml").zero?
107
111
 
108
- if parent_element.nil? && output.length.positive?
109
- output << "\n"
110
- end
112
+ ensure_separator(output) if parent_element.nil?
111
113
  output << "<?" << node.target
112
114
  output << " " << node.data unless node.data.to_s.empty?
113
115
  output << "?>"
114
116
  output << "\n" if parent_element.nil?
115
117
  end
118
+
119
+ # One newline between document-level nodes, never a doubled
120
+ # separator (a PI's trailing newline already starts the next
121
+ # node's line).
122
+ def ensure_separator(output)
123
+ output << "\n" if output.length.positive? && !output.end_with?("\n")
124
+ end
116
125
  end
117
126
  end
118
127
  end