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
@@ -178,6 +178,18 @@ module Moxml
178
178
  method(:sax_parse).owner != Moxml::Adapter::Base.singleton_class
179
179
  end
180
180
 
181
+ # Whether the SAX engine reports an event family as distinct
182
+ # events. Engines that cannot must raise the named error when
183
+ # the handler subscribes (model, SAX: the callback stream is
184
+ # authoritative; absence is loud, never a silent fold).
185
+ #
186
+ # @param family [Symbol] :cdata, :comment, or
187
+ # :processing_instruction
188
+ # @return [Boolean]
189
+ def sax_event_family_supported?(_family)
190
+ true
191
+ end
192
+
181
193
  def create_document(_native_doc = nil)
182
194
  raise Moxml::NotImplementedError.new(
183
195
  "create_document not implemented",
@@ -312,6 +324,30 @@ module Moxml
312
324
  # validator raise; leptris gates on >= 1.9.321.0.
313
325
  XSD_SUPPORTED = false
314
326
 
327
+ # RELAX NG validation (moxml RelaxNG contract): the same
328
+ # loud-capability rule.
329
+ RELAXNG_SUPPORTED = false
330
+
331
+ def relaxng_compile(_schema_source)
332
+ raise Moxml::NotImplementedError,
333
+ "RELAX NG validation requires an adapter with an RNG engine"
334
+ end
335
+
336
+ def relaxng_compile_file(_path)
337
+ raise Moxml::NotImplementedError,
338
+ "RELAX NG validation requires an adapter with an RNG engine"
339
+ end
340
+
341
+ def relaxng_validate_errors(_handle, _native_document)
342
+ raise Moxml::NotImplementedError,
343
+ "RELAX NG validation requires an adapter with an RNG engine"
344
+ end
345
+
346
+ def relaxng_valid?(_handle, _native_document)
347
+ raise Moxml::NotImplementedError,
348
+ "RELAX NG validation requires an adapter with an RNG engine"
349
+ end
350
+
315
351
  # Issue #384: schema handles are adapter-native objects — a
316
352
  # handle compiled through one adapter must not dispatch
317
353
  # through another. Adapters with an XSD engine answer their
@@ -499,6 +535,43 @@ namespace_validation_mode: :strict)
499
535
  node
500
536
  end
501
537
 
538
+ # Whether add_next_sibling/add_previous_sibling answer the
539
+ # native actually installed (libxml imports cross-document
540
+ # fragments and answers the copy) — the facade then refreshes
541
+ # the caller's wrapper to it.
542
+ def add_sibling_returns_inserted?
543
+ false
544
+ end
545
+
546
+ # Whether element-level to_xml(indent:) is honored. The
547
+ # facade raises a named AdapterError on incapable engines
548
+ # instead of returning unindented output silently (D-09).
549
+ def element_indent_supported?
550
+ false
551
+ end
552
+
553
+ # Whether adapter.at_xpath is safe as the primary first-node
554
+ # lane. leptris: its at_xpath falls back to the model's Ruby
555
+ # engine when the native lane answers nothing (an engine index
556
+ # left stale by sibling insertion), so it reflects the live
557
+ # tree where the node-set lane does not.
558
+ def native_at_xpath?
559
+ false
560
+ end
561
+
562
+ # libxml2 (nokogiri and libxml-ruby alike) rejects an empty
563
+ # namespace URI in an XPath binding context, but the model's
564
+ # `xmlns:` idiom must be able to address the no-namespace
565
+ # default. Adapters route such expressions through the
566
+ # reference engine.
567
+ def empty_default_namespace_binding?(namespaces)
568
+ return false unless namespaces
569
+
570
+ namespaces.any? do |prefix, uri|
571
+ prefix.to_s == "xmlns" && uri.to_s.empty?
572
+ end
573
+ end
574
+
502
575
  # Whether children() results need per-child patch_node
503
576
  # rewriting. Adapters whose natives arrive pre-wrapped (all but
504
577
  # ox and libxml) answer false so the wrapper layer can skip
@@ -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,
@@ -348,9 +399,13 @@ module Moxml
348
399
 
349
400
  def xsd_content_valid?(xsd_handle, element_name, children)
350
401
  # The binding reads each child's name + effective
