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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +117 -66
- data/Gemfile +1 -0
- data/README.adoc +11 -9
- data/Rakefile +34 -1
- data/TODO.remaining/1-entity-reference-adapter-support.md +157 -0
- data/TODO.remaining/2-entity-restoration-model-driven.md +169 -0
- data/TODO.remaining/3-entity-reference-test-coverage.md +170 -0
- data/TODO.remaining/4-lenient-entities-mode.md +106 -0
- data/TODO.remaining/5-fixture-integrity.md +65 -0
- data/TODO.remaining/6-ox-element-ordering-bug.md +36 -0
- data/TODO.remaining/7-headed-ox-limitations.md +95 -0
- data/TODO.remaining/8-xpath-predicate-gaps.md +68 -0
- data/TODO.remaining/9-cleanup-hygiene.md +42 -0
- data/TODO.remaining/README.md +54 -0
- data/benchmarks/generate_report.rb +1 -1
- data/docs/_pages/configuration.adoc +22 -19
- data/docs/_tutorials/namespace-handling.adoc +5 -5
- data/lib/moxml/adapter/base.rb +22 -3
- data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -1
- data/lib/moxml/adapter/customized_libxml/entity_reference.rb +23 -0
- data/lib/moxml/adapter/customized_libxml.rb +18 -0
- data/lib/moxml/adapter/customized_oga.rb +10 -0
- data/lib/moxml/adapter/customized_ox/entity_reference.rb +25 -0
- data/lib/moxml/adapter/customized_ox.rb +12 -0
- data/lib/moxml/adapter/customized_rexml/entity_reference.rb +19 -0
- data/lib/moxml/adapter/customized_rexml/formatter.rb +44 -20
- data/lib/moxml/adapter/customized_rexml.rb +11 -0
- data/lib/moxml/adapter/headed_ox.rb +37 -14
- data/lib/moxml/adapter/libxml.rb +233 -119
- data/lib/moxml/adapter/nokogiri.rb +22 -11
- data/lib/moxml/adapter/oga.rb +64 -25
- data/lib/moxml/adapter/ox.rb +198 -42
- data/lib/moxml/adapter/rexml.rb +64 -13
- data/lib/moxml/attribute.rb +3 -0
- data/lib/moxml/builder.rb +78 -24
- data/lib/moxml/config.rb +24 -7
- data/lib/moxml/declaration.rb +4 -2
- data/lib/moxml/document.rb +8 -1
- data/lib/moxml/document_builder.rb +44 -37
- data/lib/moxml/element.rb +18 -5
- data/lib/moxml/entity_registry.rb +51 -1
- data/lib/moxml/native_attachment.rb +65 -0
- data/lib/moxml/node.rb +39 -50
- data/lib/moxml/node_set.rb +43 -15
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xml_utils.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +4 -1
- data/lib/moxml.rb +1 -0
- data/scripts/format_xml.rb +16 -0
- data/scripts/pretty_format_xml.rb +14 -0
- data/spec/consistency/round_trip_spec.rb +3 -30
- data/spec/integration/all_adapters_spec.rb +1 -0
- data/spec/integration/headed_ox_integration_spec.rb +0 -2
- data/spec/integration/shared_examples/edge_cases.rb +7 -4
- data/spec/integration/shared_examples/integration_workflows.rb +3 -3
- data/spec/integration/shared_examples/node_wrappers/cdata_behavior.rb +1 -1
- data/spec/integration/shared_examples/node_wrappers/entity_reference_behavior.rb +224 -0
- data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +1 -1
- data/spec/moxml/adapter/headed_ox_spec.rb +8 -8
- data/spec/moxml/adapter/oga_spec.rb +46 -0
- data/spec/moxml/adapter/shared_examples/adapter_contract.rb +1 -12
- data/spec/moxml/allocation_benchmark_spec.rb +96 -0
- data/spec/moxml/allocation_guard_spec.rb +282 -0
- data/spec/moxml/builder_spec.rb +256 -0
- data/spec/moxml/config_spec.rb +11 -11
- data/spec/moxml/doctype_spec.rb +41 -0
- data/spec/moxml/lazy_parse_spec.rb +115 -0
- data/spec/moxml/namespace_uri_validation_spec.rb +11 -3
- data/spec/moxml/node_cache_spec.rb +110 -0
- data/spec/moxml/node_set_cache_spec.rb +90 -0
- data/spec/moxml/xml_utils_spec.rb +32 -0
- data/spec/moxml/xpath/axes_spec.rb +1 -1
- data/spec/moxml/xpath/compiler_spec.rb +2 -2
- data/spec/moxml/xpath/functions/position_functions_spec.rb +5 -5
- data/spec/moxml/xpath/functions/special_functions_spec.rb +1 -1
- data/spec/performance/memory_usage_spec.rb +0 -4
- data/spec/support/allocation_helper.rb +165 -0
- data/spec/support/w3c_namespace_helpers.rb +2 -1
- 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
|
|
95
|
+
describe "lenient namespace_validation_mode" do
|
|
96
96
|
let(:context) do
|
|
97
97
|
Moxml.new do |config|
|
|
98
|
-
config.
|
|
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
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|