moxml 0.5.149 → 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 (61) 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 +73 -0
  22. data/lib/moxml/adapter/leptris.rb +183 -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/c14n/data_model.rb +8 -0
  29. data/lib/moxml/c14n/processor.rb +17 -8
  30. data/lib/moxml/context.rb +28 -0
  31. data/lib/moxml/document.rb +17 -3
  32. data/lib/moxml/node.rb +56 -5
  33. data/lib/moxml/node_set.rb +31 -1
  34. data/lib/moxml/relaxng.rb +62 -0
  35. data/lib/moxml/sax/block_handler.rb +7 -0
  36. data/lib/moxml/sax/handler.rb +20 -0
  37. data/lib/moxml/version.rb +1 -1
  38. data/lib/moxml/xpath.rb +28 -0
  39. data/lib/moxml.rb +1 -0
  40. data/spec/consistency/adapter_parity_spec.rb +22 -0
  41. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  42. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  43. data/spec/consistency/published_docs_spec.rb +49 -0
  44. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  45. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  46. data/spec/moxml/adapter/leptris_spec.rb +23 -3
  47. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  48. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  49. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  50. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  51. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  52. data/spec/moxml/c14n/subset_spec.rb +2 -12
  53. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  54. data/spec/moxml/element_indent_spec.rb +34 -0
  55. data/spec/moxml/node_spec.rb +0 -3
  56. data/spec/moxml/relax_ng_spec.rb +67 -0
  57. data/spec/moxml/sax_spec.rb +45 -74
  58. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  59. data/spec/support/input_taxonomy.rb +7 -0
  60. data/spec/support/model_coverage.rb +159 -0
  61. 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,60 @@ 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
+ XPATH_INDEX_STALE = :xpath_index_stale
1197
+
1198
+ def mark_xpath_index_stale(node)
1199
+ doc = xpath_index_document(node)
1200
+ attachments.set(doc, XPATH_INDEX_STALE, true) if doc
1201
+ end
1202
+
1203
+ def clear_xpath_index_stale(node)
1204
+ doc = xpath_index_document(node)
1205
+ attachments.set(doc, XPATH_INDEX_STALE, false) if doc
1206
+ end
1207
+
1208
+ def xpath_index_stale?(doc)
1209
+ # leptris-ruby#519 is fixed upstream: on builds whose
1210
+ # node-set index survives sibling insertion the stale mark
1211
+ # is moot, and post-insert XPath stays on the native lane.
1212
+ return false if native_xpath_index_fixed?
1213
+
1214
+ doc && attachments.get(doc, XPATH_INDEX_STALE)
1215
+ end
1216
+
1217
+ # Runs the #519 repro once at the binding level: populate the
1218
+ # node-set index, insert a sibling, and require the re-query
1219
+ # to see the inserted element. True on fixed builds, so the
1220
+ # guard auto-retires without a moxml release.
1221
+ def native_xpath_index_fixed?
1222
+ return @native_xpath_index_fixed unless @native_xpath_index_fixed.nil?
1223
+
1224
+ @native_xpath_index_fixed = begin
1225
+ native_doc = ::Leptris::XML::Document.parse("<r><a/><b/></r>")
1226
+ root = native_doc.root
1227
+ root.xpath("child::*")
1228
+ b = root.children.to_a[1]
1229
+ b.add_previous_sibling(native_doc.create_element("c"))
1230
+ names = root.xpath("child::*").to_a.map { |n| n.name.to_s }
1231
+ names == %w[a c b]
1232
+ rescue StandardError
1233
+ false
1234
+ end
1235
+ end
1236
+
1237
+ def xpath_index_document(node)
1238
+ return node if node.is_a?(::Leptris::XML::Document)
1239
+
1240
+ node.document
1241
+ end
1242
+
1118
1243
  def set_root(doc, element)
1119
1244
  to_binding(doc).root = to_binding(element)
1120
1245
  end
@@ -1135,13 +1260,23 @@ module Moxml
1135
1260
  true
1136
1261
  end
1137
1262
 
