moxml 0.5.32 → 0.5.33
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/Rakefile +8 -0
- data/benchmark/pipeline_bench.rb +74 -0
- data/docs/_pages/adapter-protocol.adoc +5 -0
- data/docs/_pages/node-api-reference.adoc +26 -0
- data/docs/_pages/performance.adoc +43 -0
- data/lib/moxml/adapter/base.rb +25 -2
- data/lib/moxml/adapter/leptris.rb +54 -19
- data/lib/moxml/adapter/nokogiri.rb +13 -14
- data/lib/moxml/adapter/oga.rb +5 -0
- data/lib/moxml/adapter/ox.rb +27 -5
- data/lib/moxml/adapter/rexml.rb +5 -0
- data/lib/moxml/attribute.rb +31 -4
- data/lib/moxml/attribute_resolver.rb +6 -6
- data/lib/moxml/c14n/exclusive.rb +1 -1
- data/lib/moxml/context.rb +12 -0
- data/lib/moxml/document.rb +1 -1
- data/lib/moxml/element.rb +55 -9
- data/lib/moxml/node.rb +25 -5
- data/lib/moxml/text.rb +1 -1
- data/lib/moxml/version.rb +1 -1
- data/spec/integration/contract_parity_spec.rb +123 -0
- data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +4 -1
- data/spec/moxml/adapter/leptris_spec.rb +53 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fcb33efd8969194833ce7ee6b7e3e3bfa6517ec7d216028407d28fbc95b1641
|
|
4
|
+
data.tar.gz: ec2239b9b5501b208331ce5874764ad99cad357bacec08af6cc47d1f3aa69aff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18526fb6430f0231b187e888104c4f2fef7e38999d1015d8324ea53b90339de4c0d5d58cb4b591380b5d6cca704014fcb30ca5429f5abeff2a027217e1d47e54
|
|
7
|
+
data.tar.gz: 206cfe96f4bcac080a134092dac6f305d931d9d589c0dd544cf47e6bceb39d77d023f3b6e6c80deaeba94d10997840c90b583770d8c9d35e81dca65706354c0c
|
data/Rakefile
CHANGED
|
@@ -244,6 +244,14 @@ namespace :benchmark do
|
|
|
244
244
|
ENV.delete("SKIP_BENCHMARKS")
|
|
245
245
|
sh "bundle exec rspec spec/performance/xpath_benchmark_spec.rb"
|
|
246
246
|
end
|
|
247
|
+
# Consumer-pipeline leg (issue #198): nested document walked into
|
|
248
|
+
# typed objects — median time + GC.stat allocation deltas, where
|
|
249
|
+
# engine-level numbers hide wrapper overhead. PIPELINE_ADAPTER and
|
|
250
|
+
# PIPELINE_REPS env-overridable.
|
|
251
|
+
desc "Consumer-pipeline benchmark (typed-object walk, issue #198)"
|
|
252
|
+
task :pipeline do
|
|
253
|
+
sh "ruby", "benchmark/pipeline_bench.rb"
|
|
254
|
+
end
|
|
247
255
|
|
|
248
256
|
desc "Generate adapter benchmark report"
|
|
249
257
|
task :report do
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Consumer-pipeline benchmark leg (issue #198): measures a nested
|
|
4
|
+
# document walked into typed objects — the shape consumers actually
|
|
5
|
+
# run, where engine-level numbers hide wrapper overhead (Amdahl).
|
|
6
|
+
# Reports fresh-run medians with GC.stat allocation deltas.
|
|
7
|
+
require "moxml"
|
|
8
|
+
require "nokogiri"
|
|
9
|
+
|
|
10
|
+
ADAPTER = (ENV["PIPELINE_ADAPTER"] || "leptris").to_sym
|
|
11
|
+
REPS = (ENV["PIPELINE_REPS"] || 7).to_i
|
|
12
|
+
|
|
13
|
+
parts = [%(<?xml version="1.0"?><catalog>)]
|
|
14
|
+
150.times do |i|
|
|
15
|
+
parts << %(<record id="r#{i}" kind="k#{i % 3}">)
|
|
16
|
+
6.times { |j| parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>) }
|
|
17
|
+
parts << %(</record>)
|
|
18
|
+
end
|
|
19
|
+
parts << %(</catalog>)
|
|
20
|
+
XML = parts.join
|
|
21
|
+
|
|
22
|
+
Field = Struct.new(:name, :unit, :value)
|
|
23
|
+
Record = Struct.new(:id, :kind, :fields)
|
|
24
|
+
Catalog = Struct.new(:records)
|
|
25
|
+
|
|
26
|
+
def walk_document(adapter_name)
|
|
27
|
+
ctx = Moxml.new(adapter_name)
|
|
28
|
+
doc = ctx.parse(XML)
|
|
29
|
+
catalog = Catalog.new([])
|
|
30
|
+
doc.root.children.each do |rec|
|
|
31
|
+
next unless rec.is_a?(Moxml::Element)
|
|
32
|
+
|
|
33
|
+
record = Record.new(rec["id"], rec["kind"], [])
|
|
34
|
+
rec.children.each do |f|
|
|
35
|
+
next unless f.is_a?(Moxml::Element)
|
|
36
|
+
|
|
37
|
+
record.fields << Field.new(f["name"], f["unit"], f.text)
|
|
38
|
+
end
|
|
39
|
+
catalog.records << record
|
|
40
|
+
end
|
|
41
|
+
raise "shape" unless catalog.records.size == 150
|
|
42
|
+
|
|
43
|
+
raise "content" unless catalog.records[7].fields[3].value.include?("7.3")
|
|
44
|
+
|
|
45
|
+
catalog
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def measure(adapter_name)
|
|
49
|
+
walk_document(adapter_name) # warm: autoloads off the books
|
|
50
|
+
times = []
|
|
51
|
+
allocs = nil
|
|
52
|
+
REPS.times do
|
|
53
|
+
GC.start
|
|
54
|
+
a0 = GC.stat(:total_allocated_objects)
|
|
55
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
walk_document(adapter_name)
|
|
57
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
a1 = GC.stat(:total_allocated_objects)
|
|
59
|
+
times << ((t1 - t0) * 1e6)
|
|
60
|
+
allocs = a1 - a0
|
|
61
|
+
end
|
|
62
|
+
median = times.sort[times.size / 2]
|
|
63
|
+
[median, allocs]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
noko_med, noko_alloc = measure(:nokogiri)
|
|
67
|
+
puts format("pipeline nokogiri: median %<med>7.0fµs %<allocs>6d allocs", med: noko_med, allocs: noko_alloc)
|
|
68
|
+
target_med, target_alloc = measure(ADAPTER)
|
|
69
|
+
puts format("pipeline %<adapter>-8s: median %<med>7.0fµs %<allocs>6d allocs (%<ratio>.2fx, allocs %<aratio>.2fx)",
|
|
70
|
+
adapter: ADAPTER, med: target_med, allocs: target_alloc,
|
|
71
|
+
ratio: noko_med / target_med, aratio: noko_alloc.to_f / target_alloc)
|
|
72
|
+
puts format("(one-shot shape: parse + typed walk + content asserts; YJIT=%<yjit>s; load %<load>.1f)",
|
|
73
|
+
yjit: ENV["RUBY_YJIT_ENABLE"] ? "on" : "off",
|
|
74
|
+
load: `sysctl -n vm.loadavg`.split[1].to_f)
|
|
@@ -91,6 +91,11 @@ the context's wrapper identity map opts out.
|
|
|
91
91
|
Whether the subtree can contain entity markers; gates the
|
|
92
92
|
post-serialize restore scan.
|
|
93
93
|
|
|
94
|
+
`digest(node, drop_ws_text:)`::
|
|
95
|
+
The backend's content-defined subtree Merkle hash where one exists
|
|
96
|
+
(leptris >= 1.9.99, engine #869); nil everywhere else. `Node#digest`
|
|
97
|
+
gates on nil — consumers fall back to walking.
|
|
98
|
+
|
|
94
99
|
`bulk_materialize?`::
|
|
95
100
|
Whether the adapter offers a bulk path for `Moxml::Materializer`;
|
|
96
101
|
`materialize_fields` fills the reused flat buffers and yields the
|
|
@@ -114,6 +114,32 @@ node.namespace? # Is it a namespace?
|
|
|
114
114
|
node.document # Get owning document
|
|
115
115
|
----
|
|
116
116
|
|
|
117
|
+
== Subtree digest
|
|
118
|
+
|
|
119
|
+
`Node#digest` exposes the backend's content-defined Merkle hash of a
|
|
120
|
+
subtree when one exists — currently only the leptris adapter
|
|
121
|
+
(libleptris >= 1.9.99, leptris#869):
|
|
122
|
+
|
|
123
|
+
[source,ruby]
|
|
124
|
+
----
|
|
125
|
+
node.digest # => Integer (u64) or nil
|
|
126
|
+
node.digest(drop_ws_text: true) # skip whitespace-only text nodes
|
|
127
|
+
----
|
|
128
|
+
|
|
129
|
+
Contract:
|
|
130
|
+
|
|
131
|
+
* `nil` means "no digest support" (every other adapter) — consumers
|
|
132
|
+
gate on nil and fall back to walking.
|
|
133
|
+
* Equal digests imply subtree equivalence under the flag set;
|
|
134
|
+
unequal digests imply nothing (descend and compare).
|
|
135
|
+
* The hash covers element prefix + resolved namespace URI + local
|
|
136
|
+
name, attributes sorted by (URI, local) and first-wins
|
|
137
|
+
deduplicated, and children in document order. No addresses
|
|
138
|
+
participate, so identical trees hash equal across parses and
|
|
139
|
+
processes.
|
|
140
|
+
* Nodes without a backing engine node (documents, attributes,
|
|
141
|
+
synthetic declarations) answer nil.
|
|
142
|
+
|
|
117
143
|
|
|
118
144
|
See also:
|
|
119
145
|
|
|
@@ -106,6 +106,26 @@ load-immune — use them when the machine is busy:*
|
|
|
106
106
|
hundreds of iterations — spot checks of 30 have missed 5-30% races
|
|
107
107
|
on this codebase.
|
|
108
108
|
|
|
109
|
+
*More traps, paid for in this codebase:*
|
|
110
|
+
|
|
111
|
+
- Optional keyword arguments do NOT allocate on Ruby 3.4 (measured
|
|
112
|
+
0.000 allocs/call with one and two optional kwargs) — do not
|
|
113
|
+
"optimize" them into positional arguments expecting an allocation
|
|
114
|
+
win. An earlier version of this page claimed otherwise: a +601
|
|
115
|
+
allocation delta attributed to kwargs was actually the write-path
|
|
116
|
+
cache clears allocating a fresh Hash per write (+500), bundled
|
|
117
|
+
into the same change as a kwargs edit. The general lesson:
|
|
118
|
+
attribute allocation deltas per-change, never per-bundle.
|
|
119
|
+
- Engines free attribute natives on removal — reading any native
|
|
120
|
+
property after `remove_attribute_native` is a use-after-free.
|
|
121
|
+
The crash only surfaced mid-suite under GC churn (standalone
|
|
122
|
+
repros passed); read names BEFORE the removal call.
|
|
123
|
+
- Scope invalidation to what the mutation can affect: a
|
|
124
|
+
non-xmlns attribute write cannot change namespace scope, so it
|
|
125
|
+
must not bump the document-wide scope generation (which evicts
|
|
126
|
+
every wrapper's caches). +5-8% on write-heavy mixed workloads,
|
|
127
|
+
and bulk builds stop thrashing every reader's cache.
|
|
128
|
+
|
|
109
129
|
=== leptris 1.9.105 numbers (reference table)
|
|
110
130
|
|
|
111
131
|
XML parse 2.4-2.8x, document serialize 2.4x, element queries 32x,
|
|
@@ -198,6 +218,29 @@ first access, not at construction — `.size`/`.empty?`/`.first` on a
|
|
|
198
218
|
(that path also skips the binding's per-node wrapper materialization
|
|
199
219
|
entirely; see `LazyNodeSet` under the leptris adapter).
|
|
200
220
|
|
|
221
|
+
=== Mint priming and scoped invalidation
|
|
222
|
+
|
|
223
|
+
`Node.wrap` resolves the adapter and node type once and primes both
|
|
224
|
+
onto the wrapper (positional constructor arguments) — a fresh
|
|
225
|
+
wrapper previously re-paid the context hop and the type probe on
|
|
226
|
+
its first access. Attribute writes that cannot change namespace
|
|
227
|
+
scope (bare and non-`xmlns` prefixed names) invalidate the
|
|
228
|
+
element's caches locally instead of bumping the document-wide scope
|
|
229
|
+
generation; `xmlns` writes keep the global bump.
|
|
230
|
+
|
|
231
|
+
| path | before | after |
|
|
232
|
+
| Attribute write (`[]=`) | 822ns | 758ns (-8%) |
|
|
233
|
+
| Cold walk (parse + wrapper mint, 752 nodes) | 811µs | 699µs (-14%) |
|
|
234
|
+
| Mixed build+read (nokogiri) | 1355µs / 5267 allocs | 1283µs / 5267 allocs |
|
|
235
|
+
| `Element#text` read | 422ns | 346ns (-18%) |
|
|
236
|
+
| `create_text` allocations | 6/node | 5/node |
|
|
237
|
+
| Bare attribute read `[]` | 714ns | 513ns (-28%) |
|
|
238
|
+
|
|
239
|
+
Allocations unchanged (the invalidation clears are nil-writes; the
|
|
240
|
+
read cache materializes lazily). The remaining cold-walk gap is
|
|
241
|
+
binding-side (its per-node Ruby wrapper construction) — tracked
|
|
242
|
+
upstream as the TypedData work.
|
|
243
|
+
|
|
201
244
|
=== Prefer bulk paths for per-node conversion
|
|
202
245
|
|
|
203
246
|
Per-node Ruby iteration pays the wrapper tax per element. When the
|
data/lib/moxml/adapter/base.rb
CHANGED
|
@@ -28,6 +28,24 @@ module Moxml
|
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Uniform fragment parsing: returns the fragment's top-level
|
|
32
|
+
# nodes as native objects. Engines without a fragment node
|
|
33
|
+
# type (everything but Nokogiri) get the wrapper-parse
|
|
34
|
+
# shape — the same trick Element#append_xml and #inner_xml=
|
|
35
|
+
# use — with a synthetic root whose children are the
|
|
36
|
+
# fragment's top-level nodes.
|
|
37
|
+
def root(_document)
|
|
38
|
+
raise Moxml::NotImplementedError.new(
|
|
39
|
+
"root not implemented", feature: "root", adapter: name
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_fragment(xml, _context = nil)
|
|
44
|
+
doc = parse("<m>#{xml}</m>")
|
|
45
|
+
root = root(doc)
|
|
46
|
+
root ? children(root) : []
|
|
47
|
+
end
|
|
48
|
+
|
|
31
49
|
# Streaming incremental parse (leptris engine): yields each
|
|
32
50
|
# completed element while the parse runs, releasing prior
|
|
33
51
|
# subtrees — memory bounded by the largest subtree, not the
|
|
@@ -98,14 +116,19 @@ module Moxml
|
|
|
98
116
|
)
|
|
99
117
|
end
|
|
100
118
|
|
|
119
|
+
# Backends without a native subtree digest answer nil —
|
|
120
|
+
# the wrapper contract Node#digest gates on (issue #173).
|
|
121
|
+
def digest(*)
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
101
125
|
def create_element(name, owner_doc: nil)
|
|
102
126
|
validate_element_name(name)
|
|
103
127
|
create_native_element(name, owner_doc)
|
|
104
128
|
end
|
|
105
129
|
|
|
106
130
|
def create_text(content, owner_doc: nil)
|
|
107
|
-
|
|
108
|
-
create_native_text(normalize_xml_value(content).dup, owner_doc)
|
|
131
|
+
create_native_text(normalize_xml_value(content), owner_doc)
|
|
109
132
|
end
|
|
110
133
|
|
|
111
134
|
def create_cdata(content, owner_doc: nil)
|
|
@@ -34,6 +34,12 @@ module Moxml
|
|
|
34
34
|
ATTR_RESULT_NATIVE =
|
|
35
35
|
Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.105")
|
|
36
36
|
|
|
37
|
+
# leptris_node_digest shipped in bindings 1.9.99 (libleptris
|
|
38
|
+
# 1.9.99, engine #869): on-demand Merkle subtree hash, zero
|
|
39
|
+
# cost when unused. Below it, Node#digest answers nil.
|
|
40
|
+
DIGEST_SUPPORTED =
|
|
41
|
+
Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.99")
|
|
42
|
+
|
|
37
43
|
# Native C14N delegation probe: the engine's C14N must
|
|
38
44
|
# byte-match the Ruby reference (ported from canon) on a
|
|
39
45
|
# namespace-sorting + attributes + comments shape before
|
|
@@ -180,13 +186,12 @@ module Moxml
|
|
|
180
186
|
# restoration when the document carries them (entity-free
|
|
181
187
|
# documents — the common case — skip the scan; parentless
|
|
182
188
|
# iterparse elements are never bearing).
|
|
189
|
+
# Raw qualified-name read; the entity-restore decision is
|
|
190
|
+
# the wrapper's (Element#[] rides its generation memo — the
|
|
191
|
+
# document-plus-attachment probe here cost more than the
|
|
192
|
+
# read itself).
|
|
183
193
|
def bare_attr_value(element, name)
|
|
184
|
-
|
|
185
|
-
if value.is_a?(String) && entity_bearing?(element)
|
|
186
|
-
restore_entities(value)
|
|
187
|
-
else
|
|
188
|
-
value
|
|
189
|
-
end
|
|
194
|
+
element[name.to_s]
|
|
190
195
|
end
|
|
191
196
|
|
|
192
197
|
def native_identity_stable?
|
|
@@ -254,6 +259,15 @@ module Moxml
|
|
|
254
259
|
# elements as the parse runs; each prior subtree is released.
|
|
255
260
|
# Yielded elements are parentless (document nil) and valid
|
|
256
261
|
# only inside the block — the wrapper mirrors that lifetime.
|
|
262
|
+
# Wrapper-parse: the synthetic root's children are the
|
|
263
|
+
# fragment's top-level nodes (the append_xml / inner_xml=
|
|
264
|
+
# trick). Children come through the normal children path so
|
|
265
|
+
# entity-marker splitting applies.
|
|
266
|
+
def parse_fragment(xml, _context = nil)
|
|
267
|
+
doc = parse("<m>#{xml}</m>")
|
|
268
|
+
children(doc.native.root)
|
|
269
|
+
end
|
|
270
|
+
|
|
257
271
|
def iterparse(xml, mode = :top_level, _context = nil, &block)
|
|
258
272
|
raise ArgumentError, "iterparse requires a block" unless block
|
|
259
273
|
|
|
@@ -537,6 +551,18 @@ module Moxml
|
|
|
537
551
|
end
|
|
538
552
|
end
|
|
539
553
|
|
|
554
|
+
# Subtree digest over the binding node (issue #173). Only
|
|
555
|
+
# binding Nodes carry a C node handle: the synthetic
|
|
556
|
+
# wrappers (declarations, doctypes, entity markers) and the
|
|
557
|
+
# lightweight Attr triples answer nil.
|
|
558
|
+
def digest(node, drop_ws_text: false)
|
|
559
|
+
return nil unless DIGEST_SUPPORTED
|
|
560
|
+
return nil unless node.is_a?(::Leptris::XML::Node) &&
|
|
561
|
+
!node.is_a?(::Leptris::XML::ResultAttr)
|
|
562
|
+
|
|
563
|
+
node.digest(drop_ws: drop_ws_text)
|
|
564
|
+
end
|
|
565
|
+
|
|
540
566
|
def node_name(node)
|
|
541
567
|
return node.root_name if node.is_a?(::Leptris::XML::DocType)
|
|
542
568
|
return node.target if node.is_a?(CustomizedLeptris::DocumentPI)
|
|
@@ -567,19 +593,10 @@ module Moxml
|
|
|
567
593
|
end
|
|
568
594
|
|
|
569
595
|
def children(node)
|
|
596
|
+
# Frequency-ordered: elements dominate every walk and paid
|
|
597
|
+
# ten failed compares to reach the else arm.
|
|
570
598
|
case node
|
|
571
|
-
when ::Leptris::XML::
|
|
572
|
-
assemble_document_children(node)
|
|
573
|
-
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
574
|
-
CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
|
|
575
|
-
CustomizedLeptris::DocumentPI,
|
|
576
|
-
# Terminal node kinds pay an FFI round trip for an empty
|
|
577
|
-
# list; unfiltered recursions visit every text node.
|
|
578
|
-
::Leptris::XML::Text, ::Leptris::XML::Comment,
|
|
579
|
-
::Leptris::XML::CDATA, ::Leptris::XML::ProcessingInstruction,
|
|
580
|
-
::Leptris::XML::Attr
|
|
581
|
-
[]
|
|
582
|
-
else
|
|
599
|
+
when ::Leptris::XML::Element
|
|
583
600
|
natives = node.children.to_a
|
|
584
601
|
# Parse records whether the preprocessed source held any
|
|
585
602
|
# entity markers, and the ER builder path flips the flag
|
|
@@ -592,6 +609,19 @@ module Moxml
|
|
|
592
609
|
attachments.get(node.document, :entity_markers) == false
|
|
593
610
|
|
|
594
611
|
split_entity_markers(natives, node)
|
|
612
|
+
when ::Leptris::XML::Document
|
|
613
|
+
assemble_document_children(node)
|
|
614
|
+
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
615
|
+
CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
|
|
616
|
+
CustomizedLeptris::DocumentPI,
|
|
617
|
+
# Terminal node kinds pay an FFI round trip for an empty
|
|
618
|
+
# list; unfiltered recursions visit every text node.
|
|
619
|
+
::Leptris::XML::Text, ::Leptris::XML::Comment,
|
|
620
|
+
::Leptris::XML::CDATA, ::Leptris::XML::ProcessingInstruction,
|
|
621
|
+
::Leptris::XML::Attr
|
|
622
|
+
[]
|
|
623
|
+
else
|
|
624
|
+
node.children.to_a
|
|
595
625
|
end
|
|
596
626
|
end
|
|
597
627
|
|
|
@@ -766,13 +796,18 @@ module Moxml
|
|
|
766
796
|
end
|
|
767
797
|
|
|
768
798
|
def text_content(node)
|
|
799
|
+
# Frequency-ordered: elements dominate reads; they paid
|
|
800
|
+
# three failed compares to reach the else arm. The
|
|
801
|
+
# duplicated branch bodies are the point.
|
|
769
802
|
case node
|
|
803
|
+
when ::Leptris::XML::Element, ::Leptris::XML::Text
|
|
804
|
+
node.content.to_s
|
|
770
805
|
when ::Leptris::XML::Document then node.root ? node.root.content : ""
|
|
771
806
|
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
772
807
|
CustomizedLeptris::EntityReference
|
|
773
808
|
""
|
|
774
809
|
when CustomizedLeptris::TextSegment then node.content
|
|
775
|
-
else node.content.to_s
|
|
810
|
+
else node.content.to_s # rubocop:disable Lint/DuplicateBranch
|
|
776
811
|
end
|
|
777
812
|
end
|
|
778
813
|
|
|
@@ -23,27 +23,26 @@ module Moxml
|
|
|
23
23
|
true
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Nokogiri has a real fragment node type — its children ARE
|
|
27
|
+
# the fragment's top-level nodes.
|
|
28
|
+
def parse_fragment(xml, _context = nil)
|
|
29
|
+
processed = Entity.preprocess_entities(xml)
|
|
30
|
+
::Nokogiri::XML::DocumentFragment.parse(processed) do |config|
|
|
31
|
+
config.strict.nonet
|
|
32
|
+
config.recover
|
|
33
|
+
end.children.to_a
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
# Fast bare-name read for Element#[]: the native call plus,
|
|
27
37
|
# only when the parse recorded entity markers, their
|
|
28
38
|
# restoration (the resolver path's other real semantic).
|
|
29
39
|
# The marker flag is constant per document — a WeakMap on
|
|
30
40
|
# the adapter beats the per-read document fetch + attachment
|
|
31
41
|
# hash chain.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
cached = cache[doc]
|
|
35
|
-
return cached unless cached.nil?
|
|
36
|
-
|
|
37
|
-
cache[doc] = attachments.get(doc, :entity_markers) == true
|
|
38
|
-
end
|
|
39
|
-
|
|
42
|
+
# Raw qualified-name read; the entity-restore decision is
|
|
43
|
+
# the wrapper's (Element#[] rides its generation memo).
|
|
40
44
|
def bare_attr_value(element, name)
|
|
41
|
-
|
|
42
|
-
if value.is_a?(String) && doc_entity_markers?(element.document)
|
|
43
|
-
restore_entities(value)
|
|
44
|
-
else
|
|
45
|
-
value
|
|
46
|
-
end
|
|
45
|
+
element[name.to_s]
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
def native_identity_stable?
|
data/lib/moxml/adapter/oga.rb
CHANGED
|
@@ -307,6 +307,11 @@ module Moxml
|
|
|
307
307
|
current
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
+
def parse_fragment(xml, _context = nil)
|
|
311
|
+
doc = parse("<m>#{xml}</m>").native
|
|
312
|
+
children(root(doc))
|
|
313
|
+
end
|
|
314
|
+
|
|
310
315
|
def root(document)
|
|
311
316
|
document.children.find { |node| node.is_a?(::Oga::XML::Element) }
|
|
312
317
|
end
|
data/lib/moxml/adapter/ox.rb
CHANGED
|
@@ -94,8 +94,11 @@ module Moxml
|
|
|
94
94
|
::Ox::Element.new(name)
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Ox stores the Ruby string object itself as the node value —
|
|
98
|
+
# without the copy, later caller mutations would write through
|
|
99
|
+
# into the document (ownership shield; see set_text_content).
|
|
97
100
|
def create_native_text(content, _owner_doc = nil)
|
|
98
|
-
content
|
|
101
|
+
content.dup
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
def create_native_entity_reference(name)
|
|
@@ -330,6 +333,12 @@ module Moxml
|
|
|
330
333
|
current
|
|
331
334
|
end
|
|
332
335
|
|
|
336
|
+
def parse_fragment(xml, _context = nil)
|
|
337
|
+
doc = parse("<m>#{xml}</m>").native
|
|
338
|
+
synthetic = doc.nodes.find { |node| node.is_a?(::Ox::Element) }
|
|
339
|
+
children(synthetic)
|
|
340
|
+
end
|
|
341
|
+
|
|
333
342
|
def root(document)
|
|
334
343
|
document.nodes&.find { |node| node.is_a?(::Ox::Element) }
|
|
335
344
|
end
|
|
@@ -357,7 +366,14 @@ module Moxml
|
|
|
357
366
|
# Ox converts all values to strings
|
|
358
367
|
remove_attribute(element, name)
|
|
359
368
|
else
|
|
360
|
-
|
|
369
|
+
key = name.to_s
|
|
370
|
+
attrs = element.attributes
|
|
371
|
+
# Ox parses attributes under Symbol keys; a naive
|
|
372
|
+
# string-keyed write would ADD a second entry instead
|
|
373
|
+
# of replacing (the parity suite caught reads returning
|
|
374
|
+
# the stale symbol-keyed value after a write).
|
|
375
|
+
attrs.delete(key.to_sym)
|
|
376
|
+
attrs[key] = value
|
|
361
377
|
end
|
|
362
378
|
|
|
363
379
|
::Moxml::Adapter::CustomizedOx::Attribute.new(
|
|
@@ -569,9 +585,11 @@ module Moxml
|
|
|
569
585
|
end
|
|
570
586
|
|
|
571
587
|
def set_text_content(node, content)
|
|
588
|
+
# Copy on the same ownership contract as create_native_text
|
|
589
|
+
content = content.to_s.dup
|
|
572
590
|
case node
|
|
573
|
-
when String then node.replace(content
|
|
574
|
-
when ::Ox::Element then node.replace_text(content
|
|
591
|
+
when String then node.replace(content)
|
|
592
|
+
when ::Ox::Element then node.replace_text(content)
|
|
575
593
|
else
|
|
576
594
|
node.value = content.to_s
|
|
577
595
|
end
|
|
@@ -602,7 +620,11 @@ module Moxml
|
|
|
602
620
|
end
|
|
603
621
|
|
|
604
622
|
def namespace_prefix(namespace)
|
|
605
|
-
namespace
|
|
623
|
+
# Ox spells the default namespace's prefix "xmlns"; the
|
|
624
|
+
# wrapper contract is nil there (parity with the other
|
|
625
|
+
# adapters' in-scope maps).
|
|
626
|
+
prefix = namespace.prefix
|
|
627
|
+
prefix == "xmlns" ? nil : prefix
|
|
606
628
|
end
|
|
607
629
|
|
|
608
630
|
def namespace_uri(namespace)
|
data/lib/moxml/adapter/rexml.rb
CHANGED
data/lib/moxml/attribute.rb
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Moxml
|
|
4
4
|
class Attribute < Node
|
|
5
|
+
# Immutable between name= writes (which clear it via
|
|
6
|
+
# clear_native_memo!), unlike element names that can change via
|
|
7
|
+
# native adoption; the memo removes an adapter read per access.
|
|
5
8
|
def name
|
|
6
|
-
adapter.attribute_name(@native)
|
|
9
|
+
@name ||= adapter.attribute_name(@native)
|
|
7
10
|
end
|
|
8
11
|
|
|
9
12
|
def name=(new_name)
|
|
@@ -11,6 +14,7 @@ module Moxml
|
|
|
11
14
|
# to keep tracking — same object for in-place adapters, fresh
|
|
12
15
|
# object for value-object adapters (leptris).
|
|
13
16
|
context.bump_namespace_scope_generation
|
|
17
|
+
@name = nil
|
|
14
18
|
@native = adapter.set_attribute_name(@native, new_name)
|
|
15
19
|
end
|
|
16
20
|
|
|
@@ -22,7 +26,16 @@ module Moxml
|
|
|
22
26
|
|
|
23
27
|
def value
|
|
24
28
|
val = @native.value.to_s
|
|
25
|
-
|
|
29
|
+
# Same guard as Element#text: entity-free documents skip the
|
|
30
|
+
# marker restore scans. The memo rides the owning element's
|
|
31
|
+
# (attr natives have no entity probe); a detached attribute
|
|
32
|
+
# wrapper falls back to the unconditional restore.
|
|
33
|
+
parent = @parent_node
|
|
34
|
+
if parent.nil? || parent.entity_bearing?
|
|
35
|
+
adapter.restore_entities(val)
|
|
36
|
+
else
|
|
37
|
+
val
|
|
38
|
+
end
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
alias content value
|
|
@@ -33,7 +46,13 @@ module Moxml
|
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def value=(new_value)
|
|
36
|
-
|
|
49
|
+
name = self.name
|
|
50
|
+
if name == "xmlns" || name.start_with?("xmlns:")
|
|
51
|
+
# Declaration rewrite — namespace scope changed
|
|
52
|
+
context.bump_namespace_scope_generation
|
|
53
|
+
else
|
|
54
|
+
@parent_node&.invalidate_attribute_value_cache!
|
|
55
|
+
end
|
|
37
56
|
adapter.set_attribute_value(@native, new_value)
|
|
38
57
|
end
|
|
39
58
|
|
|
@@ -59,9 +78,17 @@ module Moxml
|
|
|
59
78
|
end
|
|
60
79
|
|
|
61
80
|
def remove
|
|
81
|
+
# The name must be read before the removal — engines free the
|
|
82
|
+
# attribute native, and post-removal reads are use-after-free.
|
|
83
|
+
name = self.name
|
|
84
|
+
declaration = name == "xmlns" || name.start_with?("xmlns:")
|
|
62
85
|
adapter.remove_attribute_native(@native)
|
|
63
86
|
if @parent_node.is_a?(Moxml::Element)
|
|
64
|
-
|
|
87
|
+
if declaration
|
|
88
|
+
@parent_node.invalidate_attribute_cache!
|
|
89
|
+
else
|
|
90
|
+
@parent_node.invalidate_local_attribute_cache!
|
|
91
|
+
end
|
|
65
92
|
end
|
|
66
93
|
self
|
|
67
94
|
end
|
|
@@ -41,7 +41,7 @@ module Moxml
|
|
|
41
41
|
uri = prefix_uri(element, prefix)
|
|
42
42
|
return nil if uri.nil?
|
|
43
43
|
|
|
44
|
-
adapter = element.
|
|
44
|
+
adapter = element.adapter
|
|
45
45
|
if adapter.is_a?(Moxml::Adapter::Leptris)
|
|
46
46
|
return adapter.expanded_attr_value(element.native, uri, local)
|
|
47
47
|
end
|
|
@@ -105,7 +105,7 @@ module Moxml
|
|
|
105
105
|
# @return [String] the assigned value
|
|
106
106
|
def assign(element, name, value)
|
|
107
107
|
name = name.to_s
|
|
108
|
-
adapter = element.
|
|
108
|
+
adapter = element.adapter
|
|
109
109
|
if name == "xmlns" || name.start_with?("xmlns:")
|
|
110
110
|
adapter.set_attribute(element.native, name, value)
|
|
111
111
|
element.invalidate_attribute_cache!
|
|
@@ -122,7 +122,7 @@ module Moxml
|
|
|
122
122
|
# writes.
|
|
123
123
|
if !name.include?(":") && adapter.bare_set_qname_safe?
|
|
124
124
|
adapter.set_attribute(element.native, name, value)
|
|
125
|
-
element.
|
|
125
|
+
element.invalidate_local_attribute_cache!
|
|
126
126
|
return value
|
|
127
127
|
end
|
|
128
128
|
|
|
@@ -133,7 +133,7 @@ module Moxml
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
adapter.set_attribute(element.native, name, value)
|
|
136
|
-
element.
|
|
136
|
+
element.invalidate_local_attribute_cache!
|
|
137
137
|
value
|
|
138
138
|
end
|
|
139
139
|
|
|
@@ -143,7 +143,7 @@ module Moxml
|
|
|
143
143
|
# @return [Moxml::Attribute, nil] the removed attribute
|
|
144
144
|
def remove(element, name)
|
|
145
145
|
name = name.to_s
|
|
146
|
-
adapter = element.
|
|
146
|
+
adapter = element.adapter
|
|
147
147
|
if name == "xmlns" || name.start_with?("xmlns:")
|
|
148
148
|
adapter.remove_attribute(element.native, name)
|
|
149
149
|
element.invalidate_attribute_cache!
|
|
@@ -154,7 +154,7 @@ module Moxml
|
|
|
154
154
|
return nil unless attr
|
|
155
155
|
|
|
156
156
|
adapter.remove_attribute_native(attr.native)
|
|
157
|
-
element.
|
|
157
|
+
element.invalidate_local_attribute_cache!
|
|
158
158
|
attr
|
|
159
159
|
end
|
|
160
160
|
|
data/lib/moxml/c14n/exclusive.rb
CHANGED
|
@@ -139,7 +139,7 @@ module Moxml
|
|
|
139
139
|
|
|
140
140
|
def bindings_declared_on(element)
|
|
141
141
|
bindings = {}
|
|
142
|
-
element.
|
|
142
|
+
element.namespace_definitions.each do |ns|
|
|
143
143
|
prefix = ns.prefix
|
|
144
144
|
prefix = "" if prefix.nil? || prefix == "xmlns"
|
|
145
145
|
bindings[prefix] = ns.uri
|
data/lib/moxml/context.rb
CHANGED
|
@@ -106,6 +106,18 @@ module Moxml
|
|
|
106
106
|
# Memory stays bounded by the largest subtree, not the document
|
|
107
107
|
# (iterparse_file streams the file C-side). Yielded elements are
|
|
108
108
|
# parentless and valid only inside the block.
|
|
109
|
+
# Parse an XML fragment: returns its top-level nodes as an
|
|
110
|
+
# Array of wrappers, uniformly across adapters (issue #188 —
|
|
111
|
+
# fragment: true was Nokogiri-only). Engines without a fragment
|
|
112
|
+
# node type parse inside a synthetic root; the fragment's nodes
|
|
113
|
+
# are the result either way.
|
|
114
|
+
def parse_fragment(xml)
|
|
115
|
+
adapter = config.adapter
|
|
116
|
+
adapter.parse_fragment(xml, self).map do |native|
|
|
117
|
+
Moxml::Node.wrap(native, self)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
109
121
|
def iterparse(xml, mode: :top_level, &block)
|
|
110
122
|
config.adapter.iterparse(xml, mode, self, &block)
|
|
111
123
|
end
|
data/lib/moxml/document.rb
CHANGED
data/lib/moxml/element.rb
CHANGED
|
@@ -63,7 +63,16 @@ module Moxml
|
|
|
63
63
|
# names, where resolution is real work.
|
|
64
64
|
adapter = self.adapter
|
|
65
65
|
if !key.include?(":") && adapter.bare_get_qname_safe?
|
|
66
|
-
|
|
66
|
+
# The entity-restore decision rides the wrapper's generation
|
|
67
|
+
# memo (as Element#text) — the adapter-level probe walks to
|
|
68
|
+
# the document and the attachment store on every read, which
|
|
69
|
+
# dominated the fast path.
|
|
70
|
+
value = adapter.bare_attr_value(@native, key)
|
|
71
|
+
if value.is_a?(String) && entity_bearing?
|
|
72
|
+
return adapter.restore_entities(value)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
cache = attribute_read_cache
|
|
@@ -86,7 +95,11 @@ module Moxml
|
|
|
86
95
|
# every attribute or namespace mutation anywhere bumps it.
|
|
87
96
|
def attribute_read_cache
|
|
88
97
|
generation = context.namespace_scope_generation
|
|
89
|
-
|
|
98
|
+
# @attribute_cache.nil? covers the local invalidation clears —
|
|
99
|
+
# a value or list change without a scope bump (see
|
|
100
|
+
# invalidate_local_attribute_cache!) — so writes never allocate
|
|
101
|
+
# a replacement hash; the next read materializes one lazily.
|
|
102
|
+
if @attribute_cache.nil? || @attribute_cache_generation != generation
|
|
90
103
|
@attribute_cache = {}
|
|
91
104
|
@attribute_cache_generation = generation
|
|
92
105
|
end
|
|
@@ -180,12 +193,25 @@ module Moxml
|
|
|
180
193
|
invalidate_namespace_cache!
|
|
181
194
|
end
|
|
182
195
|
|
|
196
|
+
# All namespaces IN SCOPE for this element — its own
|
|
197
|
+
# declarations plus everything inherited from ancestors —
|
|
198
|
+
# matching the Nokogiri #namespaces contract consumers port
|
|
199
|
+
# against (issue #198: this returned only own declarations,
|
|
200
|
+
# losing ancestor scope under every backend).
|
|
183
201
|
def namespaces
|
|
184
|
-
|
|
202
|
+
in_scope_namespaces
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The element's OWN namespace declarations only (not
|
|
206
|
+
# inherited). C14n's visibly-utilized calculation and the
|
|
207
|
+
# materializer's declaration pairs want exactly this shape.
|
|
208
|
+
def namespace_definitions
|
|
209
|
+
return @namespace_definitions unless @namespace_definitions.nil?
|
|
210
|
+
|
|
211
|
+
@namespace_definitions = adapter.namespace_definitions(@native).map do |ns|
|
|
185
212
|
Namespace.new(ns, context)
|
|
186
213
|
end
|
|
187
214
|
end
|
|
188
|
-
alias namespace_definitions namespaces
|
|
189
215
|
|
|
190
216
|
# The element's OWN namespace declarations as [prefix, uri]
|
|
191
217
|
# pairs (nil prefix = default namespace) — not the inherited
|
|
@@ -217,7 +243,10 @@ module Moxml
|
|
|
217
243
|
|
|
218
244
|
def text
|
|
219
245
|
val = adapter.text_content(@native)
|
|
220
|
-
|
|
246
|
+
# Entity-free documents (the common case) skip the marker
|
|
247
|
+
# restore scans entirely — the per-wrapper entity_bearing?
|
|
248
|
+
# memo rides the adapter's serialize generation.
|
|
249
|
+
entity_bearing? ? adapter.restore_entities(val) : val
|
|
221
250
|
end
|
|
222
251
|
|
|
223
252
|
alias content text
|
|
@@ -229,7 +258,7 @@ module Moxml
|
|
|
229
258
|
|
|
230
259
|
def inner_text
|
|
231
260
|
text = raw_inner_text
|
|
232
|
-
adapter.restore_entities(text)
|
|
261
|
+
entity_bearing? ? adapter.restore_entities(text) : text
|
|
233
262
|
end
|
|
234
263
|
|
|
235
264
|
# Returns inner text without entity marker restoration.
|
|
@@ -291,9 +320,25 @@ module Moxml
|
|
|
291
320
|
children
|
|
292
321
|
end
|
|
293
322
|
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
323
|
+
# Attribute mutations that cannot change namespace scope (bare
|
|
324
|
+
# and non-xmlns prefixed names) invalidate locally: the
|
|
325
|
+
# resolved-read cache alone for value writes, the wrapper list
|
|
326
|
+
# too when the attribute set changes. No context generation
|
|
327
|
+
# bump — that would evict every wrapper's caches document-wide
|
|
328
|
+
# on each write of a bulk build.
|
|
329
|
+
def invalidate_attribute_value_cache!
|
|
330
|
+
@attribute_cache = nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def invalidate_local_attribute_cache!
|
|
334
|
+
@attributes = nil
|
|
335
|
+
@attribute_cache = nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Called by the namespace-scoped attribute paths (xmlns writes
|
|
339
|
+
# and removals) and Attribute#name=. Clears the attribute list
|
|
340
|
+
# and the resolved-read cache, and bumps the context generation
|
|
341
|
+
# so cross-wrapper reads recompute.
|
|
297
342
|
def invalidate_attribute_cache!
|
|
298
343
|
@attributes = nil
|
|
299
344
|
@attribute_cache = nil
|
|
@@ -305,6 +350,7 @@ module Moxml
|
|
|
305
350
|
# any children cache — recomputes on next read.
|
|
306
351
|
def invalidate_namespace_cache!
|
|
307
352
|
@namespaces = nil
|
|
353
|
+
@namespace_definitions = nil
|
|
308
354
|
@in_scope_namespaces = nil
|
|
309
355
|
context.bump_namespace_scope_generation
|
|
310
356
|
end
|
data/lib/moxml/node.rb
CHANGED
|
@@ -12,10 +12,16 @@ module Moxml
|
|
|
12
12
|
|
|
13
13
|
attr_reader :native, :context
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# adapter/node_type are primed by Node.wrap, which resolves both
|
|
16
|
+
# before choosing the wrapper class — a fresh wrapper would
|
|
17
|
+
# otherwise pay the context hop and the type probe again on its
|
|
18
|
+
# first access.
|
|
19
|
+
def initialize(native, context, adapter = nil, node_type = nil)
|
|
16
20
|
@context = context
|
|
17
21
|
@native = native
|
|
18
22
|
@parent_node = nil
|
|
23
|
+
@adapter = adapter
|
|
24
|
+
@node_type_cached = node_type
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# Update native reference after identity-changing operations
|
|
@@ -349,6 +355,18 @@ module Moxml
|
|
|
349
355
|
adapter.line_number(@native)
|
|
350
356
|
end
|
|
351
357
|
|
|
358
|
+
# Content-defined Merkle digest of this subtree (issue #173,
|
|
359
|
+
# companion to leptris#869): a u64 Integer where the backend
|
|
360
|
+
# computes one, nil everywhere else. Consumers gate on nil and
|
|
361
|
+
# fall back to walking. Equal digests imply subtree equivalence
|
|
362
|
+
# under the flag set; unequal digests imply nothing (descend).
|
|
363
|
+
# +drop_ws_text+ skips whitespace-only text nodes.
|
|
364
|
+
#
|
|
365
|
+
# @return [Integer, nil]
|
|
366
|
+
def digest(drop_ws_text: false)
|
|
367
|
+
adapter.digest(@native, drop_ws_text: drop_ws_text)
|
|
368
|
+
end
|
|
369
|
+
|
|
352
370
|
def outer_xml
|
|
353
371
|
to_xml
|
|
354
372
|
end
|
|
@@ -418,10 +436,12 @@ module Moxml
|
|
|
418
436
|
cached = context.wrapper_for(node)
|
|
419
437
|
return cached if cached
|
|
420
438
|
|
|
421
|
-
|
|
439
|
+
adapter = adapter(context)
|
|
440
|
+
type = adapter.node_type(node)
|
|
422
441
|
klass = node_type_map[type] || self
|
|
423
442
|
|
|
424
|
-
klass.new(node, context
|
|
443
|
+
klass.new(node, context, adapter, type)
|
|
444
|
+
.tap { |wrapper| context.register_wrapper(node, wrapper) }
|
|
425
445
|
end
|
|
426
446
|
|
|
427
447
|
# Internal: Set the parent node for cache invalidation tracking.
|
|
@@ -429,14 +449,14 @@ module Moxml
|
|
|
429
449
|
# relationships. Public to allow cross-class usage within Moxml internals.
|
|
430
450
|
attr_writer :parent_node
|
|
431
451
|
|
|
432
|
-
protected
|
|
433
|
-
|
|
434
452
|
def adapter
|
|
435
453
|
# A context's adapter object is fixed for its lifetime; the
|
|
436
454
|
# chain deref ran on every node access.
|
|
437
455
|
@adapter ||= context.config.adapter
|
|
438
456
|
end
|
|
439
457
|
|
|
458
|
+
protected
|
|
459
|
+
|
|
440
460
|
def self.adapter(context)
|
|
441
461
|
context.config.adapter
|
|
442
462
|
end
|
data/lib/moxml/text.rb
CHANGED
data/lib/moxml/version.rb
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Cross-adapter contract parity (issue #198): every bundled adapter
|
|
4
|
+
# runs the same behavioral battery, so parity is CI-enforced rather
|
|
5
|
+
# than discovered by consumers.
|
|
6
|
+
%w[nokogiri leptris ox oga rexml].each do |adapter_name|
|
|
7
|
+
begin
|
|
8
|
+
next unless Moxml::Adapter.load_available?(adapter_name.to_sym)
|
|
9
|
+
rescue StandardError
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec.describe "Contract parity: #{adapter_name}", :adapter do
|
|
14
|
+
around do |example|
|
|
15
|
+
Moxml.with_config(adapter_name.to_sym, true, "UTF-8") do
|
|
16
|
+
example.run
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:ctx) { Moxml.new(adapter_name.to_sym) }
|
|
21
|
+
|
|
22
|
+
describe "fragment parsing (issue #188)" do
|
|
23
|
+
it "returns the fragment's top-level nodes uniformly" do
|
|
24
|
+
nodes = ctx.parse_fragment(%(<a x="1">t1</a><b/>tail))
|
|
25
|
+
expect(nodes.map(&:class)).to eq(
|
|
26
|
+
[Moxml::Element, Moxml::Element, Moxml::Text],
|
|
27
|
+
)
|
|
28
|
+
expect(nodes[0]["x"]).to eq("1")
|
|
29
|
+
expect(nodes[0].text).to eq("t1")
|
|
30
|
+
expect(nodes[2].content).to eq("tail")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "round-trips entities inside fragments" do
|
|
34
|
+
nodes = ctx.parse_fragment(%(<p>café & more</p>))
|
|
35
|
+
# The moxml XML contract preserves entity REFERENCES in
|
|
36
|
+
# text reads (the entity-preservation design) — uniform
|
|
37
|
+
# across adapters, fragments included.
|
|
38
|
+
expect(nodes.first.content).to include("é")
|
|
39
|
+
expect(nodes.first.to_xml).to include("&")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "returns [] for an empty fragment" do
|
|
43
|
+
expect(ctx.parse_fragment("")).to eq([])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe "nil namespace clearing (issue #164 contract)" do
|
|
48
|
+
it "accepts namespace = nil without raising" do
|
|
49
|
+
doc = ctx.parse(%(<r xmlns="urn:d"><c>t</c></r>))
|
|
50
|
+
child = doc.root.children.first
|
|
51
|
+
expect { child.namespace = nil }.not_to raise_error
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "namespace adoption on append" do
|
|
56
|
+
it "keeps a subtree's own declarations when appended" do
|
|
57
|
+
doc = ctx.parse(%(<root/>))
|
|
58
|
+
nodes = ctx.parse_fragment(%(<p:s xmlns:p="urn:p">x</p:s>))
|
|
59
|
+
doc.root.add_child(nodes.first)
|
|
60
|
+
out = doc.to_xml
|
|
61
|
+
expect(out).to include("urn:p")
|
|
62
|
+
expect(out).to include("x")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe "namespaces contract (issue #198 comment)" do
|
|
67
|
+
it "returns the in-scope map including ancestor declarations" do
|
|
68
|
+
doc = ctx.parse(%(<root xmlns:p="urn:p" xmlns="urn:d"><p:c plain="1"/></root>))
|
|
69
|
+
child = doc.root.children.first
|
|
70
|
+
map = child.namespaces.map { |n| [n.prefix, n.uri.to_s] }
|
|
71
|
+
expect(map).to contain_exactly(["p", "urn:p"], [nil, "urn:d"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "namespace_definitions stays the element's own declarations" do
|
|
75
|
+
doc = ctx.parse(%(<root xmlns:p="urn:p"><p:c/></root>))
|
|
76
|
+
child = doc.root.children.first
|
|
77
|
+
expect(child.namespace_definitions).to be_empty
|
|
78
|
+
expect(doc.root.namespace_definitions.map(&:prefix)).to eq(["p"])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe "subtree digest channel (issue #173)" do
|
|
83
|
+
it "answers an Integer on digest-capable backends, nil elsewhere" do
|
|
84
|
+
doc = ctx.parse(%(<r><a x="1">t</a></r>))
|
|
85
|
+
digest = doc.root.digest
|
|
86
|
+
expect(digest).to be_nil.or be_a(Integer)
|
|
87
|
+
# Where non-nil, it is deterministic within the backend
|
|
88
|
+
doc2 = ctx.parse(%(<r><a x="1">t</a></r>))
|
|
89
|
+
expect(doc2.root.digest).to eq(digest) unless digest.nil?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe "attribute channel semantics" do
|
|
94
|
+
it "reads and writes bare and prefixed attributes" do
|
|
95
|
+
doc = ctx.parse(%(<r xmlns:p="urn:p"><e a="1" p:b="2"/></r>))
|
|
96
|
+
e = doc.at_xpath("//e")
|
|
97
|
+
expect(e["a"]).to eq("1")
|
|
98
|
+
expect(e["p:b"]).to eq("2")
|
|
99
|
+
e["a"] = "changed"
|
|
100
|
+
expect(e["a"]).to eq("changed")
|
|
101
|
+
expect(e["p:b"]).to eq("2")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Writes invalidate locally (no document-wide scope bump); a
|
|
105
|
+
# sibling's resolved reads must survive interleaved writes.
|
|
106
|
+
it "keeps sibling read caches coherent across interleaved writes" do
|
|
107
|
+
doc = ctx.parse(%(<r><a x="1"/><b x="2"/></r>))
|
|
108
|
+
first, second = doc.root.children.to_a
|
|
109
|
+
expect(first["x"]).to eq("1")
|
|
110
|
+
expect(second["x"]).to eq("2")
|
|
111
|
+
first["x"] = "one"
|
|
112
|
+
first["y"] = "fresh"
|
|
113
|
+
expect(second["x"]).to eq("2")
|
|
114
|
+
expect(first["x"]).to eq("one")
|
|
115
|
+
expect(first["y"]).to eq("fresh")
|
|
116
|
+
second["x"] = "two"
|
|
117
|
+
expect(first["x"]).to eq("one")
|
|
118
|
+
expect(first["y"]).to eq("fresh")
|
|
119
|
+
expect(second["x"]).to eq("two")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -134,7 +134,10 @@ RSpec.shared_examples "Moxml::Namespace" do
|
|
|
134
134
|
child.add_namespace("dc", "http://purl.org/dc/elements/1.1/")
|
|
135
135
|
root.add_child(child)
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# Own declarations (the #namespaces name moved to the
|
|
138
|
+
# in-scope Nokogiri contract — issue #198; this assertion
|
|
139
|
+
# pins own-declarations, which is what it always meant).
|
|
140
|
+
ns_defs = child.namespace_definitions
|
|
138
141
|
prefixes = ns_defs.map(&:prefix)
|
|
139
142
|
|
|
140
143
|
expect(prefixes).to contain_exactly("dc")
|
|
@@ -634,4 +634,57 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
634
634
|
expect(doc.at_xpath("//a").children.to_a.map(&:class)).to include(Moxml::EntityReference)
|
|
635
635
|
end
|
|
636
636
|
end
|
|
637
|
+
|
|
638
|
+
describe "subtree digest (issue #173, leptris#869)" do
|
|
639
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
640
|
+
|
|
641
|
+
it "answers equal integers for identical subtrees parsed separately" do
|
|
642
|
+
xml = %(<r xmlns:p="urn:p"><a x="1" p:y="2">t</a><b><c/></b></r>)
|
|
643
|
+
d1 = ctx.parse(xml)
|
|
644
|
+
d2 = ctx.parse(xml)
|
|
645
|
+
expect(d1.root.digest).to be_a(Integer)
|
|
646
|
+
expect(d1.root.digest).to eq(d2.root.digest)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
it "answers unequal integers when content differs" do
|
|
650
|
+
d1 = ctx.parse(%(<r><a x="1"/></r>))
|
|
651
|
+
d2 = ctx.parse(%(<r><a x="2"/></r>))
|
|
652
|
+
expect(d1.root.digest).not_to eq(d2.root.digest)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it "skips whitespace-only text with drop_ws_text" do
|
|
656
|
+
spaced = ctx.parse(%(<r>\n <a/>\n</r>))
|
|
657
|
+
tight = ctx.parse(%(<r><a/></r>))
|
|
658
|
+
expect(spaced.root.digest).not_to eq(tight.root.digest)
|
|
659
|
+
expect(spaced.root.digest(drop_ws_text: true))
|
|
660
|
+
.to eq(tight.root.digest(drop_ws_text: true))
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
it "hashes the prefix as well as the resolved namespace" do
|
|
664
|
+
same = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
|
|
665
|
+
mirror = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
|
|
666
|
+
renamed = ctx.parse(%(<r xmlns:q="urn:p"><q:a/></r>))
|
|
667
|
+
other_uri = ctx.parse(%(<r xmlns:p="urn:z"><p:a/></r>))
|
|
668
|
+
base = same.root.digest
|
|
669
|
+
expect(mirror.root.digest).to eq(base)
|
|
670
|
+
# prefix participates (leptris#869: hash(prefix, URI, local))
|
|
671
|
+
expect(renamed.root.digest).not_to eq(base)
|
|
672
|
+
expect(other_uri.root.digest).not_to eq(base)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it "answers nil for nodes without a C handle" do
|
|
676
|
+
doc = ctx.parse(%(<r a="1"><!-- c --><p/><?p instr?></r>))
|
|
677
|
+
expect(doc.digest).to be_nil
|
|
678
|
+
expect(doc.root.attributes.first.digest).to be_nil
|
|
679
|
+
doc.children.select(&:declaration?).each do |decl|
|
|
680
|
+
expect(decl.digest).to be_nil
|
|
681
|
+
end
|
|
682
|
+
# comments and PIs hash in C
|
|
683
|
+
kinds = doc.root.children.map { |n| [n.class, n.digest] }
|
|
684
|
+
comment = kinds.find { |n, _| n == Moxml::Comment }
|
|
685
|
+
pi = kinds.find { |n, _| n == Moxml::ProcessingInstruction }
|
|
686
|
+
expect(comment[1]).to be_a(Integer)
|
|
687
|
+
expect(pi[1]).to be_a(Integer)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
637
690
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moxml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.33
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- LICENSE.md
|
|
39
39
|
- README.adoc
|
|
40
40
|
- Rakefile
|
|
41
|
+
- benchmark/pipeline_bench.rb
|
|
41
42
|
- benchmarks/.gitignore
|
|
42
43
|
- benchmarks/generate_report.rb
|
|
43
44
|
- bin/console
|
|
@@ -395,6 +396,7 @@ files:
|
|
|
395
396
|
- spec/fixtures/xmldsig/sign3-result.xml
|
|
396
397
|
- spec/integration/README.md
|
|
397
398
|
- spec/integration/all_adapters_spec.rb
|
|
399
|
+
- spec/integration/contract_parity_spec.rb
|
|
398
400
|
- spec/integration/headed_ox_integration_spec.rb
|
|
399
401
|
- spec/integration/sax_parity_spec.rb
|
|
400
402
|
- spec/integration/shared_examples/edge_cases.rb
|