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
@@ -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
 
@@ -46,6 +50,29 @@ module Moxml
46
50
  )
47
51
  end
48
52
  create_document
53
+ rescue ::RuntimeError => e
54
+ # REXML's entity-expansion cap (and any engine-level
55
+ # guard) surfaces as a bare RuntimeError; the facade
56
+ # names it (issue: taxonomy differential, huge-entity
57
+ # class; the cap itself is D-16).
58
+ raise Moxml::ParseError.new(
59
+ e.message,
60
+ source: xml.is_a?(String) ? xml[0..100] : nil,
61
+ )
62
+ end
63
+
64
+ # Rootless parses (empty or declaration-only input) are
65
+ # malformed: XML requires exactly one root. Nokogiri and
66
+ # leptris raise at the engine; rexml returns a bare
67
+ # document — the facade names it instead (loudness
68
+ # differential, TODO.sota/01). Recover mode keeps the
69
+ # rootless-document fallback with its diagnostics (#147).
70
+ if options[:strict] && native_doc.respond_to?(:root) &&
71
+ native_doc.root.nil?
72
+ raise Moxml::ParseError.new(
73
+ "document has no root element",
74
+ source: xml.is_a?(String) ? xml[0..100] : nil,
75
+ )
49
76
  end
50
77
 
51
78
  ctx = _context || Context.new(:rexml)
@@ -192,8 +219,10 @@ module Moxml
192
219
  def children(node, **_entity_bearing)
193
220
  return [] unless node.is_a?(::REXML::Parent)
194
221
 
195
- # Return all children preserving whitespace text nodes
196
- 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)
197
226
 
198
227
  # Include any EntityReference wrappers stored alongside native
199
228
  # children. Native children are identity-unique; only this
@@ -435,7 +464,7 @@ module Moxml
435
464
  end
436
465
 
437
466
  def processing_instruction_content(node)
438
- node.content
467
+ node.content.to_s
439
468
  end
440
469
 
441
470
  def set_processing_instruction_content(node, content)
@@ -458,17 +487,26 @@ module Moxml
458
487
  return "" unless element
459
488
 
460
489
  text = ""
461
- element.children.each do |child|
462
- case child
463
- when ::REXML::Text
464
- # Preserve original spacing from text nodes exactly including newlines and all whitespace
465
- text += child.value
466
- when ::REXML::Element
467
- # Extract text recursively from child element
468
- child_text = extract_text_recursively(child)
469
- # Concatenate directly like other adapters - NO SPACE INSERTION
470
- text += child_text
490
+ begin
491
+ element.children.each do |child|
492
+ case child
493
+ when ::REXML::Text
494
+ # Preserve original spacing from text nodes exactly including newlines and all whitespace
495
+ text += child.value
496
+ when ::REXML::Element
497
+ # Extract text recursively from child element
498
+ child_text = extract_text_recursively(child)
499
+ # Concatenate directly like other adapters - NO SPACE INSERTION
500
+ text += child_text
501
+ end
471
502
  end
503
+ rescue ::RuntimeError => e
504
+ # rexml expands entities lazily: the parse accepts, the
505
+ # read hits the expansion guard (D-16). The facade names
506
+ # it instead of leaking the bare RuntimeError.
507
+ raise Moxml::Error,
508
+ "entity expansion limit exceeded (rexml guard, D-16): " \
509
+ "#{e.message}"
472
510
  end
473
511
  # Don't strip - preserve original spacing including newlines
474
512
  text
@@ -604,6 +642,15 @@ module Moxml
604
642
  end
605
643
 
606
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
+
607
654
  # XPath.match for every expression: get_elements only
608
655
  # handles simple child-name paths — attribute terminals,
609
656
  # absolute paths and predicates silently degrade there.
@@ -612,11 +659,29 @@ module Moxml
612
659
  # namespaced elements never match (issue #395).
613
660
  namespaces = namespaces.transform_keys(&:to_s) if
614
661
  namespaces.is_a?(Hash)
