moxml 0.5.147 → 0.5.149

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-fuzz.yml +80 -0
  3. data/docs/_pages/divergences.adoc +58 -0
  4. data/docs/_references/processing-model.adoc +30 -3
  5. data/lib/compat/opal/moxml_boot.rb +4 -0
  6. data/lib/moxml/adapter/leptris.rb +180 -20
  7. data/lib/moxml/adapter/libxml.rb +145 -23
  8. data/lib/moxml/adapter/oga.rb +33 -3
  9. data/lib/moxml/adapter/ox.rb +26 -2
  10. data/lib/moxml/adapter/rexml.rb +65 -15
  11. data/lib/moxml/context.rb +5 -1
  12. data/lib/moxml/document.rb +24 -10
  13. data/lib/moxml/node.rb +34 -6
  14. data/lib/moxml/node_set.rb +11 -0
  15. data/lib/moxml/reference/normalizer.rb +63 -0
  16. data/lib/moxml/reference/parser.rb +417 -0
  17. data/lib/moxml/reference/writer.rb +121 -0
  18. data/lib/moxml/reference.rb +205 -0
  19. data/lib/moxml/version.rb +1 -1
  20. data/lib/moxml/xpath/compiler.rb +17 -2
  21. data/lib/moxml/xpath/ordering.rb +97 -0
  22. data/lib/moxml/xpath.rb +1 -0
  23. data/lib/moxml.rb +1 -0
  24. data/spec/consistency/adapter_parity_spec.rb +2 -0
  25. data/spec/consistency/differential_taxonomy_spec.rb +117 -0
  26. data/spec/consistency/divergence_register_spec.rb +9 -1
  27. data/spec/consistency/eol_normalization_spec.rb +2 -0
  28. data/spec/consistency/loud_degradation_spec.rb +2 -0
  29. data/spec/consistency/metamorphic_properties_spec.rb +195 -0
  30. data/spec/consistency/metamorphic_spec.rb +138 -0
  31. data/spec/consistency/model_coverage_audit_spec.rb +108 -0
  32. data/spec/consistency/reference_differential_spec.rb +186 -0
  33. data/spec/consistency/xpath_idioms_spec.rb +11 -0
  34. data/spec/consistency/xslt_spec.rb +2 -0
  35. data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
  36. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +29 -0
  37. data/spec/moxml/adapter/leptris_detached_text_spec.rb +10 -3
  38. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +12 -0
  39. data/spec/moxml/adapter/ox_spec.rb +1 -1
  40. data/spec/moxml/attribute_resolver_spec.rb +2 -0
  41. data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
  42. data/spec/moxml/doctype_spec.rb +7 -0
  43. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  44. data/spec/moxml/entity_spec.rb +2 -0
  45. data/spec/moxml/plan_conformance_spec.rb +2 -0
  46. data/spec/moxml/processing_instruction_spec.rb +9 -0
  47. data/spec/moxml/reference_opal_spec.rb +29 -0
  48. data/spec/moxml/reference_spec.rb +162 -0
  49. data/spec/moxml/sax_spec.rb +2 -0
  50. data/spec/moxml/text_spec.rb +2 -0
  51. data/spec/moxml/to_html_spec.rb +2 -0
  52. data/spec/moxml/xhtml_output_spec.rb +2 -0
  53. data/spec/moxml/xpath/axes_spec.rb +2 -2
  54. data/spec/moxml/xsd_spec.rb +38 -0
  55. data/spec/support/input_generator.rb +154 -0
  56. data/spec/support/input_taxonomy.rb +212 -0
  57. data/spec/support/xml_generator.rb +54 -0
  58. metadata +17 -2
@@ -7,23 +7,26 @@ require "libxml-ruby"
7
7
  module Moxml
8
8
  module Adapter
9
9
  class Libxml < Base