351
- # namespace through its own node API — pass the natives.
402
+ # namespace through its own node API (c_ptr) — bridge to
403
+ # the binding's nodes. 1.9.374+ mints registered klasses
404
+ # at create, so children arrive as Identity wrappers whose
405
+ # .native is self (no c_ptr); to_binding hands back the
406
+ # binding node whatever the wrapper flavor.
352
407
  xsd_handle.content_valid?(
353
- element_name, children.map(&:native)
408
+ element_name, children.map { |c| to_binding(c.native) }
354
409
  )
355
410
  end
356
411
 
@@ -525,12 +580,18 @@ module Moxml
525
580
  include ::Moxml::Node
526
581
  include ::Moxml::ProcessingInstruction
527
582
 
528
- # Same nil bridge as Comment (leptris-ruby#344).
583
+ # Same nil bridge as Comment (leptris-ruby#344); the
584
+ # separating whitespace after the target is not content
585
+ # (uniform PI semantics, TODO.sota/02).
529
586
  def content
530
587
  content = native_content
531
- return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
588
+ if content.nil?
589
+ return ::Moxml::Adapter::Leptris.to_binding(self)
590
+ .content.to_s
591
+ .sub(/\A\s+/, "")
592
+ end
532
593
 
533
- content
594
+ content.sub(/\A\s+/, "")
534
595
  end
535
596
  end
536
597
 
@@ -569,6 +630,21 @@ module Moxml
569
630
  bearing
570
631
  end
571
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
+
572
648
  def [](key)
573
649
  if key.is_a?(String) && !key.include?(":")
574
650
  value = attr_read(key)
@@ -640,12 +716,17 @@ module Moxml
640
716
  if NN.method_defined?(:children_read)
641
717
  module ChildReads
642
718
  def children
643
- @children = nil if @children_gen != context.children_generation
644
- @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
645
725
  @children ||= if doc_entity_bearing?
646
726
  super
647
727
  else
648
- NodeSet.new(children_read, context, self)
728
+ NodeSet.new(children_read, ctx, self,
729
+ prewrapped: true)
649
730
  end
650
731
  end
651
732
  end
@@ -1105,6 +1186,60 @@ module Moxml
1105
1186
  @attachments ||= Moxml::NativeAttachment.new
1106
1187
  end
1107
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
+
1108
1243
  def set_root(doc, element)
1109
1244
  to_binding(doc).root = to_binding(element)
1110
1245
  end
@@ -1125,13 +1260,23 @@ module Moxml
1125
1260
  true
1126
1261
  end
1127
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
+
1128
1272
  def native_inclusive10(native)
1129
1273
  return nil unless native_c14n_byte_safe?
1130
1274
 
1131
- to_binding(native).canonicalize(
1275
+ canonical = to_binding(native).canonicalize(
1132
1276
  ::Leptris::XML::FFI::C14N_1_0, nil,
1133
1277
  mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL
1134
1278
  )
1279
+ canonical.gsub(NATIVE_C14N_PI_SEPARATOR, '<?\1 ')
1135
1280
  end
1136
1281
 
1137
1282
  def expanded_attr_value(element, uri, local)
@@ -1174,8 +1319,44 @@ module Moxml
1174
1319
  true
1175
1320
  end
1176
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
+
1330
+ # UTF-8 is the default effective encoding; a prolog declaring
1331
+ # another encoding hands byte validity to that encoding's lane.
1332
+ DECLARED_ENCODING_RE =
1333
+ /<\?xml\s[^>]*?encoding\s*=\s*["']([^"']+)["']/i
1334
+ private_constant :DECLARED_ENCODING_RE
1335
+
1336
+ def encoding_lane_owns_validity?(xml_string)
1337
+ # Binary-tagged head: the prolog is ASCII, and matching
1338
+ # never raises on binary-tagged bytes (invalid-UTF-8 input
1339
+ # is exactly the case this check exists for).
1340
+ declared = xml_string.b[0, 256].match(DECLARED_ENCODING_RE)
1341
+ declared &&
1342
+ !declared[1].match?(/\A(?:utf-?8|us-ascii|ascii)\z/i)
1343
+ end
1344
+
1177
1345
  def parse(xml, options = {}, _context = nil)
1178
1346
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
1347
+ # Strict parse raises on bytes invalid in the effective
1348
+ # encoding (default UTF-8; declared non-UTF-8 encodings own
1349
+ # their validity) — the validating engines do this at load
1350
+ # (libxml2: "Invalid bytes in character encoding"), the
1351
+ # leptris engine lane does not yet (leptris-ruby#514), so
1352
+ # the facade names it (taxonomy differential, D-18).
1353
+ if options[:strict] && !xml_string.valid_encoding? &&
1354
+ !encoding_lane_owns_validity?(xml_string)
1355
+ raise Moxml::ParseError.new(
1356
+ "Invalid bytes in character encoding (#{xml_string.encoding})",
1357
+ source: xml_string[0, 100],
1358
+ )
1359
+ end
1179
1360
  # The marker flag rides preprocess's own `&` scan — no
1180
1361
  # second full-buffer probe (issue #132 parse-side note).
1181
1362
  entity_mode_keep =
@@ -1213,7 +1394,11 @@ module Moxml
1213
1394
  options: parse_flags(options, recover: recover,
1214
1395
  context: _context),
1215
1396
  )
