moxml 0.1.26 → 0.3.0

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +6 -1
  6. data/lib/moxml/adapter/base.rb +53 -56
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +137 -117
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +139 -55
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +64 -51
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/builder.rb +0 -18
  35. data/lib/moxml/config.rb +29 -5
  36. data/lib/moxml/context.rb +33 -0
  37. data/lib/moxml/document.rb +1 -1
  38. data/lib/moxml/element.rb +29 -12
  39. data/lib/moxml/entity/reference.rb +28 -0
  40. data/lib/moxml/entity/restorer.rb +95 -0
  41. data/lib/moxml/entity.rb +126 -0
  42. data/lib/moxml/entity_registry.rb +1 -1
  43. data/lib/moxml/error.rb +0 -42
  44. data/lib/moxml/node.rb +135 -10
  45. data/lib/moxml/node_set.rb +1 -1
  46. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  47. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  48. data/lib/moxml/version.rb +1 -1
  49. data/lib/moxml/xml_emitter.rb +71 -0
  50. data/lib/moxml/xpath/compiler.rb +143 -8
  51. data/lib/moxml/xpath/parser.rb +22 -15
  52. data/lib/moxml.rb +3 -3
  53. data/spec/examples/readme_examples_spec.rb +0 -4
  54. data/spec/examples/xpath_examples_spec.rb +0 -11
  55. data/spec/integration/all_adapters_spec.rb +18 -1
  56. data/spec/integration/sax_parity_spec.rb +58 -0
  57. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  58. data/spec/integration/shared_examples/high_level/{document_builder_behavior.rb → parse_behavior.rb} +1 -2
  59. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  60. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  61. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  62. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +15 -8
  63. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  64. data/spec/moxml/adapter/libxml_internals_spec.rb +1 -1
  65. data/spec/moxml/adapter/ox_spec.rb +20 -6
  66. data/spec/moxml/adapter/platform_spec.rb +15 -2
  67. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  68. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  69. data/spec/moxml/config_spec.rb +0 -1
  70. data/spec/moxml/context_spec.rb +22 -0
  71. data/spec/moxml/doctype_spec.rb +1 -1
  72. data/spec/moxml/entity_spec.rb +76 -0
  73. data/spec/moxml/error_spec.rb +0 -50
  74. data/spec/moxml/node_spec.rb +104 -0
  75. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  76. data/spec/moxml/xml_emitter_spec.rb +64 -0
  77. data/spec/moxml/xpath/axes_spec.rb +112 -0
  78. data/spec/moxml/xpath/parser_spec.rb +6 -0
  79. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  80. data/spec/performance/benchmark_spec.rb +13 -6
  81. data/spec/performance/thread_safety_spec.rb +0 -4
  82. data/spec/spec_helper.rb +5 -1
  83. metadata +23 -6
  84. data/lib/moxml/adapter/libxml/entity_restorer.rb +0 -94
  85. data/lib/moxml/document_builder.rb +0 -167
  86. data/spec/moxml/document_builder_spec.rb +0 -30
@@ -294,6 +294,12 @@ module Moxml
294
294
 
295
295
  handler = axis_name.gsub("-", "_")
296
296
 
297
+ unless respond_to?(:"on_axis_#{handler}", true)
298
+ raise XPath::Error.new(
299
+ "XPath axis #{axis_name.inspect} is not supported by this engine",
300
+ )
301
+ end
302
+
297
303
  send(:"on_axis_#{handler}", test, input, &block)
298
304
  end
299
305
 
@@ -362,6 +368,78 @@ module Moxml
362
368
  end
363
369
  end
364
370
 