10
- # DOCTYPE prolog sniffer; see parse for the index-then-match
11
- # strategy that keeps this off the hot path.
12
- DOCTYPE_RE = /<!DOCTYPE\s+([^\s>]+)(?:\s+PUBLIC\s+"([^"]+)"\s+"([^"]+)"|\s+SYSTEM\s+"([^"]+)")?\s*>/i
13
- private_constant :DOCTYPE_RE
10
+ # DOCTYPE prolog scanning is manual (index/char scan): a regex
11
+ # over hostile prolog text backtracks polynomially on inputs
12
+ # with repeated <!DOCTYPE prefixes or unterminated subsets
13
+ # (CodeQL rb/polynomial-redos). See doctype_parts.
14
+ DOCTYPE_OPEN_SIZE = 9
15
+ QUOTES = ['"', "'"].freeze
14
16
 
15
17
  autoload :EntityRefRegistry, "moxml/adapter/libxml/entity_ref_registry"
16
18
 
17
19
  # Wrapper class to store DOCTYPE information
18
20
  class DoctypeWrapper
19
21
  attr_reader :native_doc
20
- attr_accessor :name, :external_id, :system_id
22
+ attr_accessor :name, :external_id, :system_id, :internal_subset
21
23
 
22
- def initialize(doc, name, external_id, system_id)
24
+ def initialize(doc, name, external_id, system_id, internal_subset = nil)
23
25
  @native_doc = doc
24
26
  @name = name
25
27
  @external_id = external_id
26
28
  @system_id = system_id
29
+ @internal_subset = internal_subset
27
30
  end
28
31
 
29
32
  # Provide native method to match adapter pattern
@@ -32,7 +35,8 @@ module Moxml
32
35
  end
33
36
 
34
37
  def to_xml
35
- XmlEmitter.doctype_xml(@name, @external_id, @system_id)
38
+ XmlEmitter.doctype_xml(@name, @external_id, @system_id,
39
+ @internal_subset)
36
40
  end
37
41
  end
38
42
 
@@ -83,6 +87,104 @@ module Moxml
83
87
  false
84
88
  end
85
89
 
90
+ # Linear DOCTYPE prolog scanner. Returns
91
+ # {name:, external_id:, system_id:, internal_subset:} or nil.
92
+ # Single pass, quote- and bracket-aware: no backtracking, so
93
+ # hostile prolog text cannot make parsing superlinear.
94
+ def doctype_parts(source, from = 0)
95
+ start = source.index("<!DOCTYPE", from)
96
+ return unless start
97
+
98
+ pos = skip_doctype_space(source, start + DOCTYPE_OPEN_SIZE)
99
+ name_start = pos
100
+ pos += 1 while pos < source.length &&
101
+ !" \t\r\n>".include?(source[pos])
102
+ name = source[name_start...pos]
103
+ return if name.empty?
104
+
105
+ pos = skip_doctype_space(source, pos)
106
+ external_id = nil
107
+ system_id = nil
108
+ keyword = source[pos, 6].to_s.upcase
109
+ if keyword == "PUBLIC"
110
+ pos = skip_doctype_space(source, pos + 6)
111
+ pair = scan_doctype_quoted(source, pos)
112
+ return unless pair
113
+
114
+ external_id, pos = pair
115
+ pos = skip_doctype_space(source, pos)
116
+ pair = scan_doctype_quoted(source, pos)
117
+ return unless pair
118
+
119
+ system_id, pos = pair
120
+ elsif keyword == "SYSTEM"
121
+ pos = skip_doctype_space(source, pos + 6)
122
+ pair = scan_doctype_quoted(source, pos)
123
+ return unless pair
124
+
125
+ system_id, pos = pair
126
+ end
127
+
128
+ pos = skip_doctype_space(source, pos)
129
+ internal_subset = nil
130
+ if source[pos] == "["
131
+ subset_start = pos + 1
132
+ pos = matching_subset_end(source, pos + 1)
133
+ return unless pos
134
+
135
+ internal_subset = source[subset_start...pos]
136
+ pos += 1
137
+ end
138
+
139
+ pos = skip_doctype_space(source, pos)
140
+ return unless source[pos] == ">"
141
+
142
+ {
143
+ name: name,
144
+ external_id: external_id,
145
+ system_id: system_id,
146
+ internal_subset: internal_subset,
147
+ }
148
+ end
149
+
150
+ def skip_doctype_space(source, pos)
151
+ pos += 1 while pos < source.length &&
152
+ " \t\r\n".include?(source[pos])
153
+ pos
154
+ end
155
+
156
+ def scan_doctype_quoted(source, pos)
157
+ quote = source[pos]
158
+ return unless QUOTES.include?(quote)
159
+
160
+ stop = source.index(quote, pos + 1)
161
+ return unless stop
162
+
163
+ [source[(pos + 1)...stop], stop + 1]
164
+ end
165
+
166
+ # Index just past the closing bracket at nesting depth zero,
167
+ # or nil when the subset is unterminated.
168
+ def matching_subset_end(source, pos)
169
+ depth = 1
170
+ quote = nil
171
+ while pos < source.length
172
+ char = source[pos]
173
+ if quote
174
+ quote = nil if char == quote
175
+ elsif QUOTES.include?(char)
176
+ quote = char
177
+ elsif char == "["
178
+ depth += 1
179
+ elsif char == "]"
180
+ depth -= 1
181
+ return pos if depth.zero?
182
+ end
183
+ pos += 1
184
+ end
185
+ nil
186
+ end
187
+
86
188
  def parse(xml, options = {}, _context = nil)