1216
- rescue ::Leptris::XML::ParseError => e
1397
+ rescue ::Leptris::XML::Error => e
1398
+ # Invalid encoding bytes surface as the base engine
1399
+ # error class, not ParseError — parse failures are all
1400
+ # named by the facade (taxonomy differential, malformed
1401
+ # class).
1217
1402
  raise Moxml::ParseError.new(e.message) if options[:strict]
1218
1403
 
1219
1404
  # Engines below the 1.9.344 recover floor (or the
@@ -1383,7 +1568,11 @@ module Moxml
1383
1568
  else
1384
1569
  ::Leptris::XML.parse_html(html_string)
1385
1570
  end
1386
- rescue ::Leptris::XML::ParseError => e
1571
+ rescue ::Leptris::XML::Error => e
1572
+ # ParseError is the common class; other engine errors
1573
+ # (invalid encoding bytes surface as the base class) are
1574
+ # parse failures too — the facade names them all
1575
+ # (taxonomy differential, malformed class).
1387
1576
  raise Moxml::ParseError.new(e.message)
1388
1577
  end
1389
1578
  attachments.set(native_doc, :entity_markers, false)
@@ -2265,9 +2454,19 @@ module Moxml
2265
2454
  def attribute_pairs(element)
2266
2455
  binding_element = to_binding(element)
2267
2456
  return super unless binding_element.respond_to?(:attribute_pairs)
2268
- return binding_element.attributes_snapshot.to_a if ELEMENT_ATTR_SNAPSHOT
2269
2457
 
2270
- binding_element.attribute_pairs
2458
+ pairs = if ELEMENT_ATTR_SNAPSHOT
2459
+ binding_element.attributes_snapshot.to_a
2460
+ else
2461
+ binding_element.attribute_pairs
2462
+ end
2463
+ # The engine exposes qualified names; the facade's listing
2464
+ # face is bare (Attribute#name), uniform with every adapter.
2465
+ pairs.map { |(name, value)| [local_name(name), value] }
2466
+ end
2467
+
2468
+ def local_name(name)
2469
+ name.include?(":") ? name.split(":", 2)[1] : name
2271
2470
  end
2272
2471
 
2273
2472
  def attribute_element(attr)
@@ -2544,6 +2743,15 @@ module Moxml
2544
2743
  end
2545
2744
 
2546
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)
2547
2755
  # Same-document Identity append — the builder hot path. One
2548
2756
  # document pair decides the verdict: the pin, the absorb
2549
2757
  # checks and the binding bridges only exist for
@@ -2656,6 +2864,12 @@ module Moxml
2656
2864
  end
2657
2865
 
2658
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)
2659
2873
  if NATIVE_READ_LAYER
2660
2874
  node = to_binding(node)
2661
2875
  new_node = to_binding(new_node)
@@ -2696,6 +2910,12 @@ module Moxml
2696
2910
  end
2697
2911
 
2698
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)
2699
2919
  if NATIVE_READ_LAYER
2700
2920
  node = to_binding(node)
2701
2921
  new_node = to_binding(new_node)
@@ -2910,6 +3130,18 @@ module Moxml
2910
3130
  end
2911
3131
 
2912
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
+
2913
3145
  node = to_binding(node) if NATIVE_READ_LAYER
2914
3146
  case node
2915
3147
  when ::Leptris::XML::Document
@@ -2959,16 +3191,20 @@ module Moxml
2959
3191
  end
2960
3192
 
2961
3193
  def processing_instruction_content(node)
3194
+ # The model's PI content excludes the separating whitespace
3195
+ # after the target (Nokogiri semantics; uniform across
3196
+ # adapters — TODO.sota/02).
2962
3197
  if NATIVE_READ_LAYER && node.is_a?(NN)
2963
3198
  content = NN_CONTENT.bind_call(node)
2964
3199
  # NativeNode#content answers nil for PI-kind nodes too
