moxml 0.5.149 → 0.5.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/performance-release.yml +79 -0
  5. data/.github/workflows/performance.yml +59 -0
  6. data/Rakefile +23 -0
  7. data/benchmark/README.md +106 -0
  8. data/benchmark/scoreboard.json +115 -0
  9. data/benchmark/scoreboard.rb +556 -0
  10. data/bin/generate-model-coverage +21 -0
  11. data/docs/_pages/adapter-compatibility.adoc +3 -3
  12. data/docs/_pages/divergences.adoc +32 -41
  13. data/docs/_pages/ecosystem.adoc +62 -0
  14. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  15. data/docs/_pages/headed-ox.adoc +2 -2
  16. data/docs/_pages/index.adoc +4 -0
  17. data/docs/_pages/model-coverage.adoc +57 -0
  18. data/docs/_pages/performance.adoc +29 -0
  19. data/docs/_pages/why-moxml.adoc +87 -0
  20. data/docs/_references/processing-model.adoc +42 -3
  21. data/lib/moxml/adapter/base.rb +80 -0
  22. data/lib/moxml/adapter/leptris.rb +238 -4
  23. data/lib/moxml/adapter/libxml.rb +34 -2
  24. data/lib/moxml/adapter/nokogiri.rb +10 -0
  25. data/lib/moxml/adapter/oga.rb +39 -7
  26. data/lib/moxml/adapter/ox.rb +8 -0
  27. data/lib/moxml/adapter/rexml.rb +17 -2
  28. data/lib/moxml/attribute_resolver.rb +4 -0
  29. data/lib/moxml/c14n/data_model.rb +8 -0
  30. data/lib/moxml/c14n/processor.rb +17 -8
  31. data/lib/moxml/context.rb +53 -2
  32. data/lib/moxml/document.rb +28 -4
  33. data/lib/moxml/element.rb +13 -1
  34. data/lib/moxml/node.rb +85 -11
  35. data/lib/moxml/node_set.rb +31 -1
  36. data/lib/moxml/relaxng.rb +62 -0
  37. data/lib/moxml/sax/block_handler.rb +7 -0
  38. data/lib/moxml/sax/handler.rb +20 -0
  39. data/lib/moxml/version.rb +1 -1
  40. data/lib/moxml/xpath.rb +28 -0
  41. data/lib/moxml.rb +1 -0
  42. data/spec/consistency/adapter_parity_spec.rb +22 -0
  43. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  44. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  45. data/spec/consistency/published_docs_spec.rb +49 -0
  46. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  47. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  48. data/spec/moxml/adapter/leptris_spec.rb +113 -3
  49. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  50. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  51. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  52. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  53. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  54. data/spec/moxml/c14n/subset_spec.rb +2 -12
  55. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  56. data/spec/moxml/element_indent_spec.rb +34 -0
  57. data/spec/moxml/node_spec.rb +0 -3
  58. data/spec/moxml/relax_ng_spec.rb +67 -0
  59. data/spec/moxml/sax_spec.rb +45 -74
  60. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  61. data/spec/support/input_taxonomy.rb +7 -0
  62. data/spec/support/model_coverage.rb +159 -0
  63. metadata +17 -2
data/lib/moxml/context.rb CHANGED
@@ -90,7 +90,10 @@ module Moxml
90
90
  end
91
91
 
92
92
  def bump_children_generation
93
- @children_generation = children_generation + 1
93
+ # One dispatch, not two: children_generation re-dispatched just
94
+ # to default nil. Shared counter — node wrappers' children memos
95
+ # key on it.
96
+ @children_generation = @children_generation.to_i + 1
94
97
  end
95
98
 
96
99
  def namespace_scope_generation
@@ -101,6 +104,22 @@ module Moxml
101
104
  @namespace_scope_generation += 1
102
105
  end
103
106
 
