moxml 0.1.14 → 0.1.16

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +117 -66
  3. data/Gemfile +1 -0
  4. data/README.adoc +11 -9
  5. data/Rakefile +34 -1
  6. data/TODO.remaining/1-entity-reference-adapter-support.md +157 -0
  7. data/TODO.remaining/2-entity-restoration-model-driven.md +169 -0
  8. data/TODO.remaining/3-entity-reference-test-coverage.md +170 -0
  9. data/TODO.remaining/4-lenient-entities-mode.md +106 -0
  10. data/TODO.remaining/5-fixture-integrity.md +65 -0
  11. data/TODO.remaining/6-ox-element-ordering-bug.md +36 -0
  12. data/TODO.remaining/7-headed-ox-limitations.md +95 -0
  13. data/TODO.remaining/8-xpath-predicate-gaps.md +68 -0
  14. data/TODO.remaining/9-cleanup-hygiene.md +42 -0
  15. data/TODO.remaining/README.md +54 -0
  16. data/benchmarks/generate_report.rb +1 -1
  17. data/docs/_pages/configuration.adoc +22 -19
  18. data/docs/_tutorials/namespace-handling.adoc +5 -5
  19. data/lib/moxml/adapter/base.rb +22 -3
  20. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -1
  21. data/lib/moxml/adapter/customized_libxml/entity_reference.rb +23 -0
  22. data/lib/moxml/adapter/customized_libxml.rb +18 -0
  23. data/lib/moxml/adapter/customized_oga.rb +10 -0
  24. data/lib/moxml/adapter/customized_ox/entity_reference.rb +25 -0
  25. data/lib/moxml/adapter/customized_ox.rb +12 -0
  26. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +19 -0
  27. data/lib/moxml/adapter/customized_rexml/formatter.rb +44 -20
  28. data/lib/moxml/adapter/customized_rexml.rb +11 -0
  29. data/lib/moxml/adapter/headed_ox.rb +37 -14
  30. data/lib/moxml/adapter/libxml.rb +233 -119
  31. data/lib/moxml/adapter/nokogiri.rb +22 -11
  32. data/lib/moxml/adapter/oga.rb +64 -25
  33. data/lib/moxml/adapter/ox.rb +198 -42
  34. data/lib/moxml/adapter/rexml.rb +64 -13
  35. data/lib/moxml/attribute.rb +3 -0
  36. data/lib/moxml/builder.rb +78 -24
  37. data/lib/moxml/config.rb +24 -7
  38. data/lib/moxml/declaration.rb +4 -2
  39. data/lib/moxml/document.rb +8 -1
  40. data/lib/moxml/document_builder.rb +44 -37
  41. data/lib/moxml/element.rb +18 -5
  42. data/lib/moxml/entity_registry.rb +51 -1
  43. data/lib/moxml/native_attachment.rb +65 -0
  44. data/lib/moxml/node.rb +39 -50
  45. data/lib/moxml/node_set.rb +43 -15
  46. data/lib/moxml/version.rb +1 -1
  47. data/lib/moxml/xml_utils.rb +1 -1
  48. data/lib/moxml/xpath/compiler.rb +4 -1
  49. data/lib/moxml.rb +1 -0
  50. data/scripts/format_xml.rb +16 -0
  51. data/scripts/pretty_format_xml.rb +14 -0
  52. data/spec/consistency/round_trip_spec.rb +3 -30
  53. data/spec/integration/all_adapters_spec.rb +1 -0
  54. data/spec/integration/headed_ox_integration_spec.rb +0 -2
  55. data/spec/integration/shared_examples/edge_cases.rb +7 -4
  56. data/spec/integration/shared_examples/integration_workflows.rb +3 -3
  57. data/spec/integration/shared_examples/node_wrappers/cdata_behavior.rb +1 -1
  58. data/spec/integration/shared_examples/node_wrappers/entity_reference_behavior.rb +224 -0
  59. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +1 -1
  60. data/spec/moxml/adapter/headed_ox_spec.rb +8 -8
  61. data/spec/moxml/adapter/oga_spec.rb +46 -0
  62. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +1 -12
  63. data/spec/moxml/allocation_benchmark_spec.rb +96 -0
  64. data/spec/moxml/allocation_guard_spec.rb +282 -0
  65. data/spec/moxml/builder_spec.rb +256 -0
  66. data/spec/moxml/config_spec.rb +11 -11
  67. data/spec/moxml/doctype_spec.rb +41 -0
  68. data/spec/moxml/lazy_parse_spec.rb +115 -0
  69. data/spec/moxml/namespace_uri_validation_spec.rb +11 -3
  70. data/spec/moxml/node_cache_spec.rb +110 -0
  71. data/spec/moxml/node_set_cache_spec.rb +90 -0
  72. data/spec/moxml/xml_utils_spec.rb +32 -0
  73. data/spec/moxml/xpath/axes_spec.rb +1 -1
  74. data/spec/moxml/xpath/compiler_spec.rb +2 -2
  75. data/spec/moxml/xpath/functions/position_functions_spec.rb +5 -5
  76. data/spec/moxml/xpath/functions/special_functions_spec.rb +1 -1
  77. data/spec/performance/memory_usage_spec.rb +0 -4
  78. data/spec/support/allocation_helper.rb +165 -0
  79. data/spec/support/w3c_namespace_helpers.rb +2 -1
  80. metadata +29 -2
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # Lazy parse correctness tests — these run in CI by default.
7
+ # Verifies that lazy parse produces correct document structure
8
+ # across all adapters without eager wrapper construction.
9
+ RSpec.describe "Moxml lazy parse" do
10
+ let(:xml) do
11
+ "<root><child><nested>text</nested></child><sibling>more</sibling></root>"
12
+ end
13
+
14
+ shared_examples "lazy parse behavior" do |adapter_name|
15
+ let(:ctx) { Moxml::Context.new(adapter_name) }
16
+
17
+ describe "#parse" do
18
+ it "returns a Document without eagerly building wrapper tree" do
19
+ doc = ctx.parse(xml)
20
+ expect(doc).to be_a(Moxml::Document)
21
+ expect(doc.root).to be_a(Moxml::Element)
22
+ expect(doc.root.name).to eq("root")
23
+ end
24
+
25
+ it "provides correct children via lazy access" do
26
+ doc = ctx.parse(xml)
27
+ root = doc.root
28
+ children = root.children.to_a
29
+ expect(children.size).to eq(2)
30
+ expect(children[0]).to be_a(Moxml::Element)
31
+ expect(children[0].name).to eq("child")
32
+ expect(children[1].name).to eq("sibling")
33
+ end
34
+
35
+ it "provides correct nested children" do
36
+ doc = ctx.parse(xml)
37
+ nested = doc.root.children[0].children[0]
38
+ expect(nested.name).to eq("nested")
39
+ expect(nested.text).to eq("text")
40
+ end
41
+
42
+ it "preserves attributes" do
43
+ xml_with_attrs = '<root a="1" b="2"><child c="3"/></root>'
44
+ doc = ctx.parse(xml_with_attrs)
45
+ root = doc.root
46
+ attrs = root.attributes
47
+ expect(attrs.size).to eq(2)
48
+ expect(root["a"]).to eq("1")
49
+ expect(root["b"]).to eq("2")
50
+ expect(root.children[0]["c"]).to eq("3")
51
+ end
52
+
53
+ it "preserves text content" do
54
+ doc = ctx.parse("<root>hello world</root>")
55
+ expect(doc.root.text).to eq("hello world")
56
+ end
57
+
58
+ it "preserves mixed content" do
59
+ mixed_xml = "<root>before<child/>after</root>"
60
+ doc = ctx.parse(mixed_xml)
61
+ expect(doc.root.text).to eq("beforeafter")
62
+ end
63
+
64
+ it "handles comments" do
65
+ comment_xml = "<root><!-- a comment --><child/></root>"
66
+ doc = ctx.parse(comment_xml)
67
+ children = doc.root.children.to_a
68
+ comment = children.find(&:comment?)
69
+ expect(comment).not_to be_nil
70
+ expect(comment.content).to include("a comment")
71
+ end
72
+
73
+ it "handles processing instructions" do
74
+ pi_xml = "<?pi-target pi-content?><root/>"
75
+ doc = ctx.parse(pi_xml)
76
+ expect(doc.root.name).to eq("root")
77
+ end
78
+
79
+ it "handles namespace declarations" do
80
+ ns_xml = '<root xmlns:ns="http://example.com"><ns:child/></root>'
81
+ doc = ctx.parse(ns_xml)
82
+ root = doc.root
83
+ nss = root.namespaces
84
+ expect(nss.size).to be >= 1
85
+ end
86
+
87
+ it "round-trips through serialize" do
88
+ doc = ctx.parse(xml)
89
+ serialized = doc.to_xml
90
+ doc2 = ctx.parse(serialized)
91
+ expect(doc2.root.name).to eq("root")
92
+ expect(doc2.root.children.to_a.size).to eq(2)
93
+ end
94
+ end
95
+ end
96
+
97
+ # Run for all guarded adapters
98
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
99
+ describe "#{adapter_name} adapter" do
100
+ before(:all) do
101
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
102
+ end
103
+
104
+ it_behaves_like "lazy parse behavior", adapter_name
105
+
106
+ # CDATA behavior differs between adapters
107
+ it "handles CDATA sections" do
108
+ ctx = Moxml::Context.new(adapter_name)
109
+ cdata_xml = "<root><![CDATA[<not xml>]]></root>"
110
+ doc = ctx.parse(cdata_xml)
111
+ expect(doc.root).not_to be_nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -92,10 +92,10 @@ RSpec.describe "Namespace URI validation" do
92
92
  end