615
- if namespaces && !namespaces.empty?
616
- ::REXML::XPath.match(node, expression, namespaces).to_a
617
- else
618
- ::REXML::XPath.match(node, expression).to_a
662
+ results = if namespaces && !namespaces.empty?
663
+ ::REXML::XPath.match(node, expression, namespaces).to_a
664
+ else
665
+ ::REXML::XPath.match(node, expression).to_a
666
+ end
667
+ # REXML answers unions in expression order (//a | //b on
668
+ # <r><b/><a/></r> answers a,a,b); path results are
669
+ # document-ordered by the model (TODO.sota/02, M3) —
670
+ # re-order here, the oga lane's pattern.
671
+ return results if results.empty?
672
+
673
+ # Scalar arm: count()/string()/boolean()/arithmetic answer a
674
+ # one-element array holding the value; the adapter contract
675
+ # passes scalars through unwrapped (numbers as Float,
676
+ # uniform with the other adapters).
677
+ unless results.first.is_a?(::REXML::Node)
678
+ value = results.first
679
+ return value.is_a?(Numeric) ? value.to_f : value
619
680
  end
681
+
682
+ ctx = Context.new(:rexml)
683
+ wrapped = results.map { |n| Moxml::Node.wrap(n, ctx) }
684
+ Moxml::XPath::Ordering.document_order(wrapped).map(&:native)
620
685
  rescue ::REXML::ParseException => e
621
686
  raise Moxml::XPathError.new(
622
687
  e.message,
@@ -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
@@ -119,7 +119,11 @@ module Moxml
119
119
  end
120
120
  # Allocation-free declaration sniff: String#strip would copy the
121
121
  # whole buffer just to test its prefix.
122
- has_declaration = xml_string.match?(/\A[ \t\r\n\f\v\0]*<\?xml/)
122
+ # Invalid byte sequences would raise from the regex before any
123
+ # adapter sees them; the adapters' named parse errors own
124
+ # malformed input (taxonomy differential, malformed class).
125
+ has_declaration = xml_string.valid_encoding? &&
126
+ xml_string.match?(/\A[ \t\r\n\f\v\0]*<\?xml/)
123
127
 
124
128
  # Parse with adapter, passing self (context) so adapter can use our config
125
129
  parsed_options = default_options.merge(options)
@@ -194,6 +198,19 @@ module Moxml
194
198
  Moxml::XSD::Schema.new(handle, self)
195
199
  end
196
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
+
197
214
  def xslt(stylesheet)
198
215
  source = case stylesheet
199
216
  when Moxml::Document then stylesheet.to_xml
@@ -278,6 +295,21 @@ module Moxml
278
295
  raise ArgumentError, "Handler must inherit from Moxml::SAX::Handler"
279
296
  end
280
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
+
281
313
  # Delegate to adapter
282
314
  config.adapter.sax_parse(xml, handler)
283
315
  end
@@ -272,7 +272,7 @@ module Moxml
272
272
  # - NodeSet: already wrapped, return directly
273
273
  # - Array / LazyNodeSet: wrap in NodeSet
274
274
  case result
275
- when NodeSet, Float, String, TrueClass, FalseClass, NilClass
275
+ when NodeSet, Numeric, String, TrueClass, FalseClass, NilClass
276
276
  result
277
277
  when Array, LazyNodeSet
278
278
  NodeSet.new(result, context)
@@ -282,12 +282,26 @@ module Moxml
282
282
  end
283
283
  end
284
284
 
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.
285
290
  def at_xpath(expression, namespaces = nil)
286
- expression = lax_document_path(expression) if relative_name_test?(expression)
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)
287
295
 
288
- if (native_node = adapter.at_xpath(@native, expression, namespaces))
289
- Moxml::Node.wrap(native_node, context)
296
+ return Moxml::Node.wrap(result, context)
290
297
  end
298
+ result = xpath(expression, namespaces)
299
+ result.is_a?(NodeSet) ? result.first : result
300
+ end
301
+
302
+ def scalar_result?(result)
303
+ result.is_a?(Numeric) || result.is_a?(String) ||
304
+ result == true || result == false || result.nil?
291
305
  end
292
306
 
293
307
  # Quick element creation and addition
data/lib/moxml/node.rb CHANGED
@@ -36,6 +36,11 @@ module Moxml
36
36
  self