107
+ # Namespace scopes only need the per-mutation generation bump
108
+ # while namespaces can exist. A builder-only document (no parse,
109
+ # no xmlns write) never has one, and the bump sat on the attach
110
+ # hot path (deletion experiment: build row 2.128x -> 1.923x).
111
+ # Every namespace introduction route marks the context active
112
+ # first — parse faces and the xmlns attribute-write face — so
113
+ # the sticky flag never misses an introduction.
114
+ def namespace_scopes_active?
115
+ @namespace_scopes_active = false if @namespace_scopes_active.nil?
116
+ @namespace_scopes_active
117
+ end
118
+
119
+ def mark_namespaces_active!
120
+ @namespace_scopes_active = true
121
+ end
122
+
104
123
  def entity_registry
105
124
  @entity_registry ||= build_entity_registry
106
125
  end
@@ -128,6 +147,7 @@ module Moxml
128
147
  # Parse with adapter, passing self (context) so adapter can use our config
129
148
  parsed_options = default_options.merge(options)
130
149
  doc = config.adapter.parse(xml_string, parsed_options, self)
150
+ mark_namespaces_active!
131
151
 
132
152
  # Set declaration flag on Document wrapper (proper OOP)
133
153
  doc.has_xml_declaration = has_declaration if doc.is_a?(Document)
@@ -198,6 +218,19 @@ module Moxml
198
218
  Moxml::XSD::Schema.new(handle, self)
199
219
  end
200
220
 
221
+ # Compile a RELAX NG schema from source or from a file path (the
222
+ # engine resolves relative includes against the file's directory).
223
+ # Adapters without an RNG engine raise Moxml::NotImplementedError.
224
+ def relaxng(schema_source)
225
+ handle = config.adapter.relaxng_compile(schema_source.to_s)
226
+ Moxml::RelaxNG::Schema.new(handle, self)
227
+ end
228
+
229
+ def relaxng_file(path)
230
+ handle = config.adapter.relaxng_compile_file(path.to_s)
231
+ Moxml::RelaxNG::Schema.new(handle, self)
232
+ end
233
+
201
234
  def xslt(stylesheet)
202
235
  source = case stylesheet
203
236
  when Moxml::Document then stylesheet.to_xml
@@ -220,7 +253,9 @@ module Moxml
220
253
  # are the result either way.
221
254
  def parse_fragment(xml)
222
255
  adapter = config.adapter
223
- adapter.parse_fragment(xml, self).map do |node|
256
+ nodes = adapter.parse_fragment(xml, self)
257
+ mark_namespaces_active!
258
+ nodes.map do |node|
224
259
  # Adapters that tie a parent_node lifetime chain (#245)
225
260
  # return wrapped nodes — pass them through untouched;
226
261
  # re-wrapping would race the weak wrapper map.
@@ -229,6 +264,7 @@ module Moxml
229
264
  end
230
265
 
231
266
  def iterparse(xml, mode: :top_level, &block)
267
+ mark_namespaces_active!
232
268
  config.adapter.iterparse(xml, mode, self, &block)
233
269
  end
234
270
 
@@ -282,6 +318,21 @@ module Moxml
282
318
  raise ArgumentError, "Handler must inherit from Moxml::SAX::Handler"
283
319
  end
284
320
 
321
+ # Loud degradation: an engine that cannot report an event family
322
+ # raises the named error when the handler subscribes, rather than
323
+ # answering a silently incomplete stream (model, SAX).
324
+ SAX::Handler::EVENT_METHODS.each_key do |family|
325
+ next unless handler.handles?(family)
326
+ next if config.adapter.sax_event_family_supported?(family)
327
+
328
+ raise Moxml::NotImplementedError.new(
329
+ "SAX #{family} events are not supported by the " \
330
+ "#{config.adapter.name.split('::').last} adapter",
331
+ feature: "sax_#{family}",
332
+ adapter: config.adapter.name,
333
+ )
334
+ end
335
+
285
336
  # Delegate to adapter
286
337
  config.adapter.sax_parse(xml, handler)
287
338
  end
@@ -163,7 +163,17 @@ module Moxml
163
163
  end
164
164
 