93
93
  end
94
94
 
95
- describe "lenient namespace_uri_mode" do
95
+ describe "lenient namespace_validation_mode" do
96
96
  let(:context) do
97
97
  Moxml.new do |config|
98
- config.namespace_uri_mode = :lenient
98
+ config.namespace_validation_mode = :lenient
99
99
  end
100
100
  end
101
101
 
@@ -134,7 +134,15 @@ RSpec.describe "Namespace URI validation" do
134
134
  end
135
135
 
136
136
  it "still accepts valid URIs" do
137
- expect { element.add_namespace("ns", "http://example.com") }.not_to raise_error
137
+ expect do
138
+ element.add_namespace("ns", "http://example.com")
139
+ end.not_to raise_error
140
+ end
141
+
142
+ it "accepts prefixes with dots in lenient mode" do
143
+ expect do
144
+ element.add_namespace("xmlns_1.0", "http://example.com")
145
+ end.not_to raise_error
138
146
  end
139
147
  end
140
148
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # Node caching correctness tests — these run in CI by default.
7
+ # Verifies that cache semantics (identity, invalidation) work correctly
8
+ # across all adapters.
9
+ RSpec.describe "Moxml node caching" do
10
+ shared_examples "cached children" do |adapter_name|
11
+ let(:ctx) { Moxml::Context.new(adapter_name) }
12
+ let(:xml) { "<root><a/><b/><c/></root>" }
13
+ let(:doc) { ctx.parse(xml) }
14
+ let(:root) { doc.root }
15
+
16
+ describe "Node#children caching" do
17
+ it "returns the same NodeSet object on repeated calls" do
18
+ children1 = root.children
19
+ children2 = root.children
20
+ expect(children1).to equal(children2)
21
+ end
22
+
23
+ it "returns consistent child elements across calls" do
24
+ children1 = root.children.to_a
25
+ children2 = root.children.to_a
26
+ expect(children1.map(&:name)).to eq(children2.map(&:name))
27
+ end
28
+
29
+ it "invalidates cache when a child is added" do
30
+ children_before = root.children
31
+ root.add_child(ctx.parse("<d/>").root)
32
+ children_after = root.children
33
+ expect(children_before).not_to equal(children_after)
34
+ expect(children_after.to_a.size).to eq(4)
35
+ end
36
+
37
+ it "invalidates cache when text is set" do
38
+ children_before = root.children
39
+ root.text = "new text"
40
+ children_after = root.children
41
+ expect(children_before).not_to equal(children_after)
42
+ end
43
+ end
44
+
45
+ describe "Element#attributes caching" do
46
+ let(:attr_xml) { '<root a="1" b="2"><child c="3"/></root>' }
47
+ let(:attr_doc) { ctx.parse(attr_xml) }
48
+ let(:attr_root) { attr_doc.root }
49
+
50
+ it "returns the same array on repeated calls" do
51
+ attrs1 = attr_root.attributes
52
+ attrs2 = attr_root.attributes
53
+ expect(attrs1).to equal(attrs2)
54
+ end
55
+
56
+ it "returns consistent attribute values" do
57
+ attrs = attr_root.attributes
58
+ expect(attrs.to_h do |a|
59
+ [a.name, a.value]
60
+ end).to eq({ "a" => "1", "b" => "2" })
61
+ end
62
+
63
+ it "invalidates cache when an attribute is set" do
64
+ attrs_before = attr_root.attributes
65
+ attr_root["c"] = "3"
66
+ attrs_after = attr_root.attributes
67
+ expect(attrs_before).not_to equal(attrs_after)
68
+ expect(attrs_after.size).to eq(3)
69
+ end
70
+
71
+ it "invalidates cache when an attribute is removed" do
72
+ attrs_before = attr_root.attributes
73
+ attr_root.remove_attribute("a")
74
+ attrs_after = attr_root.attributes
75
+ expect(attrs_before).not_to equal(attrs_after)
76
+ expect(attrs_after.size).to eq(1)
77
+ end
78
+ end
79
+
80
+ describe "Element#namespaces caching" do
81
+ let(:ns_xml) { '<root xmlns:a="http://a.com" xmlns:b="http://b.com"><a:child/></root>' }
82
+ let(:ns_doc) { ctx.parse(ns_xml) }
83
+ let(:ns_root) { ns_doc.root }
84
+
85
+ it "returns the same array on repeated calls" do
86
+ nss1 = ns_root.namespaces
87
+ nss2 = ns_root.namespaces
88
+ expect(nss1).to equal(nss2)
89
+ end
90
+
91
+ it "invalidates cache when a namespace is added" do
92
+ nss_before = ns_root.namespaces
93
+ ns_root.add_namespace("c", "http://c.com")
94
+ nss_after = ns_root.namespaces
95
+ expect(nss_before).not_to equal(nss_after)
96
+ expect(nss_after.size).to eq(3)
97
+ end
98
+ end
99
+ end
100
+
101
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
102
+ describe "#{adapter_name} adapter" do
103
+ before(:all) do
104
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
105
+ end
106
+
107
+ it_behaves_like "cached children", adapter_name
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # NodeSet wrap caching correctness tests — these run in CI by default.
7
+ # Verifies that NodeSet per-index wrap caching works correctly across adapters.
8
+ RSpec.describe "Moxml NodeSet wrap caching" do
9
+ shared_examples "cached NodeSet wraps" do |adapter_name|
10
+ let(:ctx) { Moxml::Context.new(adapter_name) }
11
+ let(:xml) { "<root><a/><b/><c/></root>" }
12
+ let(:doc) { ctx.parse(xml) }
13
+
14
+ describe "NodeSet#each caching" do
15
+ it "returns the same wrapper object on repeated iteration" do
16
+ root = doc.root
17
+ first_pass = root.children.to_a
18
+ second_pass = root.children.to_a
19
+ # Since children itself is cached, the same NodeSet is returned.
20
+ # Within that NodeSet, wrapped nodes should be cached.
21
+ first_pass.each_with_index do |node, i|
22
+ expect(node).to equal(second_pass[i])
23
+ end
24
+ end
25
+
26
+ it "returns consistent node names" do
27
+ children = doc.root.children
28
+ names = children.map(&:name)
29
+ expect(names).to eq(%w[a b c])
30
+ end
31
+ end
32
+
33
+ describe "NodeSet#[] caching" do
34
+ it "returns the same wrapper for the same index" do
35
+ children = doc.root.children
36
+ first = children[0]
37
+ second = children[0]
38
+ expect(first).to equal(second)
39
+ end
40
+
41
+ it "returns the same wrapper from #[] as from #each" do
42
+ children = doc.root.children
43
+ from_each = children.to_a[1]
44
+ from_index = children[1]
45
+ expect(from_each).to equal(from_index)
46
+ end
47
+ end
48
+
49
+ describe "NodeSet#first/#last caching" do
50
+ it "returns the same wrapper from #first as from #[0]" do
51
+ children = doc.root.children
52
+ expect(children.first).to equal(children[0])
53
+ end
54
+
55
+ it "returns the same wrapper from #last as from #[-1]" do
56
+ children = doc.root.children
57
+ last_idx = children.size - 1
58
+ expect(children.last).to equal(children[last_idx])
59
+ end
60
+ end
61
+
62
+ describe "NodeSet mutation" do
63
+ it "appends to cache correctly" do
64
+ ns = doc.root.children
65
+ initial_size = ns.size
66
+ ns << ctx.parse("<d/>").root
67
+ expect(ns.size).to eq(initial_size + 1)
68
+ expect(ns[initial_size].name).to eq("d")
69
+ end
70
+
71
+ it "deletes from cache correctly" do
72
+ ns = doc.root.children
73
+ first_child = ns[0]
74
+ ns.delete(first_child)
75
+ expect(ns.size).to eq(2)
76
+ expect(ns[0].name).to eq("b")
77
+ end
78
+ end
79
+ end
80
+
81
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
82
+ describe "#{adapter_name} adapter" do
83
+ before(:all) do
84
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
85
+ end
86
+
87
+ it_behaves_like "cached NodeSet wraps", adapter_name
88
+ end
89
+ end
90
+ end
@@ -46,4 +46,36 @@ RSpec.describe Moxml::XmlUtils do
46
46
  "Invalid XML element name: 123invalid")