87
189
  # LibXML doesn't preserve DOCTYPE during parsing, so we need to extract it manually
88
190
  xml_string = if xml.is_a?(String)
@@ -99,19 +201,24 @@ module Moxml
99
201
  xml_string = preprocess_entities(xml_string)
100
202
 
101
203
  # Extract DOCTYPE before parsing. Locate the literal first:
102
- # a plain memscan is ~2x cheaper than the regex's literal
103
- # prefix scan on doctype-less documents (the common case),
104
- # and matching from the found offset keeps captures
105
- # identical.
204
+ # a plain memscan keeps doctype-less documents (the common
205
+ # case) off the scanner path; the scan itself is linear.
106
206
  doctype_idx = xml_string.index("<!DOCTYPE")
107
- doctype_match = if doctype_idx
108
- xml_string.match(DOCTYPE_RE, doctype_idx)
109
- end
207
+ doctype = doctype_idx && doctype_parts(xml_string, doctype_idx)
110
208
 
111
209
  native_doc = begin
112
210
  # Handle both string and file inputs
113
211
  parser = ::LibXML::XML::Parser.string(xml_string)
114
212
  parser.parse
213
+ rescue ::ArgumentError => e
214
+ # Invalid byte sequences raise ArgumentError from Ruby's
215
+ # encoding checks before libxml sees the bytes; the
216
+ # facade names them (taxonomy differential, malformed
217
+ # class).
218
+ raise Moxml::ParseError.new(
219
+ e.message,
220
+ source: xml.is_a?(String) ? xml[0..100] : nil,
221
+ )
115
222
  rescue ::LibXML::XML::Error => e
116
223
  if options[:strict]
117
224
  line = e.line
@@ -127,16 +234,17 @@ module Moxml
127
234
  end
128
235
 
129
236
  # Store DOCTYPE if found
130
- if doctype_match
131
- name = doctype_match[1]
132
- external_id = doctype_match[2]
133
- system_id = doctype_match[3] || doctype_match[4]
237
+ if doctype
238
+ name = doctype[:name]
239
+ external_id = doctype[:external_id]
240
+ system_id = doctype[:system_id]
134
241
 
135
242
  doctype_wrapper = DoctypeWrapper.new(
136
243
  native_doc,
137
244
  name,
138
245
  external_id,
139
246
  system_id,
247
+ doctype[:internal_subset],
140
248
  )
141
249
  attachments.set(native_doc, :doctype, doctype_wrapper)