165
165
  def create_element(name)
166
- native = adapter.create_element(name, owner_doc: @native)
166
+ # The preprimed mint (leptris binding with the C-primed
167
+ # create_element): the mint already wrote @context/@adapter in
168
+ # C, so the returned node IS the finished wrapper and the Ruby
169
+ # prime pass disappears from the build path.
170
+ native = if adapter.mints_primed?
171
+ adapter.create_primed_element(name, owner_doc: @native,
172
+ context: context,
173
+ adapter: adapter)
174
+ else
175
+ adapter.create_element(name, owner_doc: @native)
176
+ end
167
177
  # leptris-ruby#508 mints registered klasses at create: the node
168
178
  # IS a contract-carrying identity (@native is self) — prime it
169
179
  # in place instead of shelling a second wrapper (a raw mint
@@ -282,14 +292,28 @@ module Moxml
282
292
  end
283
293
  end
284
294
 
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.
295
+ # The first node of a node-set; scalar results (count(), string(),
296
+ # boolean()) pass through unwrapped and uniform across adapters.
297
+ # Adapters whose at_xpath lane falls back to the reference engine
298
+ # when the native answer is empty (leptris, whose node-set index
299
+ # is stale after sibling insertion) use it.
288
300
  def at_xpath(expression, namespaces = nil)
301
+ if adapter.native_at_xpath?
302
+ expression = lax_document_path(expression) if relative_name_test?(expression)
303
+ result = adapter.at_xpath(@native, expression, namespaces)
304
+ return result if scalar_result?(result)
305
+
306
+ return Moxml::Node.wrap(result, context)
307
+ end
289
308
  result = xpath(expression, namespaces)
290
309
  result.is_a?(NodeSet) ? result.first : result
291
310
  end
292
311
 
312
+ def scalar_result?(result)
313
+ result.is_a?(Numeric) || result.is_a?(String) ||
314
+ result == true || result == false || result.nil?
315
+ end
316
+
293
317
  # Quick element creation and addition
294
318
  def add_element(name, attributes = {}, &block)
295
319
  elem = create_element(name)
data/lib/moxml/element.rb CHANGED
@@ -276,6 +276,7 @@ module Moxml
276
276
  def add_namespace(prefix, uri)
277
277
  adapter.create_namespace(@native, prefix, uri,
278
278
  namespace_validation_mode: context.config.namespace_validation_mode)
279
+ context.mark_namespaces_active!
279
280
  invalidate_namespace_cache!
280
281
  self
281
282
  rescue ValidationError => e
@@ -530,8 +531,19 @@ module Moxml
530
531
 
531
532
  # Clear the namespace caches and bump the context's scope
532
533
  # generation so every wrapper — including ones not reachable from
533
- # any children cache — recomputes on next read.
534
+ # any children cache — recomputes on next read. While namespaces
535
+ # cannot exist (context.namespace_scopes_active? is false) the
536
+ # memos can only hold namespace-free reads, so skipping the whole
537
+ # body is exact — and the nil-assignment form would MATERIALIZE
538
+ # three unset ivars per attached node, which is the cost this
539
+ # gate exists to remove. Every introduction route — parse faces
540
+ # and the xmlns write face — marks the context BEFORE its own
541
+ # invalidation, so the introducing mutation still clears and
542
+ # bumps; in-scope reads are generation-keyed, so other wrappers
543
+ # recompute on the bump.
534
544
  def invalidate_namespace_cache!
545
+ return unless context.namespace_scopes_active?
546
+
535
547
  @namespaces = nil
536
548
  @namespace_definitions = nil
537
549
  @in_scope_namespaces = nil
data/lib/moxml/node.rb CHANGED
@@ -30,12 +30,19 @@ module Moxml
30
30
  def prime_contract(native, context, adapter = nil, node_type = nil)
31
31
  @context = context
32
32
  @native = native