47
47
  end
48
48
  end
49
+
50
+ describe "#validate_prefix" do
51
+ it "accepts valid NCName prefixes" do
52
+ expect { utils.validate_prefix("xs") }.not_to raise_error
53
+ expect { utils.validate_prefix("my-ns") }.not_to raise_error
54
+ expect { utils.validate_prefix("a1") }.not_to raise_error
55
+ end
56
+
57
+ it "accepts prefixes containing dots (valid NCName NameChar)" do
58
+ expect { utils.validate_prefix("abc_1.0") }.not_to raise_error
59
+ expect { utils.validate_prefix("xmlns_1.0") }.not_to raise_error
60
+ expect { utils.validate_prefix("v2.0.1") }.not_to raise_error
61
+ end
62
+
63
+ it "rejects prefixes starting with a digit" do
64
+ expect do
65
+ utils.validate_prefix("1abc")
66
+ end.to raise_error(Moxml::ValidationError, /Invalid namespace prefix/)
67
+ end
68
+
69
+ it "rejects prefixes containing colons" do
70
+ expect do
71
+ utils.validate_prefix("a:b")
72
+ end.to raise_error(Moxml::ValidationError, /Invalid namespace prefix/)
73
+ end
74
+
75
+ it "rejects empty prefixes" do
76
+ expect do
77
+ utils.validate_prefix("")
78
+ end.to raise_error(Moxml::ValidationError, /Invalid namespace prefix/)
79
+ end
80
+ end
49
81
  end