37
37
  end
38
38
 
39
+ # Document-derived wrapper state (the entity-marker verdict) flows
40
+ # parent -> child during prewrapped traversal; adapters that keep
41
+ # no per-wrapper document state carry nothing.
42
+ def inherit_document_state(_parent); end
43
+
39
44
  def refresh_native!(new_native)
40
45
  unless new_native.equal?(@native)
41
46
  # Cache-stable binding nodes carry the wrapper in an ivar
@@ -226,7 +231,8 @@ module Moxml
226
231
  def attach_previous_sibling(node)
227
232
  context.bump_children_generation
228
233
  node = prepare_node(node)
229
- adapter.add_previous_sibling(@native, node.native)
234
+ inserted = adapter.add_previous_sibling(@native, node.native)
235
+ refresh_inserted_native(node, inserted)
230
236
  # Invalidate the parent's memoized children list. The wrapper-side
231
237
  # @parent_node link is only set when this node was yielded through a
232
238
  # parent-aware NodeSet; nodes obtained via at()/xpath() carry no such
@@ -239,12 +245,20 @@ module Moxml
239
245
  def attach_next_sibling_to(anchor, node)
240
246
  context.bump_children_generation
241
247
  node = prepare_node(node)
242
- adapter.add_next_sibling(anchor.native, node.native)
248
+ inserted = adapter.add_next_sibling(anchor.native, node.native)
249
+ refresh_inserted_native(node, inserted)
243
250
  anchor.parent&.invalidate_children_cache!
244
251
  anchor.invalidate_parent_children_cache!
245
252
  self
246
253
  end
247
254
 
255
+ def refresh_inserted_native(node, inserted)
256
+ return unless adapter.add_sibling_returns_inserted?
257
+ return if inserted.nil? || inserted.equal?(node.native)
258
+
259
+ node.refresh_native!(inserted)
260
+ end
261
+
248
262
  def remove
249
263
  # Nokogiri parity: unlinking an already-detached node is a
250
264
  # no-op returning self. Re-attach idioms like
@@ -296,6 +310,21 @@ module Moxml
296
310
  end
297
311
 
298
312
  def to_xml(options = {})
313
+ # Element-level indentation is a declared capability: engines
314
+ # that ignore it must not answer unindented output silently
315
+ # (D-09). indent: 0 and the no-option path stay common.
316
+ indent = options[:indent]
317
+ if !is_a?(Document) && indent.is_a?(Integer) && indent.positive? &&
318
+ !adapter.element_indent_supported?
319
+ raise Moxml::AdapterError.new(
320
+ "element-level indentation is not supported by the " \
321
+ "#{adapter.name.split('::').last} adapter; serialize the " \
322
+ "document or use indent: 0",
323
+ adapter: adapter.name,
324
+ operation: "serialize",
325
+ )
326
+ end
327
+
299
328
  # Determine if we should include XML declaration
300
329
  # For Document nodes: check native then wrapper, unless explicitly overridden
301
330
  # For other nodes: default to no declaration unless explicitly set
@@ -367,9 +396,24 @@ module Moxml
367
396
  Materializer.materialize_fields(self, &block)
368
397
  end
369
398
 
399
+ # The first node of a node-set; scalar results (count(), string(),
400
+ # boolean()) pass through unwrapped and uniform across adapters.
401
+ # Adapters with a native first-only face use it (see
402
+ # Document#at_xpath).
370
403
  def at_xpath(expression, namespaces = {})
371
- Moxml::Node.wrap(adapter.at_xpath(@native, expression, namespaces),
372
- context)
404
+ if adapter.native_at_xpath?
405
+ result = adapter.at_xpath(@native, expression, namespaces)
406
+ return result if scalar_result?(result)
407
+
408
+ return Moxml::Node.wrap(result, context)
409
+ end
410
+ result = xpath(expression, namespaces)
411
+ result.is_a?(NodeSet) ? result.first : result
412
+ end
413
+
414
+ def scalar_result?(result)
415
+ result.is_a?(Numeric) || result.is_a?(String) ||
416
+ result == true || result == false || result.nil?
373
417
  end