371
+ # AXIS: ancestor - all ancestors up to the document
372
+ def on_axis_ancestor(ast, input)
373
+ ancestor = unique_literal(:ancestor)
374
+
375
+ document_or_node(input).if_true do
376
+ input.ancestors.each.add_block(ancestor) do
377
+ condition = process(ast, ancestor)
378
+ if block_given?
379
+ condition.if_true { yield ancestor }
380
+ else
381
+ condition.if_true { ancestor }
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ # AXIS: ancestor-or-self - the context node and its ancestors
388
+ def on_axis_ancestor_or_self(ast, input)
389
+ ancestor = unique_literal(:ancestor)
390
+
391
+ document_or_node(input).if_true do
392
+ self_result = process(ast, input)
393
+ self_collection = if block_given?
394
+ self_result.if_true { yield input }
395
+ else
396
+ self_result.if_true { input }
397
+ end
398
+ self_collection.followed_by do
399
+ input.ancestors.each.add_block(ancestor) do
400
+ condition = process(ast, ancestor)
401
+ if block_given?
402
+ condition.if_true { yield ancestor }
403
+ else
404
+ condition.if_true { ancestor }
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
410
+
411
+ # AXIS: following-sibling - siblings after the context node
412
+ def on_axis_following_sibling(ast, input)
413
+ sibling = unique_literal(:sibling)
414
+
415
+ document_or_node(input).if_true do
416
+ input.following_siblings.each.add_block(sibling) do
417
+ condition = process(ast, sibling)
418
+ if block_given?
419
+ condition.if_true { yield sibling }
420
+ else
421
+ condition.if_true { sibling }
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ # AXIS: preceding-sibling - siblings before the context node
428
+ def on_axis_preceding_sibling(ast, input)
429
+ sibling = unique_literal(:sibling)
430
+
431
+ document_or_node(input).if_true do
432
+ input.preceding_siblings.each.add_block(sibling) do
433
+ condition = process(ast, sibling)
434
+ if block_given?
435
+ condition.if_true { yield sibling }
436
+ else
437
+ condition.if_true { sibling }
438
+ end
439
+ end
440
+ end
441
+ end
442
+
365
443
  # AXIS: descendant-or-self - Enables // operator
366
444
  def on_axis_descendant_or_self(ast, input)
367
445
  node = unique_literal(:descendant)
@@ -425,15 +503,22 @@ module Moxml
425
503
  end
426
504
  end
427
505
 
428
- # AXIS: attribute - Enables @attribute syntax
506
+ # AXIS: attribute - Enables @attribute syntax. Name tests share
507
+ # the resolver's expanded-name semantics (Namespaces 1.0): bare
508
+ # names match no-namespace attributes only; prefixed names match
509
+ # by namespace URI, never by prefix spelling.
429
510
  def on_axis_attribute(ast, input)
430
511
  elem_class = const_ref("Moxml", "Element")
431
512
  attribute = unique_literal(:attribute)
432
513
 
433
514
  input.is_a?(elem_class).if_true do
434
515
  input.attributes.each.add_block(attribute) do
435
- # Use process to handle both :test and :wildcard nodes
436
- condition = process(ast, attribute)
516
+ condition = if ast.type == :test
517
+ attribute_test_condition(ast, input, attribute)
518
+ else
519
+ # Wildcards and node types keep the generic path
520
+ process(ast, attribute)
521
+ end
437
522
 
438
523
  if block_given?
439
524
  condition.if_true { yield attribute }
@@ -444,6 +529,34 @@ module Moxml
444
529
  end
445
530
  end
446
531
 
532
+ # Attribute node-test condition built on AttributeResolver. The
533
+ # XPath context's prefix mapping (when provided) wins over the
534
+ # document's own declarations and is resolved at compile time.
535
+ # Returns nil for the both-wildcard test (@*): no name constraint.
536
+ def attribute_test_condition(ast, element, attribute)
537
+ resolver = const_ref("Moxml", "AttributeResolver")
538
+ ns = ast.value[:namespace]
539
+ name = ast.value[:name]
540
+
541
+ return nil if name == STAR && (!ns || ns == STAR)
542
+
543
+ local = name == STAR ? self_nil : string(name)
544
+
545
+ if ns && ns != STAR && @namespaces && (uri = @namespaces[ns])
546
+ return resolver.attribute_test_uri?(element, attribute,
547
+ string(uri.to_s), local)
548
+ end
549
+
550
+ prefix = if ns.nil?
551
+ self_nil
552
+ elsif ns == STAR
553
+ symbol(:any)
554
+ else
555
+ string(ns)
556
+ end
557
+ resolver.attribute_test?(element, attribute, prefix, local)
558
+ end
559
+
447
560
  # AXIS: descendant - All descendant nodes (without self)