1263
+ # Canonical XML excludes the initial whitespace of PI data;
1264
+ # the C canonicalizer keeps the raw separator when the source
1265
+ # used more than one whitespace character, so collapse it to
1266
+ # the single canonical space (libxml2 oracle). In canonical
1267
+ # output a literal "<?" can only start a PI: "<" is escaped in
1268
+ # text and attribute values, and comments/DTD are not emitted
1269
+ # on this lane.
1270
+ NATIVE_C14N_PI_SEPARATOR = /<\?([^\s?]+)[ \t\r\n]+/
1271
+
1138
1272
  def native_inclusive10(native)
1139
1273
  return nil unless native_c14n_byte_safe?
1140
1274
 
1141
- to_binding(native).canonicalize(
1275
+ canonical = to_binding(native).canonicalize(
1142
1276
  ::Leptris::XML::FFI::C14N_1_0, nil,
1143
1277
  mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL
1144
1278
  )
1279
+ canonical.gsub(NATIVE_C14N_PI_SEPARATOR, '<?\1 ')
1145
1280
  end
1146
1281
 
1147
1282
  def expanded_attr_value(element, uri, local)
@@ -1184,6 +1319,14 @@ module Moxml
1184
1319
  true
1185
1320
  end
1186
1321
 
1322
+ # Our at_xpath falls back to the model's Ruby engine when the
1323
+ # native lane answers nothing — the engine's node-set XPath
1324
+ # index is stale after sibling insertion, so `at` must not be
1325
+ # routed through the node-set lane.
1326
+ def native_at_xpath?
1327
+ true
1328
+ end
1329
+
1187
1330
  # UTF-8 is the default effective encoding; a prolog declaring
1188
1331
  # another encoding hands byte validity to that encoding's lane.
1189
1332
  DECLARED_ENCODING_RE =
@@ -2600,6 +2743,15 @@ module Moxml
2600
2743
  end
2601
2744
 
2602
2745
  def add_child(parent, child)
2746
+ result = add_child_impl(parent, child)
2747
+ # A successful child insertion rebuilds the engine's
2748
+ # document index; the upstream sibling-insert bug leaves it
2749
+ # stale (see native_xpath), so a rebuild clears the guard.
2750
+ clear_xpath_index_stale(parent) if result
2751
+ result
2752
+ end
2753
+
2754
+ def add_child_impl(parent, child)
2603
2755
  # Same-document Identity append — the builder hot path. One
2604
2756
  # document pair decides the verdict: the pin, the absorb
2605
2757
  # checks and the binding bridges only exist for
@@ -2712,6 +2864,12 @@ module Moxml
2712
2864
  end
2713
2865
 
2714
2866
  def add_previous_sibling(node, new_node)
2867
+ result = add_previous_sibling_impl(node, new_node)
2868
+ mark_xpath_index_stale(node)
2869
+ result
2870
+ end
2871
+
2872
+ def add_previous_sibling_impl(node, new_node)
2715
2873
  if NATIVE_READ_LAYER
2716
2874
  node = to_binding(node)
2717
2875
  new_node = to_binding(new_node)
@@ -2752,6 +2910,12 @@ module Moxml
2752
2910
  end
2753
2911
 
2754
2912
  def add_next_sibling(node, new_node)
2913
+ result = add_next_sibling_impl(node, new_node)
2914
+ mark_xpath_index_stale(node)
2915
+ result
2916
+ end
2917
+
2918
+ def add_next_sibling_impl(node, new_node)
2755
2919
  if NATIVE_READ_LAYER
2756
2920
  node = to_binding(node)
2757
2921
  new_node = to_binding(new_node)
@@ -2966,6 +3130,18 @@ module Moxml
2966
3130
  end
2967
3131
 
2968
3132
  def set_text_content(node, content)
3133
+ # Native-layer fast path (leptris-ruby#460): the C setter
3134
+ # takes document+address directly — no binding twin mint
3135
+ # per write. The Identity wrapper's own content= would
3136
+ # re-enter the facade, so call the module face.
3137
+ if NATIVE_SET_BINDING_TEXT && node.is_a?(::Leptris::XML::NativeNode) &&
3138
+ (doc = doc_for(node))
3139
+ ::Leptris::XML::Native.set_binding_text(
3140
+ doc, node.address, content.to_s
3141
+ )
3142
+ return
3143
+ end
3144
+
2969
3145
  node = to_binding(node) if NATIVE_READ_LAYER
2970
3146
  case node
2971
3147
  when ::Leptris::XML::Document
@@ -3185,6 +3361,9 @@ module Moxml
3185
3361
  ::Leptris::XML::XPath.compile(expression)
3186
3362
  end
3187
3363
  document = node.is_a?(::Leptris::XML::Document) ? node : node.document
3364
+ # A sibling insertion left the engine index stale; the
3365
+ # reference engine walks the live tree (see the guard above).
3366
+ return nil if xpath_index_stale?(document)
3188
3367
 
3189
3368
  if namespaces && !namespaces.empty?
3190
3369
  # 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.
@@ -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
data/lib/moxml/context.rb CHANGED
@@ -198,6 +198,19 @@ module Moxml
198
198
  Moxml::XSD::Schema.new(handle, self)
199
199
  end
200
200
 
201
+ # Compile a RELAX NG schema from source or from a file path (the
202
+ # engine resolves relative includes against the file's directory).
203
+ # Adapters without an RNG engine raise Moxml::NotImplementedError.
204
+ def relaxng(schema_source)
205
+ handle = config.adapter.relaxng_compile(schema_source.to_s)
206
+ Moxml::RelaxNG::Schema.new(handle, self)
207
+ end
208
+
209
+ def relaxng_file(path)
210
+ handle = config.adapter.relaxng_compile_file(path.to_s)
211
+ Moxml::RelaxNG::Schema.new(handle, self)
212
+ end
213
+
201
214
  def xslt(stylesheet)
202
215
  source = case stylesheet
203
216
  when Moxml::Document then stylesheet.to_xml
@@ -282,6 +295,21 @@ module Moxml
282
295
  raise ArgumentError, "Handler must inherit from Moxml::SAX::Handler"
283
296
  end
284
297
 
298
+ # Loud degradation: an engine that cannot report an event family
299
+ # raises the named error when the handler subscribes, rather than
300
+ # answering a silently incomplete stream (model, SAX).
301
+ SAX::Handler::EVENT_METHODS.each_key do |family|
302
+ next unless handler.handles?(family)
303
+ next if config.adapter.sax_event_family_supported?(family)
304
+
305
+ raise Moxml::NotImplementedError.new(
306
+ "SAX #{family} events are not supported by the " \
307
+ "#{config.adapter.name.split('::').last} adapter",
308
+ feature: "sax_#{family}",
309
+ adapter: config.adapter.name,
310
+ )
311
+ end
312
+
285
313
  # Delegate to adapter
286
314
  config.adapter.sax_parse(xml, handler)
287
315
  end
@@ -282,14 +282,28 @@ module Moxml
282
282
  end
283
283
  end
284
284
 
285
- # Sugar over xpath: the first node of a node-set; scalar results
286
- # (count(), string(), boolean()) pass through unwrapped and
287
- # uniform across adapters.
285
+ # The first node of a node-set; scalar results (count(), string(),
286
+ # boolean()) pass through unwrapped and uniform across adapters.
287
+ # Adapters whose at_xpath lane falls back to the reference engine
288
+ # when the native answer is empty (leptris, whose node-set index
289
+ # is stale after sibling insertion) use it.
288
290
  def at_xpath(expression, namespaces = nil)
291
+ if adapter.native_at_xpath?
292
+ expression = lax_document_path(expression) if relative_name_test?(expression)
293
+ result = adapter.at_xpath(@native, expression, namespaces)
294
+ return result if scalar_result?(result)
295
+
296
+ return Moxml::Node.wrap(result, context)
297
+ end
289
298
  result = xpath(expression, namespaces)
290
299
  result.is_a?(NodeSet) ? result.first : result
291
300
  end
292
301
 
302
+ def scalar_result?(result)
303
+ result.is_a?(Numeric) || result.is_a?(String) ||
304
+ result == true || result == false || result.nil?
305
+ end
306
+
293
307
  # Quick element creation and addition
294
308
  def add_element(name, attributes = {}, &block)
295
309
  elem = create_element(name)