374
418
 
375
419
  # Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
@@ -410,10 +454,16 @@ module Moxml
410
454
  end
411
455
 
412
456
  # Nokogiri-compatible depth-first traversal: yields self, then
413
- # children recursively (document-order visitor).
457
+ # children recursively (document-order visitor). Document-level
458
+ # parts (Declaration, Doctype) are state, not nodes: they are
459
+ # never yielded, and their (adapter-specific, sometimes missing)
460
+ # child faces are never consulted (issue-free by the model's
461
+ # object-model rule; found by the taxonomy differential).
414
462
  def traverse(&block)
415
463
  return to_enum(:traverse) unless block
416
464
 
465
+ return self if is_a?(Moxml::Declaration) || is_a?(Moxml::Doctype)
466
+
417
467
  yield self
418
468
  children.each { |child| child.traverse(&block) }
419
469
  self
@@ -682,9 +732,16 @@ module Moxml
682
732
  end
683
733
  end
684
734
 
685
- # The adapter's type probe is an FFI call per invocation; the
686
- # type is fixed for a node's lifetime, so the first answer is
687
- # memoized on the wrapper.
735
+ # The node's kind (the model's Kind face): :document, :element,
736
+ # :text, :comment, :cdata, :processing_instruction, :doctype,
737
+ # :declaration, :entity_reference, :namespace, :attribute. The
738
+ # adapter's type probe is an FFI call per invocation; the type is
739
+ # fixed for a node's lifetime, so the first answer is memoized on
740
+ # the wrapper.
741
+ def node_type
742
+ node_type_cached
743
+ end
744
+
688
745
  def node_type_cached
689
746
  @node_type_cached ||= adapter.node_type(@native)
690
747
  end
@@ -890,6 +947,16 @@ module Moxml
890
947
  # input native keeps its registration so later wraps of the
891
948
  # same base object hit the cache instead of re-minting.
892
949
  if (extended = adapter.wrap_native(node, type, context))
950
+ # A native that already carries the contract IS the wrapper
951
+ # (leptris C mint): prime it in place and skip the WeakMap
952
+ # round trip — the primed @native-identity answers every
953
+ # later wrap through the fast path above.
954
+ if extended.equal?(node) && extended.is_a?(Node)
955
+ extended.prime_contract(extended, context, adapter, type) unless
956
+ extended.instance_variable_defined?(:@context)
957
+ return extended
958
+ end
959
+
893
960
  extended.prime_contract(extended, context, adapter, type)
894
961
  context.register_wrapper(node, extended)
895
962
  context.register_wrapper(extended, extended) unless extended.equal?(node)
@@ -39,12 +39,18 @@ module Moxml
39
39
  # nodes: Array of natives, or an adapter's LazyNodeSet — the
40
40
  # native set is held unmaterialized until an operation needs an
41
41
  # Array (#+, #<<, #delete, Range slices).
42
- def initialize(nodes, context, parent_node = nil)
42
+ #
43
+ # prewrapped: the adapter vouches the entries are already
44
+ # contract-carrying wrappers (the leptris C-mint children face),
45
+ # so the walk path skips the wrapped-buffer allocation and the
46
+ # per-child wrap dispatch.
47
+ def initialize(nodes, context, parent_node = nil, prewrapped: false)
43
48
  @nodes = nodes.is_a?(Array) ? nodes : nil
44
49
  @lazy = @nodes ? nil : nodes
45
50
  @context = context
46
51
  @wrapped = nil
47
52
  @parent_node = parent_node
53
+ @prewrapped = prewrapped
48
54
  end
49
55
 
50
56
  # The raw native nodes — adapter-internal work only (issue #26).
@@ -59,6 +65,13 @@ module Moxml
59
65
  def each
60
66
  return to_enum(:each) unless block_given?
61
67
 
68
+ if @prewrapped
69
+ (@nodes || @lazy).each do |node|
70
+ yield adopt_prewrapped(node)
71
+ end
72
+ return self
73
+ end
74
+
62
75
  wrapped = wrapped_buffer
63
76
  index = 0