142
250
  end
@@ -855,7 +963,8 @@ module Moxml
855
963
 
856
964
  def processing_instruction_content(node)
857
965
  # XML 1.0 §2.6: PI content is verbatim — no entity resolution.
858
- unpatch_node(node)&.content
966
+ # Empty data is the empty string, never nil (uniform Kind face).
967
+ unpatch_node(node)&.content.to_s
859
968
  end
860
969
 
861
970
  def set_processing_instruction_content(node, content)
@@ -936,12 +1045,12 @@ module Moxml
936
1045
 
937
1046
  # libxml-ruby 5.x exposes no external/system ID accessor on
938
1047
  # the DTD node — the DOCTYPE source text is the only source,
939
- # so re-derive from it (same DOCTYPE_RE as the parse path).
1048
+ # so re-derive from it (same linear scanner as the parse path).
940
1049
  def doctype_external_id(native)
941
1050
  if native.is_a?(DoctypeWrapper)
942
1051
  native.external_id
943
1052
  else
944
- native.to_s[DOCTYPE_RE, 2]
1053
+ Libxml.doctype_parts(native.to_s)&.dig(:external_id)
945
1054
  end
946
1055
  end
947
1056
 
@@ -949,7 +1058,7 @@ module Moxml
949
1058
  if native.is_a?(DoctypeWrapper)
950
1059
  native.system_id
951
1060
  else
952
- native.to_s[DOCTYPE_RE, 3] || native.to_s[DOCTYPE_RE, 4]
1061
+ Libxml.doctype_parts(native.to_s)&.dig(:system_id)
953
1062
  end
954
1063
  end
955
1064
 
@@ -962,8 +1071,10 @@ module Moxml
962
1071
  # expression from the document node is equivalent to the
963
1072
  # absolute form — the document's only name-testable child
964
1073
  # is the root — so rewrite instead of losing the match.
1074
+ # Function calls and unions are not name-test paths: a
1075
+ # "/count(...)" rewrite is invalid XPath (scalar arm).
965
1076
  if native_node.is_a?(::LibXML::XML::Document) &&
966
- expression.match?(/\A[A-Za-z_*@]/)
1077
+ relative_name_path?(expression)
967
1078
  expression = "/#{expression}"
968
1079
  end
969
1080
 
@@ -997,6 +1108,16 @@ module Moxml
997
1108
  results&.first
998
1109
  end
999
1110
 
1111
+ # A relative name-test path (the only shape the document-context
1112
+ # rewrite is for): a name/`*`/`@` start, no function call (the
1113
+ # scalar arm), no union.
1114
+ def relative_name_path?(expression)
1115
+ return false unless expression.match?(/\A[A-Za-z_*@]/)
1116
+ return false if expression.match?(/\A[A-Za-z][\w.-]*\s*\(/)
1117
+
1118
+ !expression.include?("|")
1119
+ end
1120
+
1000
1121
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
1001
1122
  # but NOT its descendants (deep duplication composes this).
1002
1123
  # walks the source tree and re-adds children one at a time via
@@ -1019,6 +1140,7 @@ module Moxml
1019
1140
  node.name,
1020
1141
  node.external_id,
1021
1142
  node.system_id,
1143
+ node.internal_subset,
1022
1144
  )
1023
1145
  else
1024
1146
  node
@@ -40,6 +40,18 @@ module Moxml
40
40
  )
41
41
  end
42
42
 
43
+ # Rootless parses (empty or declaration-only input) are
44
+ # malformed: XML requires exactly one root. Oga returns a
45
+ # bare document where nokogiri/leptris raise — the facade
46
+ # names it instead (loudness differential, TODO.sota/01).
47
+ if options[:strict] && native_doc.respond_to?(:root_node) &&
48
+ native_doc.children.none?(::Oga::XML::Element)
49
+ raise Moxml::ParseError.new(
50
+ "document has no root element",
51
+ source: xml.is_a?(String) ? xml[0..100] : nil,
52
+ )
53
+ end
54
+
43
55
  # Oga parses multiple root elements leniently; the moxml