@@ -222,7 +222,7 @@ RSpec.describe "XPath Axes" do
222
222
  end
223
223
 
224
224
  it "combines attribute axis with wildcards" do
225
- skip "HeadedOx limitation: Attribute wildcard (@*) not supported by XPath parser. See docs/HEADED_OX_LIMITATIONS.md"
225
+ skip "HeadedOx limitation: Attribute wildcard (@*) not supported by XPath parser. See docs/_pages/headed-ox-limitations.adoc"
226
226
  ast = Moxml::XPath::Parser.parse("//book/@*")
227
227
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
228
228
  result = proc.call(book_doc)
@@ -153,7 +153,7 @@ RSpec.describe Moxml::XPath::Compiler do
153
153
  end
154
154
 
155
155
  it "works with wildcards" do
156
- skip "HeadedOx limitation: Wildcard count differs due to Ox's DOM structure. See docs/HEADED_OX_LIMITATIONS.md"
156
+ skip "HeadedOx limitation: Wildcard count differs due to Ox's DOM structure. See docs/_pages/headed-ox-limitations.adoc"
157
157
  ast = Moxml::XPath::Parser.parse("//*")
158
158
  proc = described_class.compile_with_cache(ast)
159
159
  result = proc.call(nested_doc)
@@ -189,7 +189,7 @@ RSpec.describe Moxml::XPath::Compiler do
189
189
  end