33
- @parent_node = nil
34
- @adapter = adapter
35
- @node_type_cached = node_type
33
+ # @parent_node and @node_type_cached stay UNSET when empty —
34
+ # every reader is nil-tolerant, and a nil write materializes the
35
+ # ivar (object-shape transition) on every created node.
36
+ @adapter = adapter unless adapter.nil?
37
+ @node_type_cached = node_type unless node_type.nil?
36
38
  self
37
39
  end
38
40
 
41
+ # Document-derived wrapper state (the entity-marker verdict) flows
42
+ # parent -> child during prewrapped traversal; adapters that keep
43
+ # no per-wrapper document state carry nothing.
44
+ def inherit_document_state(_parent); end
45
+
39
46
  def refresh_native!(new_native)
40
47
  unless new_native.equal?(@native)
41
48
  # Cache-stable binding nodes carry the wrapper in an ivar
@@ -209,10 +216,16 @@ module Moxml
209
216
  def attach_child(node)
210
217
  context.bump_children_generation
211
218
  node = prepare_node(node)
212
- adapter.add_child(@native, node.native)
219
+ oc = node.context
220
+ if !oc.nil? && !oc.equal?(context) &&
221
+ oc.namespace_scopes_active?
222
+ context.mark_namespaces_active!
223
+ end
224
+ a = adapter
225
+ a.add_child(@native, node.native)
213
226
  # Refresh native in case adapter changed identity (e.g., LibXML
214
227
  # doc.root=); stable-identity adapters skip the round trip.
215
- unless adapter.native_identity_stable?
228
+ unless a.native_identity_stable?
216
229
  refreshed = adapter.actual_native(node.native, @native)
217
230
  node.refresh_native!(refreshed) if refreshed && refreshed != node.native
218
231
  end
@@ -226,7 +239,9 @@ module Moxml
226
239
  def attach_previous_sibling(node)
227
240
  context.bump_children_generation
228
241
  node = prepare_node(node)
229
- adapter.add_previous_sibling(@native, node.native)
242
+ absorb_namespace_activity(node)
243
+ inserted = adapter.add_previous_sibling(@native, node.native)
244
+ refresh_inserted_native(node, inserted)
230
245
  # Invalidate the parent's memoized children list. The wrapper-side
231
246
  # @parent_node link is only set when this node was yielded through a
232
247
  # parent-aware NodeSet; nodes obtained via at()/xpath() carry no such
@@ -239,12 +254,31 @@ module Moxml
239
254
  def attach_next_sibling_to(anchor, node)
240
255
  context.bump_children_generation
241
256
  node = prepare_node(node)
242
- adapter.add_next_sibling(anchor.native, node.native)
257
+ absorb_namespace_activity(node)
258
+ inserted = adapter.add_next_sibling(anchor.native, node.native)
259
+ refresh_inserted_native(node, inserted)
243
260
  anchor.parent&.invalidate_children_cache!
244
261
  anchor.invalidate_parent_children_cache!
245
262
  self
246
263
  end
247
264
 
265
+ # A node adopted from ANOTHER context can carry namespaces this
266
+ # context's own faces (parse, xmlns writes) never saw; import the
267
+ # source context's activity flag so the gated scope-generation
268
+ # bump stays armed here too.
269
+ def absorb_namespace_activity(node)
270
+ oc = node.context
271
+ return if oc.nil? || oc.equal?(context)
272
+ context.mark_namespaces_active! if oc.namespace_scopes_active?
273
+ end
274
+
275
+ def refresh_inserted_native(node, inserted)
276
+ return unless adapter.add_sibling_returns_inserted?
277
+ return if inserted.nil? || inserted.equal?(node.native)
278
+
279
+ node.refresh_native!(inserted)
280
+ end
281
+
248
282
  def remove
249
283
  # Nokogiri parity: unlinking an already-detached node is a
250
284
  # no-op returning self. Re-attach idioms like
@@ -296,6 +330,21 @@ module Moxml
296
330
  end
297
331
 
298
332
  def to_xml(options = {})