44
56
  # contract requires a single root. to_a because the vendored
45
57
  # fork's NodeSet#count ignores its block.
@@ -499,7 +511,13 @@ module Moxml
499
511
  end
500
512
 
501
513
  def processing_instruction_content(node)
502
- node.text
514
+ # Oga keeps the target/content separator space inside the
515
+ # PI's text; the model's content is the data AFTER the
516
+ # separator (D-04). Separator whitespace is parser syntax, and
517
+ # leaving it in makes canonicalization non-idempotent (the
518
+ # serializer re-adds the separator each round — TODO.sota/02,
519
+ # M2).
520
+ node.text.to_s.sub(/\A\s+/, "")
503
521
  end
504
522
 
505
523
  def set_processing_instruction_content(node, content)
@@ -549,8 +567,20 @@ module Moxml
549
567
  def xpath(node, expression, namespaces = nil)
550
568
  result = node.xpath(expression, {},
551
569
  namespaces: namespaces&.transform_keys(&:to_s))
552
- # Adapter contract: Array<native> | scalar
553
- result.is_a?(Enumerable) ? result.to_a : result
570
+ # Adapter contract: Array<native> | scalar. Oga's engine
571
+ # answers node-sets in evaluation order, not document order
572
+ # (//* on <r><a><b/></a><c/></r> answers a,c,b); path results
573
+ # are document-ordered by the model — re-order here
574
+ # (TODO.sota/02, M3).
575
+ if result.is_a?(Enumerable)
576
+ nodes = result.to_a
577
+ ctx = Context.new(:oga)
578
+ wrapped = nodes.map { |n| Moxml::Node.wrap(n, ctx) }
579
+ ordered = Moxml::XPath::Ordering.document_order(wrapped)
580
+ ordered.map(&:native)
581
+ else
582
+ result
583
+ end
554
584
  rescue ::LL::ParserError => e
555
585
  raise Moxml::XPathError.new(
556
586
  e.message,
@@ -60,13 +60,28 @@ module Moxml
60
60
  assign_parents(doc)
61
61
  doc
62
62
  end
63
- rescue ::Ox::ParseError => e
63
+ rescue ::Ox::ParseError, ::RuntimeError => e
64
+ # Ox leaks raw RuntimeErrors from some malformed shapes
65
+ # (e.g. declaration-only input reaches Element#<<); the
66
+ # facade names every parse-time engine failure.
64
67
  raise Moxml::ParseError.new(
65
68
  e.message,
66
69
  source: xml.is_a?(String) ? xml[0..100] : nil,
67
70
  )
68
71
  end
69
72
 
73
+ # Rootless parses (empty or declaration-only input) are
74
+ # malformed: XML requires exactly one root. Ox answers a
75
+ # bare document — the facade names it (uniform with the
76
+ # rexml/oga arms; loudness differential, TODO.sota/01).
77
+ if options[:strict] && native_doc.is_a?(::Ox::Document) &&
78
+ native_doc.nodes.none?(::Ox::Element)
79
+ raise Moxml::ParseError.new(
80
+ "document has no root element",
81
+ source: xml.is_a?(String) ? xml[0..100] : nil,
82
+ )
83
+ end
84
+
70
85
  ctx = _context || Context.new(context_adapter_name)
71
86
  Wrappers::Document.new(native_doc, ctx)
72
87
  end
@@ -627,7 +642,11 @@ module Moxml
627
642
  end
628
643
 
629
644
  def processing_instruction_content(node)
630
- node.content.to_s
645
+ # Ox keeps the target/content separator space inside the
646
+ # PI content; the model's content is the data AFTER the
647
+ # separator. Leaving it in makes canonicalization
648
+ # non-idempotent (TODO.sota/02, M2).
649
+ node.content.to_s.sub(/\A\s+/, "")
631
650
  end
632
651
 
633
652
  def set_processing_instruction_content(node, content)
@@ -708,6 +727,11 @@ module Moxml
708
727
  end)
