moxml 0.5.32 → 0.5.34
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 +85 -25
- data/lib/moxml/adapter/nokogiri.rb +21 -14
- data/lib/moxml/adapter/oga.rb +14 -0
- data/lib/moxml/adapter/ox.rb +42 -13
- data/lib/moxml/adapter/rexml.rb +9 -1
- data/lib/moxml/attribute.rb +39 -4
- data/lib/moxml/attribute_resolver.rb +12 -8
- data/lib/moxml/c14n/exclusive.rb +1 -1
- data/lib/moxml/context.rb +29 -6
- data/lib/moxml/document.rb +1 -1
- data/lib/moxml/element.rb +59 -10
- 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 +136 -0
- data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +4 -1
- data/spec/moxml/adapter/leptris_spec.rb +53 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d43dcd89303022c12889585b26d38ab3bf199046d869b6412d45c6f3f9249347
|
|
4
|
+
data.tar.gz: e09d8585d90c5a2765040aaf4060bedaececca5228fe7808c11841088415d2e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff23fcc8eaa05cefd83e236a387b707803d8fd06968e9bc84aefd450ff3f2eeca6445fe5a16e3ddd65bcf315b78e140bf27de3f45c30125a34eb6723cee26768
|
|
7
|
+
data.tar.gz: af7d2a75a27d30be26dc4daf750a77661235cf9c203b7e5c0638fde4fe58bed062499f4fa0b1f6648cb6aabc5b45768dfcfa101f095e1037143be67366ad8d94
|
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
|
|
|
@@ -442,8 +456,25 @@ module Moxml
|
|
|
442
456
|
# never applies to a prefixed name.
|
|
443
457
|
element.name = element.name.split(":", 2)[-1] if element.name.include?(":")
|
|
444
458
|
else
|
|
445
|
-
|
|
446
|
-
|
|
459
|
+
# Name is the local part re-prefixed (a qname create leaves
|
|
460
|
+
# "p:c"; re-joining without the split would double —
|
|
461
|
+
# issue #208). Declare only when the prefix is not already
|
|
462
|
+
# in scope: under a parent that binds p, a bare name set
|
|
463
|
+
# is enough (leptris resolves through ancestors). Detached
|
|
464
|
+
# elements still get the declaration so a standalone
|
|
465
|
+
# serialize stays well-formed.
|
|
466
|
+
local = element.name.split(":", 2)[-1]
|
|
467
|
+
element.name = "#{prefix}:#{local}"
|
|
468
|
+
already = resolve_prefix_ns(element, prefix)
|
|
469
|
+
needs_decl = already.nil? || already.href.to_s != uri.to_s
|
|
470
|
+
# Detached elements that will be attached under a declaring
|
|
471
|
+
# parent must not carry their own declaration — that is
|
|
472
|
+
# the create_element(qname) + namespace= + attach order
|
|
473
|
+
# (issue #208). Standalone serialize of a still-detached
|
|
474
|
+
# namespaced element is the add_namespace caller's job.
|
|
475
|
+
if needs_decl && !element.parent.nil?
|
|
476
|
+
element.add_namespace_definition(prefix, uri.to_s)
|
|
477
|
+
end
|
|
447
478
|
end
|
|
448
479
|
element
|
|
449
480
|
end
|
|
@@ -537,6 +568,18 @@ module Moxml
|
|
|
537
568
|
end
|
|
538
569
|
end
|
|
539
570
|
|
|
571
|
+
# Subtree digest over the binding node (issue #173). Only
|
|
572
|
+
# binding Nodes carry a C node handle: the synthetic
|
|
573
|
+
# wrappers (declarations, doctypes, entity markers) and the
|
|
574
|
+
# lightweight Attr triples answer nil.
|
|
575
|
+
def digest(node, drop_ws_text: false)
|
|
576
|
+
return nil unless DIGEST_SUPPORTED
|
|
577
|
+
return nil unless node.is_a?(::Leptris::XML::Node) &&
|
|
578
|
+
!node.is_a?(::Leptris::XML::ResultAttr)
|
|
579
|
+
|
|
580
|
+
node.digest(drop_ws: drop_ws_text)
|
|
581
|
+
end
|
|
582
|
+
|
|
540
583
|
def node_name(node)
|
|
541
584
|
return node.root_name if node.is_a?(::Leptris::XML::DocType)
|
|
542
585
|
return node.target if node.is_a?(CustomizedLeptris::DocumentPI)
|
|
@@ -567,19 +610,10 @@ module Moxml
|
|
|
567
610
|
end
|
|
568
611
|
|
|
569
612
|
def children(node)
|
|
613
|
+
# Frequency-ordered: elements dominate every walk and paid
|
|
614
|
+
# ten failed compares to reach the else arm.
|
|
570
615
|
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
|
|
616
|
+
when ::Leptris::XML::Element
|
|
583
617
|
natives = node.children.to_a
|
|
584
618
|
# Parse records whether the preprocessed source held any
|
|
585
619
|
# entity markers, and the ER builder path flips the flag
|
|
@@ -592,22 +626,43 @@ module Moxml
|
|
|
592
626
|
attachments.get(node.document, :entity_markers) == false
|
|
593
627
|
|
|
594
628
|
split_entity_markers(natives, node)
|
|
629
|
+
when ::Leptris::XML::Document
|
|
630
|
+
assemble_document_children(node)
|
|
631
|
+
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
632
|
+
CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
|
|
633
|
+
CustomizedLeptris::DocumentPI,
|
|
634
|
+
# Terminal node kinds pay an FFI round trip for an empty
|
|
635
|
+
# list; unfiltered recursions visit every text node.
|
|
636
|
+
::Leptris::XML::Text, ::Leptris::XML::Comment,
|
|
637
|
+
::Leptris::XML::CDATA, ::Leptris::XML::ProcessingInstruction,
|
|
638
|
+
::Leptris::XML::Attr
|
|
639
|
+
[]
|
|
640
|
+
else
|
|
641
|
+
node.children.to_a
|
|
595
642
|
end
|
|
596
643
|
end
|
|
597
644
|
|
|
598
645
|
def parent(node)
|
|
646
|
+
# Frequency-ordered: elements dominate navigation and paid
|
|
647
|
+
# three failed compares to reach the else arm.
|
|
599
648
|
case node
|
|
649
|
+
when ::Leptris::XML::Element then root_parent(node)
|
|
600
650
|
when ::Leptris::XML::Document then nil
|
|
601
651
|
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
602
652
|
CustomizedLeptris::DocumentPI then node.parent_doc
|
|
603
653
|
when CustomizedLeptris::TextSegment, CustomizedLeptris::EntityReference then node.parent
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
node.parent || (node.document&.root == node ? node.document : nil)
|
|
654
|
+
# Same body as the Element arm by design (frequency
|
|
655
|
+
# ordering); the generic kinds are cold.
|
|
656
|
+
else root_parent(node) # rubocop:disable Lint/DuplicateBranch
|
|
608
657
|
end
|
|
609
658
|
end
|
|
610
659
|
|
|
660
|
+
# The binding reports the root element as parentless; the
|
|
661
|
+
# moxml contract roots at the document.
|
|
662
|
+
def root_parent(node)
|
|
663
|
+
node.parent || (node.document&.root == node ? node.document : nil)
|
|
664
|
+
end
|
|
665
|
+
|
|
611
666
|
def next_sibling(node)
|
|
612
667
|
node.next_sibling if node.is_a?(::Leptris::XML::Node)
|
|
613
668
|
end
|
|
@@ -766,13 +821,18 @@ module Moxml
|
|
|
766
821
|
end
|
|
767
822
|
|
|
768
823
|
def text_content(node)
|
|
824
|
+
# Frequency-ordered: elements dominate reads; they paid
|
|
825
|
+
# three failed compares to reach the else arm. The
|
|
826
|
+
# duplicated branch bodies are the point.
|
|
769
827
|
case node
|
|
828
|
+
when ::Leptris::XML::Element, ::Leptris::XML::Text
|
|
829
|
+
node.content.to_s
|
|
770
830
|
when ::Leptris::XML::Document then node.root ? node.root.content : ""
|
|
771
831
|
when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
|
|
772
832
|
CustomizedLeptris::EntityReference
|
|
773
833
|
""
|
|
774
834
|
when CustomizedLeptris::TextSegment then node.content
|
|
775
|
-
else node.content.to_s
|
|
835
|
+
else node.content.to_s # rubocop:disable Lint/DuplicateBranch
|
|
776
836
|
end
|
|
777
837
|
end
|
|
778
838
|
|
|
@@ -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?
|
|
@@ -201,6 +200,14 @@ module Moxml
|
|
|
201
200
|
end
|
|
202
201
|
|
|
203
202
|
def set_namespace(element, ns)
|
|
203
|
+
# Strip any existing name prefix before binding: create_element
|
|
204
|
+
# with a qname ("p:c") leaves name="p:c", and Nokogiri's
|
|
205
|
+
# bare namespace= then serializes p: + p:c → p:p:c (issue
|
|
206
|
+
# #208). Local-name-only + namespace= is the correct shape;
|
|
207
|
+
# consumers that pass the qname get the same result.
|
|
208
|
+
if ns && element.respond_to?(:name=) && element.name.to_s.include?(":")
|
|
209
|
+
element.name = element.name.split(":", 2)[-1]
|
|
210
|
+
end
|
|
204
211
|
element.namespace = ns
|
|
205
212
|
element
|
|
206
213
|
end
|
data/lib/moxml/adapter/oga.rb
CHANGED
|
@@ -172,10 +172,19 @@ module Moxml
|
|
|
172
172
|
def set_namespace(element, ns_or_string)
|
|
173
173
|
if ns_or_string.nil?
|
|
174
174
|
element.namespace_name = nil
|
|
175
|
+
# Drop any name prefix so the element is truly unqualified
|
|
176
|
+
if element.respond_to?(:name=) && element.name.to_s.include?(":")
|
|
177
|
+
element.name = element.name.split(":", 2)[-1]
|
|
178
|
+
end
|
|
175
179
|
set_attribute(element, "xmlns", "")
|
|
176
180
|
return element
|
|
177
181
|
end
|
|
178
182
|
|
|
183
|
+
# Local part only before binding — qname create + namespace=
|
|
184
|
+
# would otherwise serialize p:p:c (issue #208).
|
|
185
|
+
if element.respond_to?(:name=) && element.name.to_s.include?(":")
|
|
186
|
+
element.name = element.name.split(":", 2)[-1]
|
|
187
|
+
end
|
|
179
188
|
element.namespace_name = ns_or_string.to_s
|
|
180
189
|
element
|
|
181
190
|
end
|
|
@@ -307,6 +316,11 @@ module Moxml
|
|
|
307
316
|
current
|
|
308
317
|
end
|
|
309
318
|
|
|
319
|
+
def parse_fragment(xml, _context = nil)
|
|
320
|
+
doc = parse("<m>#{xml}</m>").native
|
|
321
|
+
children(root(doc))
|
|
322
|
+
end
|
|
323
|
+
|
|
310
324
|
def root(document)
|
|
311
325
|
document.children.find { |node| node.is_a?(::Oga::XML::Element) }
|
|
312
326
|
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)
|
|
@@ -164,13 +167,16 @@ module Moxml
|
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
prefix = ns.prefix
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
# Local part only — a qname create_element leaves
|
|
171
|
+
# "p:c" and re-joining would double the prefix (#208).
|
|
172
|
+
local = element.name.to_s.split(":", 2)[-1]
|
|
173
|
+
element.name = prefix.nil? || prefix.empty? ? local : "#{prefix}:#{local}"
|
|
174
|
+
# Declare only when the element is already in a tree and the
|
|
175
|
+
# prefix is not already bound — detached create+namespace=
|
|
176
|
+
# + attach under a declaring parent must not re-emit xmlns:p.
|
|
177
|
+
if element.is_a?(::Ox::Element) && element.parent
|
|
178
|
+
set_attribute(element, ns.expanded_prefix, ns.uri)
|
|
171
179
|
end
|
|
172
|
-
element.name = [prefix,
|
|
173
|
-
element.name.delete_prefix("xmlns:")].compact.join(":")
|
|
174
180
|
element
|
|
175
181
|
end
|
|
176
182
|
|
|
@@ -297,9 +303,13 @@ module Moxml
|
|
|
297
303
|
|
|
298
304
|
result = node.nodes || []
|
|
299
305
|
# Ox doesn't set parent references during parsing.
|
|
300
|
-
# Set them here so parent/sibling navigation works.
|
|
306
|
+
# Set them here so parent/sibling navigation works. The
|
|
307
|
+
# != node guard skips the write for already-linked (or
|
|
308
|
+
# stale-parented) children — steady-state access pays one
|
|
309
|
+
# comparison instead of a mutation per child.
|
|
301
310
|
result.each do |child|
|
|
302
|
-
child.parent = node if child.is_a?(::Ox::Element)
|
|
311
|
+
child.parent = node if child.is_a?(::Ox::Element) &&
|
|
312
|
+
child.parent != node
|
|
303
313
|
end
|
|
304
314
|
result
|
|
305
315
|
end
|
|
@@ -330,6 +340,12 @@ module Moxml
|
|
|
330
340
|
current
|
|
331
341
|
end
|
|
332
342
|
|
|
343
|
+
def parse_fragment(xml, _context = nil)
|
|
344
|
+
doc = parse("<m>#{xml}</m>").native
|
|
345
|
+
synthetic = doc.nodes.find { |node| node.is_a?(::Ox::Element) }
|
|
346
|
+
children(synthetic)
|
|
347
|
+
end
|
|
348
|
+
|
|
333
349
|
def root(document)
|
|
334
350
|
document.nodes&.find { |node| node.is_a?(::Ox::Element) }
|
|
335
351
|
end
|
|
@@ -357,7 +373,14 @@ module Moxml
|
|
|
357
373
|
# Ox converts all values to strings
|
|
358
374
|
remove_attribute(element, name)
|
|
359
375
|
else
|
|
360
|
-
|
|
376
|
+
key = name.to_s
|
|
377
|
+
attrs = element.attributes
|
|
378
|
+
# Ox parses attributes under Symbol keys; a naive
|
|
379
|
+
# string-keyed write would ADD a second entry instead
|
|
380
|
+
# of replacing (the parity suite caught reads returning
|
|
381
|
+
# the stale symbol-keyed value after a write).
|
|
382
|
+
attrs.delete(key.to_sym)
|
|
383
|
+
attrs[key] = value
|
|
361
384
|
end
|
|
362
385
|
|
|
363
386
|
::Moxml::Adapter::CustomizedOx::Attribute.new(
|
|
@@ -569,9 +592,11 @@ module Moxml
|
|
|
569
592
|
end
|
|
570
593
|
|
|
571
594
|
def set_text_content(node, content)
|
|
595
|
+
# Copy on the same ownership contract as create_native_text
|
|
596
|
+
content = content.to_s.dup
|
|
572
597
|
case node
|
|
573
|
-
when String then node.replace(content
|
|
574
|
-
when ::Ox::Element then node.replace_text(content
|
|
598
|
+
when String then node.replace(content)
|
|
599
|
+
when ::Ox::Element then node.replace_text(content)
|
|
575
600
|
else
|
|
576
601
|
node.value = content.to_s
|
|
577
602
|
end
|
|
@@ -602,7 +627,11 @@ module Moxml
|
|
|
602
627
|
end
|
|
603
628
|
|
|
604
629
|
def namespace_prefix(namespace)
|
|
605
|
-
namespace
|
|
630
|
+
# Ox spells the default namespace's prefix "xmlns"; the
|
|
631
|
+
# wrapper contract is nil there (parity with the other
|
|
632
|
+
# adapters' in-scope maps).
|
|
633
|
+
prefix = namespace.prefix
|
|
634
|
+
prefix == "xmlns" ? nil : prefix
|
|
606
635
|
end
|
|
607
636
|
|
|
608
637
|
def namespace_uri(namespace)
|
data/lib/moxml/adapter/rexml.rb
CHANGED
|
@@ -258,6 +258,11 @@ module Moxml
|
|
|
258
258
|
node.document
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
def parse_fragment(xml, _context = nil)
|
|
262
|
+
doc = parse("<m>#{xml}</m>").native
|
|
263
|
+
children(root(doc))
|
|
264
|
+
end
|
|
265
|
+
|
|
261
266
|
def root(document)
|
|
262
267
|
document.root
|
|
263
268
|
end
|
|
@@ -509,7 +514,10 @@ module Moxml
|
|
|
509
514
|
# Renames the node in place (works for elements and
|
|
510
515
|
# attributes alike); the caller keeps tracking the same
|
|
511
516
|
# native.
|
|
512
|
-
|
|
517
|
+
# Local part only — a qname create_element leaves "p:c"
|
|
518
|
+
# and prefixing it again would double (issue #208).
|
|
519
|
+
local = element.name.to_s.split(":", 2)[-1]
|
|
520
|
+
element.name = "#{prefix}:#{local}"
|
|
513
521
|
element
|
|
514
522
|
end
|
|
515
523
|
|