190
190
 
191
191
  it "works with wildcards" do
192
- skip "HeadedOx limitation: Attribute wildcard (@*) not supported by XPath parser. See docs/HEADED_OX_LIMITATIONS.md"
192
+ skip "HeadedOx limitation: Attribute wildcard (@*) not supported by XPath parser. See docs/_pages/headed-ox-limitations.adoc"
193
193
  ast = Moxml::XPath::Parser.parse("/root/book/@*")
194
194
  proc = described_class.compile_with_cache(ast)
195
195
  result = proc.call(attr_doc)
@@ -30,7 +30,7 @@ RSpec.describe "XPath Position Functions" do
30
30
 
31
31
  # These tests require predicate support to be implemented
32
32
  # They are marked as pending until predicates are working
33
- xit "returns current position in predicate" do
33
+ it "returns current position in predicate" do
34
34
  ast = Moxml::XPath::Parser.parse("/root/item[position() = 2]")
35
35
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
36
36
  result = proc.call(doc)
@@ -39,7 +39,7 @@ RSpec.describe "XPath Position Functions" do
39
39
  expect(result.first.text).to eq("second")
40
40
  end
41
41
 
42
- xit "works with position comparison" do
42
+ it "works with position comparison" do
43
43
  ast = Moxml::XPath::Parser.parse("/root/item[position() > 1]")