333
+ # Element-level indentation is a declared capability: engines
334
+ # that ignore it must not answer unindented output silently
335
+ # (D-09). indent: 0 and the no-option path stay common.
336
+ indent = options[:indent]
337
+ if !is_a?(Document) && indent.is_a?(Integer) && indent.positive? &&
338
+ !adapter.element_indent_supported?
339
+ raise Moxml::AdapterError.new(
340
+ "element-level indentation is not supported by the " \
341
+ "#{adapter.name.split('::').last} adapter; serialize the " \
342
+ "document or use indent: 0",
343
+ adapter: adapter.name,
344
+ operation: "serialize",
345
+ )
346
+ end
347
+
299
348
  # Determine if we should include XML declaration
300
349
  # For Document nodes: check native then wrapper, unless explicitly overridden
301
350
  # For other nodes: default to no declaration unless explicitly set
@@ -367,14 +416,26 @@ module Moxml
367
416
  Materializer.materialize_fields(self, &block)
368
417
  end
369
418
 
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.
419
+ # The first node of a node-set; scalar results (count(), string(),
420
+ # boolean()) pass through unwrapped and uniform across adapters.
421
+ # Adapters with a native first-only face use it (see
422
+ # Document#at_xpath).
373
423
  def at_xpath(expression, namespaces = {})
424
+ if adapter.native_at_xpath?
425
+ result = adapter.at_xpath(@native, expression, namespaces)
426
+ return result if scalar_result?(result)
427
+
428
+ return Moxml::Node.wrap(result, context)
429
+ end
374
430
  result = xpath(expression, namespaces)
375
431
  result.is_a?(NodeSet) ? result.first : result
376
432
  end
377
433
 
434
+ def scalar_result?(result)
435
+ result.is_a?(Numeric) || result.is_a?(String) ||
436
+ result == true || result == false || result.nil?
437
+ end
438
+
378
439
  # Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
379
440
  # at/search auto-detect XPath vs CSS; the XPath-first fallback
380
441
  # covers the dominant caller shape (XPath expression strings)
@@ -732,7 +793,10 @@ module Moxml
732
793
  protected
733
794
 
734
795
  def invalidate_children_cache!
735
- @children = nil
796
+ # Only nil an EXISTING memo: nil-assignment materializes an
797
+ # unset ivar (object-shape transition), and the builder hot
798
+ # path invalidates parents whose children memo was never read.
799
+ @children = nil unless @children.nil?
736
800
  end
737
801
 
738
802
  # Invalidate parent's cached children when this node
@@ -906,6 +970,16 @@ module Moxml
906
970
  # input native keeps its registration so later wraps of the
907
971
  # same base object hit the cache instead of re-minting.
908
972
  if (extended = adapter.wrap_native(node, type, context))
973
+ # A native that already carries the contract IS the wrapper
974
+ # (leptris C mint): prime it in place and skip the WeakMap
975
+ # round trip — the primed @native-identity answers every
976
+ # later wrap through the fast path above.
977
+ if extended.equal?(node) && extended.is_a?(Node)
978
+ extended.prime_contract(extended, context, adapter, type) unless
979
+ extended.instance_variable_defined?(:@context)
980
+ return extended
981
+ end
982
+
909
983
  extended.prime_contract(extended, context, adapter, type)
910
984
  context.register_wrapper(node, extended)
911
985
  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|
@@ -227,5 +240,22 @@ module Moxml
227
240
  end
228
241
  wrapped
229
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
230
260
  end