709
728
  when NodeSet
710
729
  dedupe_natives(result.to_a.map(&:native))
730
+ when Moxml::Node
731
+ # The engine answers a lone node for the context node
732
+ # (`.`); the adapter contract is a node-set for node
733
+ # results.
734
+ [result.native]
711
735
  else
712
736
  result
713
737
  end
@@ -46,6 +46,29 @@ module Moxml
46
46
  )
47
47
  end
48
48
  create_document
49
+ rescue ::RuntimeError => e
50
+ # REXML's entity-expansion cap (and any engine-level
51
+ # guard) surfaces as a bare RuntimeError; the facade
52
+ # names it (issue: taxonomy differential, huge-entity
53
+ # class; the cap itself is D-16).
54
+ raise Moxml::ParseError.new(
55
+ e.message,
56
+ source: xml.is_a?(String) ? xml[0..100] : nil,
57
+ )
58
+ end
59
+
60
+ # Rootless parses (empty or declaration-only input) are
61
+ # malformed: XML requires exactly one root. Nokogiri and
62
+ # leptris raise at the engine; rexml returns a bare
63
+ # document — the facade names it instead (loudness
64
+ # differential, TODO.sota/01). Recover mode keeps the
65
+ # rootless-document fallback with its diagnostics (#147).
66
+ if options[:strict] && native_doc.respond_to?(:root) &&
67
+ native_doc.root.nil?
68
+ raise Moxml::ParseError.new(
69
+ "document has no root element",
70
+ source: xml.is_a?(String) ? xml[0..100] : nil,
71
+ )
49
72
  end
50
73
 
51
74
  ctx = _context || Context.new(:rexml)
@@ -435,7 +458,7 @@ module Moxml
435
458
  end
436
459
 
437
460
  def processing_instruction_content(node)
438
- node.content
461
+ node.content.to_s
439
462
  end
440
463
 
441
464
  def set_processing_instruction_content(node, content)
@@ -458,17 +481,26 @@ module Moxml
458
481
  return "" unless element
459
482
 
460
483
  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
484
+ begin
485
+ element.children.each do |child|
486
+ case child
487
+ when ::REXML::Text
488
+ # Preserve original spacing from text nodes exactly including newlines and all whitespace
489
+ text += child.value
490
+ when ::REXML::Element
491
+ # Extract text recursively from child element
492
+ child_text = extract_text_recursively(child)
493
+ # Concatenate directly like other adapters - NO SPACE INSERTION
494
+ text += child_text
495
+ end
471
496
  end
497
+ rescue ::RuntimeError => e
498
+ # rexml expands entities lazily: the parse accepts, the
499
+ # read hits the expansion guard (D-16). The facade names
500
+ # it instead of leaking the bare RuntimeError.
501
+ raise Moxml::Error,
502
+ "entity expansion limit exceeded (rexml guard, D-16): " \
503
+ "#{e.message}"
472
504
  end
473
505
  # Don't strip - preserve original spacing including newlines
474
506
  text
@@ -612,11 +644,29 @@ module Moxml
612
644
  # namespaced elements never match (issue #395).
613
645
  namespaces = namespaces.transform_keys(&:to_s) if
614
646
  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
647
+ results = if namespaces && !namespaces.empty?
648
+ ::REXML::XPath.match(node, expression, namespaces).to_a
649
+ else
650
+ ::REXML::XPath.match(node, expression).to_a
651
+ end
652
+ # REXML answers unions in expression order (//a | //b on
653
+ # <r><b/><a/></r> answers a,a,b); path results are
654
+ # document-ordered by the model (TODO.sota/02, M3) —
655
+ # re-order here, the oga lane's pattern.
656
+ return results if results.empty?
657
+
658
+ # Scalar arm: count()/string()/boolean()/arithmetic answer a
659
+ # one-element array holding the value; the adapter contract
660
+ # passes scalars through unwrapped (numbers as Float,
661
+ # uniform with the other adapters).
662
+ unless results.first.is_a?(::REXML::Node)
663
+ value = results.first
664
+ return value.is_a?(Numeric) ? value.to_f : value
619
665
  end