44
44
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
45
45
  result = proc.call(doc)
@@ -63,7 +63,7 @@ RSpec.describe "XPath Position Functions" do
63
63
 
64
64
  # These tests require predicate support to be implemented
65
65
  # They are marked as pending until predicates are working
66
- xit "returns size of context in predicate" do
66
+ it "returns size of context in predicate" do
67
67
  ast = Moxml::XPath::Parser.parse("/root/item[position() = last()]")
68
68
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
69
69
  result = proc.call(doc)
@@ -72,7 +72,7 @@ RSpec.describe "XPath Position Functions" do
72
72
  expect(result.first.text).to eq("third")
73
73
  end
74
74
 
75
- xit "works with last() - 1" do
75
+ it "works with last() - 1" do
76
76
  ast = Moxml::XPath::Parser.parse("/root/item[position() = last() - 1]")
77
77
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
78
78
  result = proc.call(doc)
@@ -81,7 +81,7 @@ RSpec.describe "XPath Position Functions" do
81
81
  expect(result.first.text).to eq("second")
82
82
  end
83
83
 
84
- xit "works with comparison to last()" do
84
+ it "works with comparison to last()" do
85
85
  ast = Moxml::XPath::Parser.parse("/root/item[position() < last()]")
86
86
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
87
87
  result = proc.call(doc)