231
261
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # RELAX NG schema validation (TODO.sota/05): a facade for engines
5
+ # that ship an RNG validator. Engines without one raise the named
6
+ # Moxml::NotImplementedError — the face is never silently absent.
7
+ #
8
+ # ctx = Moxml.new(:leptris)
9
+ # schema = ctx.relaxng_file("schema.rng")
10
+ # schema.valid?(doc) # => true/false
11
+ # schema.validate_errors(doc) # => [] or [{line:, column:, message:}]
12
+ # schema.validate(doc) # => [] or ["line:col: error: message"]
13
+ #
14
+ # Adapters without an engine raise at compile; RelaxNG.supported?
15
+ # answers without raising (the XSD facade's pattern).
16
+ module RelaxNG
17
+ class << self
18
+ # Whether the context's adapter ships a RELAX NG validator.
19
+ def supported?(context)
20
+ adapter = context.config.adapter
21
+ adapter.const_defined?(:RELAXNG_SUPPORTED) &&
22
+ adapter.const_get(:RELAXNG_SUPPORTED)
23
+ end
24
+ end
25
+
26
+ # A compiled schema (adapter-owned handle).
27
+ class Schema
28
+ def initialize(handle, context)
29
+ @handle = handle
30
+ @context = context
31
+ end
32
+
33
+ def valid?(document)
34
+ @context.config.adapter.relaxng_valid?(@handle, native(document))
35
+ end
36
+
37
+ # Structured report: [{line:, column:, message:}] (empty = valid).
38
+ def validate_errors(document)
39
+ @context.config.adapter
40
+ .relaxng_validate_errors(@handle, native(document))
41
+ end
42
+
43
+ # Nokogiri shape: error strings (empty = valid).
44
+ def validate(document)
45
+ validate_errors(document).map do |error|
46
+ line = error[:line]
47
+ if line&.to_i&.positive?
48
+ "#{line}:#{error[:column]}: error: #{error[:message]}"
49
+ else
50
+ error[:message]
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def native(document)
58
+ document.is_a?(Moxml::Document) ? document.native : document
59
+ end
60
+ end
61
+ end
62
+ end
@@ -191,6 +191,13 @@ module Moxml
191
191
  def on_warning(message)
192
192
  @handlers[:warning]&.call(message)
193
193
  end
194
+
195
+ # A block handler subscribes to a family only when its DSL block
196
+ # was registered — it overrides every callback, so method
197
+ # ownership cannot answer interest here.
198
+ def handles?(family)
199
+ @handlers.key?(family)
200
+ end
194
201
  end
195
202
  end
196
203
  end
@@ -98,6 +98,26 @@ module Moxml
98
98
  raise error
99
99
  end
100
100
 
101
+ # SAX event families that engines may or may not report as
102
+ # distinct events, with their handler methods. An engine that
103
+ # cannot report a family must raise the named error when the
104
+ # handler subscribes — never fold the events silently.
105
+ EVENT_METHODS = {
106
+ cdata: :on_cdata,
107
+ comment: :on_comment,
108
+ processing_instruction: :on_processing_instruction,
109
+ }.freeze
110
+
111
+ # Whether this handler subscribes to an event family: the
112
+ # callback was overridden (class handlers) or registered (block
113
+ # handlers).
114
+ #
115
+ # @param family [Symbol] one of EVENT_METHODS keys
116
+ # @return [Boolean]
117
+ def handles?(family)
118
+ method(EVENT_METHODS.fetch(family)).owner != Moxml::SAX::Handler
119
+ end
120
+
101
121
  # Called when a non-fatal warning occurs
102
122
  #
103
123
  # Default implementation ignores warnings.
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.149"
4
+ VERSION = "0.5.151"
5
5
  end
data/lib/moxml/xpath.rb CHANGED
@@ -26,5 +26,33 @@ module Moxml
26
26
  autoload :Node, "moxml/xpath/ruby/node"
27
27
  autoload :Generator, "moxml/xpath/ruby/generator"
28
28
  end