666
+
667
+ ctx = Context.new(:rexml)
668
+ wrapped = results.map { |n| Moxml::Node.wrap(n, ctx) }
669
+ Moxml::XPath::Ordering.document_order(wrapped).map(&:native)
620
670
  rescue ::REXML::ParseException => e
621
671
  raise Moxml::XPathError.new(
622
672
  e.message,
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)
@@ -163,11 +163,21 @@ module Moxml
163
163
  end
164
164
 
165
165
  def create_element(name)
166
- Wrappers::Element.new(adapter.create_element(name, owner_doc: @native), context)
166
+ native = adapter.create_element(name, owner_doc: @native)
167
+ # leptris-ruby#508 mints registered klasses at create: the node
168
+ # IS a contract-carrying identity (@native is self) — prime it
169
+ # in place instead of shelling a second wrapper (a raw mint
170
+ # reaching ==/adapter dies on the nil @context).
171
+ return native.prime_contract(native, context, adapter) if native.is_a?(Moxml::Node)
172
+
173
+ Wrappers::Element.new(native, context)
167
174
  end
168
175
 
169
176
  def create_text(content)
170
- Wrappers::Text.new(adapter.create_text(content, owner_doc: @native), context)
177
+ native = adapter.create_text(content, owner_doc: @native)
178
+ return native.prime_contract(native, context, adapter) if native.is_a?(Moxml::Node)
179
+
180
+ Wrappers::Text.new(native, context)
171
181
  end
172
182
 
173
183
  def create_cdata(content)
@@ -227,9 +237,13 @@ module Moxml
227
237
  raise Error, "Document already has a root element"
228
238
  else
229
239
  adapter.add_child(@native, node.native)
230
- # Refresh native for adapters where identity changes (e.g., LibXML doc.root=)
240
+ # Refresh native for adapters where identity changes (e.g., LibXML doc.root=).
241
+ # Native equality via the adapter, not wrapper !=: re-mints can
242
+ # arrive without context primed (1.9.373 actual_native), and
243
+ # wrapper == dispatches through the adapter (issue #409-class).
231
244
  refreshed = adapter.actual_native(node.native, @native)
232
- node.refresh_native!(refreshed) if refreshed && refreshed != node.native
245
+ node.refresh_native!(refreshed) if refreshed &&
246
+ !adapter.same_node?(refreshed, node.native)
233
247
  end
234
248
  node.parent_node = self
235
249
  invalidate_children_cache!
@@ -258,7 +272,7 @@ module Moxml
258
272
  # - NodeSet: already wrapped, return directly
259
273
  # - Array / LazyNodeSet: wrap in NodeSet
260
274
  case result
261
- when NodeSet, Float, String, TrueClass, FalseClass, NilClass
275
+ when NodeSet, Numeric, String, TrueClass, FalseClass, NilClass
262
276
  result
263
277
  when Array, LazyNodeSet
264
278
  NodeSet.new(result, context)
@@ -268,12 +282,12 @@ module Moxml
268
282
  end
269
283
  end
270
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.
271
288
  def at_xpath(expression, namespaces = nil)
272
- expression = lax_document_path(expression) if relative_name_test?(expression)
273
-
274
- if (native_node = adapter.at_xpath(@native, expression, namespaces))
275
- Moxml::Node.wrap(native_node, context)
276
- end
289
+ result = xpath(expression, namespaces)
290
+ result.is_a?(NodeSet) ? result.first : result
277
291
  end
278
292
 
279
293
  # Quick element creation and addition
data/lib/moxml/node.rb CHANGED
@@ -367,9 +367,12 @@ module Moxml
367
367
  Materializer.materialize_fields(self, &block)