64
77
  (@nodes || @lazy).each do |node|
@@ -119,6 +132,17 @@ module Moxml
119
132
 
120
133
  # Nokogiri-compatible: Array#flatten and multiple-assignment
121
134
  # treat a NodeSet as its wrapped-element array via to_ary.
135
+ # The same node-set in document order (XPath path results are
136
+ # conventionally document-ordered; the compiler batches multi-step
137
+ # evaluation and must re-order). Wrappers are ordered, then the
138
+ # internal natives are re-paired — no re-wrapping.
139
+ def document_order
140
+ return self if native_nodes.size < 2
141
+
142
+ ordered = Moxml::XPath::Ordering.document_order(to_a)
143
+ NodeSet.new(ordered.map(&:native), @context, @parent_node)
144
+ end
145
+
122
146
  def to_ary
123
147
  to_a
124
148
  end
@@ -216,5 +240,22 @@ module Moxml
216
240
  end
217
241
  wrapped
218
242
  end
243
+
244
+ # The prewrapped entry path: prime a not-yet-primed C-minted
245
+ # wrapper in place (one ivar check), attach the traversal parent,
246
+ # and hand the document's marker verdict down the tree.
247
+ def adopt_prewrapped(node)
248
+ unless node.is_a?(Moxml::Node)
249
+ node = Moxml::Node.wrap(node, @context)
250
+ end
251
+ unless node.instance_variable_defined?(:@context)
252
+ node.prime_contract(node, @context, Moxml::Node.adapter(@context))
253
+ end
254
+ if @parent_node
255
+ node.parent_node = @parent_node
256
+ node.inherit_document_state(@parent_node)
257
+ end
258
+ node
259
+ end
219
260
  end
220
261
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Reference
5
+ # Pure-Ruby normalization faces: XML 1.0 §2.11 line endings and
6
+ # entity expansion. These are the model's normative answers with
7
+ # no engine involved; adapters are differentially tested against
8
+ # them.
9
+ module Normalizer
10
+ BUILTIN_ENTITIES = {
11
+ "amp" => "&", "lt" => "<", "gt" => ">",
12
+ "quot" => '"', "apos" => "'"
13
+ }.freeze
14
+ ENTITY_REFERENCE = /&([^;&<>]*);/
15
+
16
+ module_function
17
+
18
+ # CRLF → LF; bare CR → LF. Character references are untouched
19
+ # (&#xD; keeps a literal CR).
20
+ def normalize_eol(text)
21
+ text.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
22
+ end
23
+
24
+ # Attribute-value normalization: literal tab/newline/CR become a
25
+ # space (character references keep their value).
26
+ def normalize_attribute_literal(text)
27
+ text.to_s.gsub(/[\t\n\r]/, " ")
28
+ end
29
+
30
+ # Expand built-in and numeric references; custom entities come
31
+ # from the provided table. An undefined reference raises a named
32
+ # ParseError (the model has no silently unresolved entities).
33
+ def expand_entities(text, entities = {})
34
+ text.to_s.gsub(ENTITY_REFERENCE) do
35
+ reference = Regexp.last_match(1)
36
+ resolve(reference, BUILTIN_ENTITIES.merge(entities))
37
+ end
38
+ end
39
+
40
+ def resolve(reference, entities)
41
+ if reference.start_with?("#x")
42
+ numeric(reference[2..], 16)
43
+ elsif reference.start_with?("#")
44
+ numeric(reference[1..], 10)
45
+ elsif BUILTIN_ENTITIES.key?(reference)
46
+ BUILTIN_ENTITIES[reference]
47
+ else
48
+ value = entities[reference]
49
+ raise ParseError, "undefined entity &#{reference};" unless value
50
+
51
+ expand_entities(value, entities)
52
+ end
53
+ end
54
+
55
+ def numeric(digits, base)
56
+ pattern = base == 16 ? /\A[0-9A-Fa-f]+\z/ : /\A[0-9]+\z/
57
+ raise ParseError, "malformed character reference" unless digits.match?(pattern)
58
+
59
+ [digits.to_i(base)].pack("U")
60
+ end
61
+ end
62
+ end
63
+ end