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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 627111c496efca4687eabeeb78989d611d3d308adc5ed32443c7058da51c57c9
4
- data.tar.gz: 86d1cc6c471d07084e0d2e0de9412b286b5486b202f9fe677b7417f514c1fe55
3
+ metadata.gz: d43dcd89303022c12889585b26d38ab3bf199046d869b6412d45c6f3f9249347
4
+ data.tar.gz: e09d8585d90c5a2765040aaf4060bedaececca5228fe7808c11841088415d2e0
5
5
  SHA512:
6
- metadata.gz: f1108c98dc8af92913605dd08b84eb50bbfec92a5dd286e15656c3ecd57937ce56bc186c9393a740f6bd8a8316f06fb059db226f20ffc9f0b84cfb433e1a532d
7
- data.tar.gz: 02d109a32659b30fc34ac5d6d3f01bc0d32d9e1ca6a38cfdc2fe3880a02c779ce5ff41fa2f0eda04adc2a9ccd6702f372d29b4d2a6fe33faeb0456cb8852d26b
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
@@ -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
- # Ox freezes the content, so we need to dup it
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
- value = element[name.to_s]
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
- element.add_namespace_definition(prefix, uri.to_s)
446
- element.name = "#{prefix}:#{element.name.split(':', 2)[-1]}"
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::Document
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
- else
605
- # The binding reports the root element as parentless; the
606
- # moxml contract roots at the document.
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
- def doc_entity_markers?(doc)
33
- cache = (@doc_markers ||= ObjectSpace::WeakMap.new)
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
- value = element[name.to_s]
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
@@ -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
@@ -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
- # attributes don't have attributes but can have a namespace prefix
168
- if element.is_a?(::Ox::Element)
169
- set_attribute(element, ns.expanded_prefix,
170
- ns.uri)
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
- element.attributes[name.to_s] = value
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.to_s)
574
- when ::Ox::Element then node.replace_text(content.to_s)
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.prefix
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)
@@ -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
- element.name = "#{prefix}:#{element.name}"
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