448
561
  def on_axis_descendant(ast, input)
449
562
  node = unique_literal(:descendant)
@@ -494,6 +607,13 @@ module Moxml
494
607
  when "node"
495
608
  # node() matches everything — use a truthy literal
496
609
  Ruby::Node.new(:lit, ["true"])
610
+ when "text"
611
+ # CDATA sections are text nodes in the XPath data model
612
+ text_class = const_ref("Moxml", "Text")
613
+ cdata_class = const_ref("Moxml", "Cdata")
614
+ input.is_a?(text_class).or(input.is_a?(cdata_class))
615
+ when "comment"
616
+ input.is_a?(const_ref("Moxml", "Comment"))
497
617
  else element_or_attribute(input)
498
618
  end
499
619
  end
@@ -531,9 +651,21 @@ module Moxml
531
651
  # Match namespace if specified
532
652
  if ns && ns != STAR
533
653
  if @namespaces && @namespaces[ns]
534
- # Resolve prefix to URI using namespace mappings
654
+ # Resolve prefix to URI using namespace mappings. An empty
655
+ # URI binding ("xmlns" => "") selects elements in no
656
+ # namespace, e.g. after an xmlns="" undeclaration.
535
657
  ns_uri = @namespaces[ns]
536
- ns_match = input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
658
+ ns_match = if ns_uri.to_s.empty?
659
+ input.namespace.nil?.or(input.namespace.uri.eq(string("")))
660
+ else
661
+ input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
662
+ end
663
+ elsif ns == "xmlns"
664
+ # Nokogiri-compatible reserved prefix: xmlns:name addresses
665
+ # elements in the default namespace (no prefix).
666
+ ns_match = input.namespace.and(
667
+ input.namespace.prefix.to_s.eq(string("")),
668
+ )
537
669
  else
538
670
  # No mapping provided - check against element's namespace prefix
539
671
  # Need to ensure input.namespace exists first
@@ -569,7 +701,11 @@ module Moxml
569
701
 
570
702
  # Parent node (..)
571
703
  def on_parent(_ast, input)
572
- input.parent
704
+ if block_given?
705
+ yield input.parent
706
+ else
707
+ input.parent
708
+ end
573
709
  end
574
710
 
575
711
  # ===== OPERATORS =====
@@ -977,10 +1113,9 @@ module Moxml
977
1113
  conversions << conversion.to_string(arg_var)
978
1114
  end
979
1115
 
980
- # rubocop:disable Style/RedundantSum, Performance/Sum
1116
+ # rubocop:disable-next Performance/Sum
981
1117
  concatted = assigns.inject(:followed_by)
982
1118
  .followed_by(conversions.inject(:+))
983
- # rubocop:enable Style/RedundantSum, Performance/Sum
984
1119
 
985
1120
  block_given? ? concatted.empty?.if_false { yield concatted } : concatted
986
1121
  end
@@ -327,11 +327,18 @@ module Moxml
327
327
  def parse_relative_path
328
328
  steps = [parse_step]
329
329
 
330
- while match?(:slash) && !at_end?
330
+ # Continue on both step separators: "/step" and the
331
+ # descendant-or-self shorthand "//step". A "//" arrives as a
332
+ # single :dslash (".//step", "a//step") or occasionally as a
333
+ # :slash/:slash or :slash/:dslash pair.
334
+ while !at_end? && match?(:slash, :dslash)
335
+ is_dslash = match?(:dslash)
331
336
  advance