368
368
  end
369
369
 
370
+ # Sugar over xpath: the first node of a node-set; scalar results
371
+ # (count(), string(), boolean()) pass through unwrapped and
372
+ # uniform across adapters.
370
373
  def at_xpath(expression, namespaces = {})
371
- Moxml::Node.wrap(adapter.at_xpath(@native, expression, namespaces),
372
- context)
374
+ result = xpath(expression, namespaces)
375
+ result.is_a?(NodeSet) ? result.first : result
373
376
  end
374
377
 
375
378
  # Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
@@ -410,10 +413,16 @@ module Moxml
410
413
  end
411
414
 
412
415
  # Nokogiri-compatible depth-first traversal: yields self, then
413
- # children recursively (document-order visitor).
416
+ # children recursively (document-order visitor). Document-level
417
+ # parts (Declaration, Doctype) are state, not nodes: they are
418
+ # never yielded, and their (adapter-specific, sometimes missing)
419
+ # child faces are never consulted (issue-free by the model's
420
+ # object-model rule; found by the taxonomy differential).
414
421
  def traverse(&block)
415
422
  return to_enum(:traverse) unless block
416
423
 
424
+ return self if is_a?(Moxml::Declaration) || is_a?(Moxml::Doctype)
425
+
417
426
  yield self
418
427
  children.each { |child| child.traverse(&block) }
419
428
  self
@@ -682,9 +691,16 @@ module Moxml
682
691
  end
683
692
  end
684
693
 
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.
694
+ # The node's kind (the model's Kind face): :document, :element,
695
+ # :text, :comment, :cdata, :processing_instruction, :doctype,
696
+ # :declaration, :entity_reference, :namespace, :attribute. The
697
+ # adapter's type probe is an FFI call per invocation; the type is
698
+ # fixed for a node's lifetime, so the first answer is memoized on
699
+ # the wrapper.
700
+ def node_type
701
+ node_type_cached
702
+ end
703
+
688
704
  def node_type_cached
689
705
  @node_type_cached ||= adapter.node_type(@native)
690
706
  end
@@ -847,6 +863,18 @@ module Moxml
847
863
  def self.wrap_with(node, context, adapter)
848
864
  return nil if node.nil?
849
865
 
866
+ # Identity natives ARE the canonical wrapper (leptris C-mint):
867
+ # @native is self, so canonicalization, the cache probes and
868
+ # the re-mint below all answer the receiver. One native ivar
869
+ # read decides; shells (@native != self) take the full path.
870
+ # The C mint leaves the node un-primed (@context unset) — prime
871
+ # on first wrap. Conditionally: an already-primed identity (a
872
+ # walked node) must not lose @parent_node to a re-prime.
873
+ if node.is_a?(Node) && node.instance_variable_get(:@native).equal?(node)
874
+ node.prime_contract(node, context, adapter) unless node.instance_variable_defined?(:@context)
875
+ return node
876
+ end
877
+
850
878
  # One C node may have several Ruby representations (leptris
851
879
  # native/binding duality, moxml#311); every wrap resolves to
852
880
  # the canonical one so memo holders share one invalidation
@@ -119,6 +119,17 @@ module Moxml
119
119
 
120
120
  # Nokogiri-compatible: Array#flatten and multiple-assignment
121
121
  # treat a NodeSet as its wrapped-element array via to_ary.
122
+ # The same node-set in document order (XPath path results are
123
+ # conventionally document-ordered; the compiler batches multi-step
124
+ # evaluation and must re-order). Wrappers are ordered, then the
125
+ # internal natives are re-paired — no re-wrapping.
126
+ def document_order
127
+ return self if native_nodes.size < 2
128
+
129
+ ordered = Moxml::XPath::Ordering.document_order(to_a)
130
+ NodeSet.new(ordered.map(&:native), @context, @parent_node)
131
+ end
132
+
122
133
  def to_ary
123
134
  to_a
124
135
  end