2965
3200
  # (leptris-ruby#344 covers comments); bridge for the data.
2966
- return to_binding(node).content if content.nil?
3201
+ return to_binding(node).content.to_s.sub(/\A\s+/, "") if
3202
+ content.nil?
2967
3203
 
2968
- return content
3204
+ return content.sub(/\A\s+/, "")
2969
3205
  end
2970
3206
 
2971
- node.content
3207
+ node.content.to_s.sub(/\A\s+/, "")
2972
3208
  end
2973
3209
 
2974
3210
  def set_processing_instruction_content(node, content)
@@ -3104,6 +3340,12 @@ module Moxml
3104
3340
  # namespace-bound queries to the Ruby engine until the
3105
3341
  # engine grows correct prefix resolution.
3106
3342
  return nil if namespaces && !namespaces.empty?
3343
+ # Reverse axes answer reverse-ordered from the native
3344
+ # engine; route them to the Ruby engine (document order).
3345
+ return nil if REVERSE_AXIS_CACHE.get_or_set(expression) do
3346
+ expression_uses_reverse_axis?(expression)
3347
+ end
3348
+
3107
3349
  # The binding reports the root element parentless while moxml
3108
3350
  # roots it at the document, so parent-axis queries from the
3109
3351
  # root element keep the Ruby engine.
@@ -3119,6 +3361,9 @@ module Moxml
3119
3361
  ::Leptris::XML::XPath.compile(expression)
3120
3362
  end
3121
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)
3122
3367
 
3123
3368
  if namespaces && !namespaces.empty?
3124
3369
  # Namespace-bound evaluation has no raw-pointer entry —
@@ -3190,6 +3435,14 @@ module Moxml
3190
3435
  node.is_a?(::Leptris::XML::Element)
3191
3436
  end
3192
3437
 
3438
+ # Reverse axes answer in reverse document order from the
3439
+ # native engine; XPath path results are document-ordered (the
3440
+ # model's order contract — TODO.sota/02, M3), so these
3441
+ # expressions route to the Ruby engine, which orders them.
3442
+ REVERSE_AXES = %w[ancestor ancestor-or-self preceding
3443
+ preceding-sibling parent].freeze
3444
+ REVERSE_AXIS_CACHE = XPath::Cache.new(200)
3445
+
3193
3446
  def expression_uses_parent_axis?(expression)
3194
3447
  ast = XPath::Parser.parse_with_cache(expression)
3195
3448
  contains_parent_axis?(ast)
@@ -3197,6 +3450,24 @@ module Moxml
3197
3450
  true
3198
3451
  end
3199
3452
 
3453
+ def expression_uses_reverse_axis?(expression)
3454
+ ast = XPath::Parser.parse_with_cache(expression)
3455
+ contains_reverse_axis?(ast)
3456
+ rescue XPath::SyntaxError
3457
+ false
3458
+ end
3459
+
3460
+ def contains_reverse_axis?(ast)
3461
+ return true if ast.type == :parent
3462
+ if ast.type == :axis && REVERSE_AXES.include?(ast.children.first)
3463
+ return true
3464
+ end
3465
+
3466
+ ast.children.any? do |child|
3467
+ child.is_a?(XPath::AST::Node) && contains_reverse_axis?(child)
3468
+ end
3469
+ end
3470
+
3200
3471
  def contains_parent_axis?(ast)
3201
3472
  return true if ast.type == :parent
3202
3473
  return true if ast.type == :axis && ast.children.first == "parent"
@@ -3366,13 +3637,12 @@ module Moxml
3366
3637
 
3367
3638
  def record_source_declaration(native_doc, xml_string)
3368
3639
  head = xml_string[0, 256]
3369
- had = if head.match?(SOURCE_DECLARATION_RE)
3370
- true
3371
- elsif head.match?(/\A\s*\z/)
3372
- xml_string.match?(SOURCE_DECLARATION_RE)
3373
- else
3374
- false
3375
- end
3640
+ # invalid bytes never reach regexes: named parse errors own
3641
+ # malformed input (taxonomy differential, malformed class)
3642
+ had = xml_string.valid_encoding? &&
3643
+ (head.match?(SOURCE_DECLARATION_RE) ||
3644
+ (head.match?(/\A\s*\z/) &&
3645
+ xml_string.match?(SOURCE_DECLARATION_RE)))
3376
3646
  attachments.set(native_doc, :had_source_declaration, had)
3377
3647
  end
3378
3648
  end