332
- if match?(:slash)
333
- # Double slash within path: expands to descendant-or-self::node()
337
+ if !is_dslash && match?(:slash, :dslash)
334
338
  advance
339
+ is_dslash = true
340
+ end
341
+ if is_dslash
335
342
  steps << AST::Node.axis("descendant-or-self", AST::Node.node_type("node"))
336
343
  end
337
344
  steps << parse_step unless at_end? || match?(:pipe, :rbracket,
@@ -352,14 +359,10 @@ module Moxml
352
359
  return AST::Node.parent
353
360
  elsif match?(:at)
354
361
  advance
355
- # Attribute: @name or @*
356
- if match?(:star)
357
- advance
358
- node_test = AST::Node.wildcard
359
- else
360
- name = consume(:name, "Expected attribute name after @")
361
- node_test = AST::Node.test(nil, name[1])
362
- end
362
+ # Attribute: @name, @prefix:name, @prefix:* or @* (XPath 1.0
363
+ # §3.2, NameTest). parse_node_test handles prefixed and
364
+ # wildcard names; bare names land in its plain-name branch.
365
+ node_test = parse_node_test
363
366
  step = AST::Node.axis("attribute", node_test)
364
367
  return parse_predicates(step)
365
368
  end
@@ -385,14 +388,18 @@ module Moxml
385
388
  if match?(:star)
386
389
  advance
387
390
  return AST::Node.wildcard
388
- elsif match?(:node_type)
391
+ elsif match?(:node_type) && peek_is?(:lparen)
389
392
  type_name = current_value
390
393
  advance
391
394
  consume(:lparen, "Expected '(' after node type")
392
395
  consume(:rparen, "Expected ')' after node type")
393
396
  return AST::Node.node_type(type_name)
394
- elsif match?(:name, :and, :or, :mod, :div)
395
- # Accept keywords as valid element names (they're valid XML names)
397
+ elsif match?(:name, :node_type, :and, :or, :mod, :div)
398
+ # Accept keywords as valid element names (they're valid XML
399
+ # names). Node-type words (text, comment, node) also land here
400
+ # when not followed by '(' — they name elements, either alone
401
+ # ("//text") or as the local part of a prefixed name
402
+ # ("//svg:text").
396
403
  name = current_value
397
404
  advance
398
405
 
@@ -402,7 +409,7 @@ module Moxml
402
409
  if match?(:star)
403
410
  advance
404
411
  return AST::Node.test(name, "*")
405
- elsif match?(:name, :and, :or, :mod, :div)
412
+ elsif match?(:name, :node_type, :and, :or, :mod, :div)
406
413
  # Accept keywords as local names too
407
414
  local_name = current_value
408
415
  advance
data/lib/moxml.rb CHANGED
@@ -26,7 +26,6 @@ module Moxml
26
26
  default_indent: original.default_indent,
27
27
  default_line_ending: original.default_line_ending,
28
28
  entity_load_mode: original.entity_load_mode,
29
- entity_encoding: original.entity_encoding,
30
29
  restore_entities: original.restore_entities,
31
30
  namespace_validation_mode: original.namespace_validation_mode,
32
31
  entity_restoration_mode: original.entity_restoration_mode,
@@ -48,7 +47,6 @@ module Moxml
48
47
  config.default_indent = saved_values[:default_indent]
49
48
  config.default_line_ending = saved_values[:default_line_ending]
50
49
  config.entity_load_mode = saved_values[:entity_load_mode]
51
- config.entity_encoding = saved_values[:entity_encoding]
52
50
  config.restore_entities = saved_values[:restore_entities]
53
51
  config.namespace_validation_mode = saved_values[:namespace_validation_mode]
54
52
  config.entity_restoration_mode = saved_values[:entity_restoration_mode]
@@ -76,16 +74,18 @@ module Moxml
76
74
  autoload :Cdata, "moxml/cdata"
77
75
  autoload :Comment, "moxml/comment"
78
76
  autoload :Attribute, "moxml/attribute"
77
+ autoload :AttributeResolver, "moxml/attribute_resolver"
79
78
  autoload :ProcessingInstruction, "moxml/processing_instruction"
80
79
  autoload :Declaration, "moxml/declaration"
81
80
  autoload :Namespace, "moxml/namespace"
82
81
  autoload :Doctype, "moxml/doctype"
83
82
  autoload :EntityReference, "moxml/entity_reference"
84
- autoload :DocumentBuilder, "moxml/document_builder"
85
83
  autoload :Builder, "moxml/builder"
84
+ autoload :Entity, "moxml/entity"
86
85
  autoload :EntityRegistry, "moxml/entity_registry"
87
86
  autoload :NativeAttachment, "moxml/native_attachment"
88
87
  autoload :XmlUtils, "moxml/xml_utils"
88
+ autoload :XmlEmitter, "moxml/xml_emitter"
89
89
  autoload :Adapter, "moxml/adapter"
90
90
  autoload :XPath, "moxml/xpath"
91
91
  autoload :SAX, "moxml/sax"
@@ -110,10 +110,6 @@ RSpec.shared_examples "README Examples" do
110
110
  describe "Error handling example" do
111
111
  it "handles errors as shown in README" do
112
112
  context = Moxml.new
113
- if context.config.adapter_name == :ox
114
- pending "Ox doesn't have a native XPath"
115
- end
116
-
117
113
  expect do
118
114
  context.parse("<invalid>")
119
115
  end.to raise_error(Moxml::ParseError)
@@ -24,20 +24,12 @@ RSpec.shared_examples "XPath Examples" do
24
24
  end
25
25
 
26
26
  it "finds nodes with namespaces" do
27
- if context.config.adapter_name == :ox
28
- pending "Ox doesn't have a native XPath"
29
- end
30
-
31
27
  titles = doc.xpath("//dc:title",
32
28
  "dc" => "http://purl.org/dc/elements/1.1/")
33
29
  expect(titles.map(&:text)).to eq(%w[First Second])
34
30
  end
35
31
 
36
32
  it "finds nodes by attributes" do
37
- if %i[ox headed_ox].include?(context.config.adapter_name)
38
- pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
39
- end
40
-
41
33
  book = doc.at_xpath('//book[@id="2"]')
42
34
  expect(book).not_to be_nil
43
35
  title = book.at_xpath(".//dc:title",
@@ -46,9 +38,6 @@ RSpec.shared_examples "XPath Examples" do
46
38
  end
47
39
 
48
40
  it "finds nested attributes efficiently" do
49
- if %i[ox headed_ox].include?(context.config.adapter_name)
50
- pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
51
- end
52
41
  # More efficient - specific path
53
42
  titles1 = doc.xpath("//book/dc:title")
54
43
 
@@ -1,5 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Leptris needs the unreleased programmatic-document-construction C
4
+ # API; skip its integration matrix until the binding ships it.
5
+ #
6
+ # const_defined? only — deliberately NO require here: loading a native
7
+ # gem at example-group definition time is a load-order side effect
8
+ # (and the only new load-time behavior on this branch). The leptris
9
+ # adapter spec is what loads the gem in its own process.
10
+ def leptris_ready?
11
+ return false unless Object.const_defined?(:Leptris)
12
+
13
+ Leptris::XML::Document.respond_to?(:create)
14
+ end
15
+
3
16
  RSpec.describe "Cross-adapter integration" do
4
17
  # Integration shared examples - use the names as defined in the files
5
18
  all_shared_examples = [
@@ -18,7 +31,7 @@ RSpec.describe "Cross-adapter integration" do
18
31
  "Moxml::EntityReference",
19
32
  "Moxml::Context",
20
33
  "Moxml::Builder",
21
- "Moxml::DocumentBuilder",
34
+ "Moxml::Parse Behavior",
22
35
  "Moxml Integration",
23
36
  "Moxml Edge Cases",
24
37
  "Attribute Examples",
@@ -41,6 +54,10 @@ RSpec.describe "Cross-adapter integration" do
41
54
  end
42
55
  end
43
56
 
57
+ if adapter_name == :leptris && !leptris_ready?
58
+ before { skip "requires leptris with Leptris::XML::Document.create (unreleased C API)" }
59
+ end
60
+
44
61
  all_shared_examples.each do |shared_example_name|
45
62
  it_behaves_like shared_example_name
46
63
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SAX event parity across adapters: every bridge must translate its
4
+ # native SAX stream into the same Moxml event sequence — same element
5
+ # names (qualified), same attribute/declaration split (nil key = the
6
+ # default namespace), same characters.
7
+ RSpec.describe "SAX parity across adapters" do
8
+ let(:xml) do
9
+ <<~XML
10
+ <?xml version="1.0" encoding="UTF-8"?>
11
+ <root xmlns="http://d.org" xmlns:p="http://p.org" kind="a"><p:child id="1">text</p:child></root>
12
+ XML
13
+ end
14
+
15
+ def recorded_events(adapter_name)
16
+ events = []
17
+ handler = Class.new(Moxml::SAX::Handler) do
18
+ define_method(:track) { |*event| events << event }
19
+ define_method(:on_start_document) { track(:start_document) }
20
+ define_method(:on_end_document) { track(:end_document) }
21
+ define_method(:on_start_element) { |n, a, ns| track(:start_element, n, a, ns) }
22
+ define_method(:on_end_element) { |n| track(:end_element, n) }
23
+ define_method(:on_characters) { |t| track(:characters, t) }
24
+ end
25
+ Moxml.new(adapter_name).sax_parse(xml, handler.new)
26
+ events
27
+ end
28
+
29
+ def assert_parity(adapter_name)
30
+ # REXML and Oga report document-level whitespace (between the
31
+ # declaration and the root) as characters; parity is over the
32
+ # meaningful event stream.
33
+ events = recorded_events(adapter_name)
34
+ .reject { |event| event[0] == :characters && event[1].to_s.strip.empty? }
35
+
36
+ expect(events.first).to eq([:start_document])
37
+ expect(events[1]).to eq(
38
+ [:start_element, "root",
39
+ { "kind" => "a" },
40
+ { nil => "http://d.org", "p" => "http://p.org" }],
41
+ )
42
+ expect(events[2]).to eq([:start_element, "p:child", { "id" => "1" }, {}])
43
+ expect(events).to include([:characters, "text"])
44
+ expect(events).to include([:end_element, "p:child"])
45
+ expect(events).to include([:end_element, "root"])
46
+ expect(events.last).to eq([:end_document])
47
+ end
48
+
49
+ adapters = %i[nokogiri oga rexml ox headed_ox libxml]
50
+ adapters << :leptris if Object.const_defined?(:Leptris) &&
51
+ Leptris::XML::Document.respond_to?(:create)
52
+
53
+ adapters.each do |adapter_name|
54
+ it "emits the same event stream via #{adapter_name}" do
55
+ assert_parity(adapter_name)
56
+ end
57
+ end
58
+ end
@@ -80,9 +80,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
80
80
 
81
81
  describe "namespace edge cases" do
82
82
  it "handles default namespace changes" do
83
- if context.config.adapter_name == :ox
84
- pending "Ox doesn't have a native XPath"
85
- end
86
83
  if context.config.adapter_name == :headed_ox
87
84
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
88
85
  end
@@ -106,9 +103,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
106
103
  end
107
104
 
108
105
  it "handles recursive namespace definitions" do
109
- if context.config.adapter_name == :ox
110
- pending "Ox doesn't have a native XPath"
111
- end
112
106
  if context.config.adapter_name == :headed_ox
113
107
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
114
108
  end
@@ -132,10 +126,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
132
126
  if context.config.adapter_name == :headed_ox
133
127
  skip "HeadedOx limitation: Namespace-prefixed attribute access needs Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
134
128
  end
135
- if context.config.adapter_name == :ox
136
- skip "Ox doesn't have a native XPath"
137
- end
138
-
139
129
  xml = <<~XML
140
130
  <root xmlns:a="http://a.org" xmlns:b="http://b.org">
141
131
  <element a:id="1" b:id="2"/>
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- RSpec.shared_examples "Moxml::DocumentBuilder" do
3
+ RSpec.shared_examples "Moxml::Parse Behavior" do
4
4
  let(:context) { Moxml.new }
5
- let(:builder) { Moxml::DocumentBuilder.new(context) }
6
5
 
7
6
  describe "#build" do
8
7
  it "builds a document model from native document" do
@@ -56,9 +56,6 @@ RSpec.shared_examples "Moxml Integration" do
56
56
  end
57
57
 
58
58
  it "handles xpath queries" do
59
- if context.config.adapter_name == :ox
60
- pending "Ox doesn't support namespace-aware XPath with predicates"
61
- end
62
59
  if context.config.adapter_name == :headed_ox
63
60
  skip "HeadedOx limitation: Namespace-aware XPath with predicates needs investigation. See docs/_pages/headed-ox-limitations.adoc"
64
61
  end
@@ -76,9 +73,6 @@ RSpec.shared_examples "Moxml Integration" do
76
73
 
77
74
  describe "namespace handling" do
78
75
  it "handles complex namespace scenarios" do
79
- if context.config.adapter_name == :ox
80
- pending "Ox doesn't have a native XPath"
81
- end
82
76
  if context.config.adapter_name == :headed_ox
83
77
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
84
78
  end
@@ -125,10 +119,6 @@ RSpec.shared_examples "Moxml Integration" do
125
119
  if context.config.adapter_name == :headed_ox
126
120
  skip "HeadedOx limitation: Parent setter not implemented. Requires Ox node reparenting API. See docs/_pages/headed-ox-limitations.adoc"
127
121
  end
128
- if context.config.adapter_name == :ox
129
- skip "Ox doesn't have a native XPath"
130
- end
131
-
132
122
  # Move nodes
133
123
  b_node = doc.at_xpath("//b")
134
124
  a_node = doc.at_xpath("//a")
@@ -65,6 +65,65 @@ RSpec.shared_examples "Moxml::Attribute" do
65
65
  expect(attribute.namespace.prefix).to eq("other")
66
66
  expect(attribute.to_s).to eq('other:attr="value"')
67
67
  end
68
+
69
+ it "replaces by expanded name across prefix spellings" do
70
+ element["ns:attr"] = "first"
71
+ element.add_namespace("alias", "http://example.org")
72
+ element["alias:attr"] = "second"
73
+
74
+ expect(element["ns:attr"]).to eq("second")
75
+ expect(element.attributes.size).to eq(1)
76
+ end
77
+ end
78
+
79
+ describe "expanded-name writes (AttributeResolver)" do
80
+ before do
81
+ element.add_namespace("p", "http://x.org")
82
+ element.add_namespace("q", "http://x.org")
83
+ element["q:type"] = "namespaced"
84
+ element["type"] = "bare"
85
+ end
86
+
87
+ it "does not clobber a namespaced attribute sharing the local name" do
88
+ element["type"] = "changed"
89
+
90
+ expect(element["type"]).to eq("changed")
91
+ expect(element["q:type"]).to eq("namespaced")
92
+ end
93
+
94
+ it "overwrites by expanded name regardless of prefix spelling" do
95
+ element["p:type"] = "overwritten"
96
+
97
+ expect(element["q:type"]).to eq("overwritten")
98
+ expect(element.attributes.size).to eq(2)
99
+ end
100
+
101
+ it "stores writes through undeclared prefixes raw for later resolution" do
102
+ element["z:type"] = "value"
103
+
104
+ # No declaration in scope: the read resolves to nothing, but
105
+ # the attribute is stored (detached builds attach under the
106
+ # declaring ancestor later).
107
+ expect(element["z:type"]).to be_nil
108
+ expect(element.attributes.size).to eq(3)
109
+
110
+ element.add_namespace("z", "http://z.org")
111
+ expect(element["z:type"]).to eq("value")
112
+ end
113
+
114
+ it "removes the bare attribute without touching the namespaced one" do
115
+ element.remove_attribute("type")
116
+
117
+ expect(element["type"]).to be_nil
118
+ expect(element["q:type"]).to eq("namespaced")
119
+ end
120
+
121
+ it "removes by expanded name regardless of prefix spelling" do
122
+ element.remove_attribute("p:type")
123
+
124
+ expect(element["q:type"]).to be_nil
125
+ expect(element["type"]).to eq("bare")
126
+ end
68
127
  end
69
128
 
70
129
  describe "manipulation" do
@@ -162,4 +221,43 @@ RSpec.shared_examples "Moxml::Attribute" do
162
221
  end
163
222
  end
164
223
  end
224
+
225
+ describe "unified XML-correct name resolution" do
226
+ let(:context) { Moxml.new }
227
+ let(:doc) do
228
+ context.parse(
229
+ %(<root xmlns:p="urn:1"><child xmlns:q="urn:1" q:type="v" type="plain" xml:lang="en"/></root>),
230
+ )
231
+ end
232
+ let(:child) { doc.root.children.find(&:element?) }
233
+
234
+ it "matches prefixed lookups by expanded name across redeclared prefixes" do
235
+ # p and q both bind urn:1; the attribute is written q:type
236
+ expect(child["p:type"]).to eq("v")
237
+ expect(child["q:type"]).to eq("v")
238
+ end
239
+
240
+ it "keeps bare and namespaced attributes distinct" do
241
+ expect(child["type"]).to eq("plain")
242
+ expect(child[:type]).to eq("plain")
243
+ end
244
+
245
+ it "resolves the prebound xml prefix without any declaration" do
246
+ expect(child["xml:lang"]).to eq("en")
247
+ end
248
+
249
+ it "returns nil for undeclared prefixes — no prefix-string fallback" do
250
+ expect(child["undeclared:type"]).to be_nil
251
+ end
252
+
253
+ it "never treats namespace declarations as attributes" do
254
+ expect(child["xmlns:q"]).to be_nil
255
+ expect(child["xmlns"]).to be_nil
256
+ end
257
+
258
+ it "attribute(name) agrees with []" do
259
+ expect(child.attribute("p:type").value).to eq("v")
260
+ expect(child.attribute("undeclared:type")).to be_nil
261
+ end
262
+ end
165
263
  end
@@ -170,6 +170,32 @@ RSpec.shared_examples "Moxml::Namespace" do
170
170
  expect(prefixes).to include("xs")
171
171
  end
172
172
 
173
+ it "sees ancestor declarations made after a first read (cache invalidation)" do
174
+ root = doc.create_element("root")
175
+ root.add_namespace("a", "http://a.org")
176
+ child = doc.create_element("child")
177
+ root.add_child(child)
178
+ child.in_scope_namespaces # materialize the scope cache
179
+
180
+ root.add_namespace("b", "http://b.org")
181
+
182
+ expect(child.in_scope_namespaces.map(&:prefix)).to include("b")
183
+ end
184
+
185
+ it "recomputes the scope after the subtree is reparented" do
186
+ source = doc.create_element("source")
187
+ source.add_namespace("s", "http://s.org")
188
+ child = doc.create_element("child")
189
+ source.add_child(child)
190
+ child.in_scope_namespaces # materialize the scope cache
191
+
192
+ target = doc.create_element("target")
193
+ child.remove
194
+ target.add_child(child)
195
+
196
+ expect(child.in_scope_namespaces.map(&:prefix)).not_to include("s")
197
+ end
198
+
173
199
  it "collects namespaces from multiple ancestor levels" do
174
200
  root = doc.create_element("root")
175
201
  root.add_namespace("xs", "http://www.w3.org/2001/XMLSchema")