@@ -66,7 +66,7 @@ RSpec.describe "XPath Special Functions" do
66
66
  end
67
67
 
68
68
  # Test with nodeset argument - requires path evaluation
69
- xit "accepts nodeset argument containing IDs" do
69
+ it "accepts nodeset argument containing IDs" do
70
70
  xml = <<~XML
71
71
  <root>
72
72
  <item id="a">first</item>
@@ -51,10 +51,6 @@ RSpec.shared_examples "Memory Usage Examples" do
51
51
  end
52
52
 
53
53
  it "handles streaming processing" do
54
- if context.config.adapter_name == :headed_ox
55
- pending "HeadedOx double-wrapping issue with file-based parsing"
56
- end
57
-
58
54
  # Process file
59
55
  doc = nil
60
56
  File.open("spec/fixtures/small.xml") do |f|
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Shared helper for allocation guard specs.
6
+ #
7
+ # Provides:
8
+ # - Precise allocation counting via GC.stat
9
+ # - Per-adapter threshold configuration
10
+ # - Adapter availability checks
11
+ # - Optional StackProf diagnostic on guard failure
12
+ module AllocationHelper
13
+ # Adapters to guard in CI (ordered by importance).
14
+ # Skip REXML/LibXML — not used in production.
15
+ GUARDED_ADAPTERS = %i[nokogiri ox headed_ox oga].freeze
16
+
17
+ # Per-adapter allocation thresholds.
18
+ # Format: { operation => { adapter => max_allocations } }
19
+ #
20
+ # Thresholds calibrated at ~2x measured baseline (2026-04-18).
21
+ # All lazy-parse adapters (nokogiri, ox, headed_ox) share similar profiles.
22
+ # OGA is pure Ruby so naturally allocates more.
23
+ THRESHOLDS = {
24
+ # Parse a 100-element document (no subsequent access).
25
+ # Measured: nokogiri=299, ox=1003, headed_ox=1001, oga=8732
26
+ parse_100: {
27
+ nokogiri: 600,
28
+ ox: 2500,
29
+ headed_ox: 2500,
30
+ oga: 18_000,
31
+ },
32
+ # Parse a 50-element document.
33
+ # Measured: nokogiri=148, ox=501, headed_ox=501, oga=4365
34
+ parse_50: {
35
+ nokogiri: 300,
36
+ ox: 1200,
37
+ headed_ox: 1200,
38
+ oga: 9000,
39
+ },
40
+ # Access root.name after parse (lazy wrapping overhead).
41
+ # Measured: nokogiri=317, ox=1013, headed_ox=1009, oga=8673
42
+ parse_and_root: {
43
+ nokogiri: 700,
44
+ ox: 2500,
45
+ headed_ox: 2500,
46
+ oga: 18_000,
47
+ },
48
+ # First access to children (NodeSet construction).
49
+ first_children_access: {
50
+ nokogiri: 200,
51
+ ox: 200,
52
+ headed_ox: 200,
53
+ oga: 300,
54
+ },
55
+ # Second access to children (should be ~0 — cached).
56
+ # Measured: all adapters = 1-3
57
+ cached_children_access: {
58
+ nokogiri: 5,
59
+ ox: 5,
60
+ headed_ox: 5,
61
+ oga: 5,
62
+ },
63
+ # Second access to attributes (should be ~0 — cached).
64
+ # Measured: all adapters = 1
65
+ cached_attributes_access: {
66
+ nokogiri: 5,
67
+ ox: 5,
68
+ headed_ox: 5,
69
+ oga: 5,
70
+ },
71
+ # Second iteration of NodeSet (wrap cache hit).
72
+ # Measured: all adapters = 2
73
+ cached_iteration: {
74
+ nokogiri: 10,
75
+ ox: 10,
76
+ headed_ox: 10,
77
+ oga: 10,
78
+ },
79
+ # Parse + serialize round-trip (50 elements).
80
+ # Measured: nokogiri=222, ox=893, headed_ox=882, oga=9523
81
+ round_trip: {
82
+ nokogiri: 500,
83
+ ox: 2000,
84
+ headed_ox: 2000,
85
+ oga: 20_000,
86
+ },
87
+ # Ratio of allocations for 200-element vs 100-element parse.
88
+ # Must be <= max (linear growth). Quadratic would be > 4x.
89
+ # Measured: nokogiri=2.01, ox=2.0, headed_ox=2.0, oga=1.99
90
+ scalability_ratio: {
91
+ nokogiri: 2.5,
92
+ ox: 2.5,
93
+ headed_ox: 2.5,
94
+ oga: 2.5,
95
+ },
96
+ }.freeze
97
+
98
+ class << self
99
+ # Count object allocations during a block.
100
+ # Uses GC.stat[:total_allocated_objects] for precision.
101
+ def count_allocations
102
+ GC.start
103
+ GC.disable
104
+ before = GC.stat[:total_allocated_objects] || ObjectSpace.count_objects[:TOTAL]
105
+ result = yield
106
+ after = GC.stat[:total_allocated_objects] || ObjectSpace.count_objects[:TOTAL]
107
+ after - before
108
+ ensure
109
+ GC.enable
110
+ result
111
+ end
112
+
113
+ # Check if an adapter is available for testing.
114
+ def adapter_available?(adapter_name)
115
+ ctx = Moxml::Context.new(adapter_name)
116
+ ctx.parse("<root/>")
117
+ true
118
+ rescue StandardError
119
+ false
120
+ end
121
+
122
+ # Get the allocation threshold for an adapter + operation.
123
+ def threshold(adapter_name, operation)
124
+ THRESHOLDS.dig(operation, adapter_name) ||
125
+ raise(ArgumentError, "No threshold for #{adapter_name}/#{operation}")
126
+ end
127
+
128
+ # Run StackProf and return top hotspots as a diagnostic string.
129
+ # Tries :obj mode first (allocation profiling), falls back to :wall.
130
+ def profile_allocations(&block)
131
+ require "stackprof"
132
+
133
+ # :obj mode tracks object allocations but requires platform support.
134
+ # :wall mode tracks wall-clock time — less precise but always available.
135
+ result = begin
136
+ StackProf.run(mode: :obj, &block)
137
+ rescue ArgumentError
138
+ StackProf.run(mode: :wall, &block)
139
+ end
140
+ return nil unless result
141
+
142
+ frames = result[:frames]
143
+ total_samples = result[:samples]
144
+
145
+ hotspots = frames.sort_by { |_, f| -f[:samples] }.first(10)
146
+ lines = ["StackProf hotspot (#{total_samples} total samples):"]
147
+ hotspots.each do |name, frame|
148
+ pct = (frame[:samples].to_f / total_samples * 100).round(1)
149
+ lines << " #{pct}% #{name} (#{frame[:samples]} samples)"
150
+ end
151
+ lines.join("\n")
152
+ rescue LoadError
153
+ "StackProf not available — add gem 'stackprof' to Gemfile"
154
+ end
155
+ end
156
+ end
157
+
158
+ # Generate a test XML document with N elements.
159
+ # Each element has 2 attributes and nested text content.
160
+ def generate_xml(element_count)
161
+ inner = Array.new(element_count) do |i|
162
+ "<elem#{i % 10} id=\"#{i}\" type=\"t#{i % 3}\">text#{i}</elem#{i % 10}>"
163
+ end.join
164
+ "<root>#{inner}</root>"
165
+ end
@@ -2,7 +2,8 @@
2
2
 
3
3
  require "rexml/document"
4
4
 
5
- W3C_NS_FIXTURES_DIR = File.expand_path("../fixtures/w3c/namespaces/1.0", __dir__)
5
+ W3C_NS_FIXTURES_DIR = File.expand_path("../fixtures/w3c/namespaces/1.0",
6
+ __dir__)
6
7
 
7
8
  # Parse the test catalog to get test metadata
8
9
  def load_w3c_namespace_tests