29
+
30
+ module_function
31
+
32
+ # Evaluate an expression through the pure-Ruby XPath 1.0 engine —
33
+ # the model's reference lane. Adapters route shapes their native
34
+ # engine cannot answer here (the `xmlns:` idiom, `text()` node
35
+ # tests, ...). Returns the engine's answer: an Array of wrapped
36
+ # nodes in document order, a NodeSet, or a scalar.
37
+ def evaluate(node, expression, namespaces = {}, adapter: nil)
38
+ context = node.is_a?(Moxml::Node) ? node.context : ::Moxml::Context.new(adapter)
39
+ wrapped = node.is_a?(Moxml::Node) ? node : Moxml::Node.wrap(node, context)
40
+ compiled = Compiler.compile_with_cache(Parser.parse(expression),
41
+ namespaces: namespaces)
42
+ compiled.call(wrapped)
43
+ end
44
+
45
+ # The adapter-contract form of #evaluate: Array<native> | scalar.
46
+ def evaluate_natives(node, expression, namespaces = {}, adapter: nil)
47
+ result = evaluate(node, expression, namespaces, adapter: adapter)
48
+ case result
49
+ when Array
50
+ result.map { |n| n.is_a?(Moxml::Node) ? n.native : n }
51
+ when NodeSet
52
+ result.to_a.map(&:native)
53
+ else
54
+ result
55
+ end
56
+ end
29
57
  end
30
58
  end
data/lib/moxml.rb CHANGED
@@ -105,6 +105,7 @@ module Moxml
105
105
  autoload :Reference, "moxml/reference"
106
106
  autoload :XSLT, "moxml/xslt"
107
107
  autoload :XSD, "moxml/xsd"
108
+ autoload :RelaxNG, "moxml/relaxng"
108
109
 
109
110
  # Error hierarchy — each subclass autoloads from the same file
110
111
  autoload :Error, "moxml/error"
@@ -5,6 +5,28 @@
5
5
  RSpec.describe "Adapter Examples" do
6
6
  let(:xml) { "<root><child>text</child></root>" }
7
7
 
8
+ describe "Document children listing" do
9
+ it "never lists the XML declaration as a child" do
10
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
11
+ doc = Moxml.new(adapter)
12
+ .parse(%(<?xml version="1.0"?><r/>))
13
+ expect(doc.children.map(&:node_type)).to eq([:element]),
14
+ "#{adapter}: declaration leaked " \
15
+ "into children (model: state, not a node)"
16
+ expect(doc.has_xml_declaration).to be(true),
17
+ "#{adapter}: declaration state lost"
18
+ end
19
+ end
20
+
21
+ it "lists the doctype per the Nokogiri contract" do
22
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
23
+ doc = Moxml.new(adapter).parse(%(<!DOCTYPE r><r/>))
24
+ expect(doc.children.map(&:node_type)).to eq(%i[doctype element]),
25
+ "#{adapter}: doctype listing"
26
+ end
27
+ end
28
+ end
29
+
8
30
  describe "Serialization consistency" do
9
31
  it "produces equivalent XML across adapters",
10
32
  skip: "No easy way to exclude the declaration from Nokogiri documents" do
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Citation hygiene (TODO.sota/07): a spec or doc that calls a
6
+ # divergence "pre-existing" or "documented" must resolve it to a
7
+ # register ID (D-NN) on the same line. The register is the only place
8
+ # such a claim is allowed to stand on its own.
9
+ CITATION_PHRASE = /(?:\bpre-existing\b|\bpreexisting\b|\bdocumented (?:divergence|limitation)\b)/i
10
+
11
+ RSpec.describe "divergence citations" do
12
+ def offenses(paths)
13
+ paths.flat_map do |path|
14
+ File.readlines(path).each_with_index.filter_map do |line, idx|
15
+ claim = line.match?(CITATION_PHRASE)
16
+ cited = line.match?(/D-\d+/)
17
+ "#{path}:#{idx + 1}: #{line.strip}" if claim && !cited
18
+ end
19
+ end
20
+ end
21
+
22
+ it "resolves every pre-existing/documented divergence claim to a register ID" do
23
+ paths = (Dir.glob("spec/**/*.rb") + Dir.glob("docs/**/*.adoc"))
24
+ .reject { |path| path.end_with?("citation_hygiene_spec.rb") }
25
+ found = offenses(paths)
26
+ expect(found).to be_empty,
27
+ "name a D-NN (docs/_pages/divergences.adoc) or drop " \
28
+ "the claim:\n#{found.join("\n")}"
29
+ end
30
+ end