moxml 0.5.104 → 0.5.106

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: 3bbf00c0bac03066dd77a5bb5f952ac786a893d51dd0b18365a67500bdad7a15
4
- data.tar.gz: 79d1609f628fcf8b69044ee5a9c990ae347cf0ff11c8b5e2ed2f812bee80ae0e
3
+ metadata.gz: 28a815d79bd0bc2f356a01f0a4f5337e111ab6bce63f7e8c2f7247efac096b4c
4
+ data.tar.gz: b0a75eb438464fe9327fdb0420f14be0e8b19fea4252fe8abc995e295a6c6449
5
5
  SHA512:
6
- metadata.gz: 756a39e9fc200fc4b1a9880f820489684169c3206739d02bb44aad987bfa9b20140a65ae923b29745609fd47407927be9d369f4ed4a64d4129e0f3168f3aec56
7
- data.tar.gz: 6efc68d693d8c3b12f90ee4bfed2bf801162b89c7f3a5b0b1fc21eed45183a1ea962d4f2916a30932314208cd5099245a4977a0020b28892a83f4f019b40e03e
6
+ metadata.gz: 33363ebc463f8e46fbd955ad3adb8f43b01775c27cc72ddaf356e62761e9a7b1917c1c15de36716806a1c306c111368fa0705ec1e37a11fd386f6183c274f291
7
+ data.tar.gz: aef109c340cdcb4f30a76f87ef05e2568091e01c3dcf815fa2c945ff1cc49a961a0c36e1541f32873cca0226af2bd43a019c7cdac0c5f89b195558c2090a621a
@@ -131,11 +131,29 @@ module Moxml
131
131
  def children_accepts_entity_flag?
132
132
  return @children_accepts_entity_flag unless @children_accepts_entity_flag.nil?
133
133
 
134
- kinds = %i[key keyrest keyreq]
134
+ # Opal records parameter data only when compiling with arity
135
+ # checking, which is off by default (opal-rspec turns it on, so
136
+ # the Opal spec run never sees this). Without it
137
+ # Method#parameters returns a bare JS undefined, which answers no
138
+ # method call (not even nil?), so it is matched with `case`,
139
+ # whose Module#=== tolerates it. Without the data the keyword is
140
+ # assumed: every built-in adapter's #children accepts it.
135
141
  @children_accepts_entity_flag =
136
- method(:children).parameters.any? do |kind, _name|
137
- kinds.include?(kind)
138
- end
142
+ children_params_flag(method(:children).parameters)
143
+ end
144
+
145
+ # The decision over a parameters VALUE, so the Opal shape (a
146
+ # bare JS undefined, matched by `case`) is spec-able with real
147
+ # values. Without parameter data the keyword is assumed: every
148
+ # built-in adapter's #children accepts it.
149
+ KEYWORD_PARAM_KINDS = %i[key keyrest keyreq].freeze
150
+
151
+ def children_params_flag(params)
152
+ case params
153
+ when Array
154
+ params.any? { |kind, _| KEYWORD_PARAM_KINDS.include?(kind) }
155
+ else true
156
+ end
139
157
  end
140
158
 
141
159
  # Protocol-level native equality; engines with more than one
@@ -157,6 +175,14 @@ module Moxml
157
175
  node
158
176
  end
159
177
 
178
+ # Cross-document root adoption: adapters whose engine deep-copies
179
+ # a foreign root into the target document natively answer the
180
+ # installed native here (leptris >= 1.9.292, engine #1495);
181
+ # nil keeps the Ruby splice.
182
+ def adopt_root(_doc, _element)
183
+ nil
184
+ end
185
+
160
186
  # Element-only children: adapters whose engine carries the
161
187
  # face override (leptris C filter, nokogiri native); the
162
188
  # default type-filters the native child list so non-element
@@ -1437,6 +1437,23 @@ module Moxml
1437
1437
  end
1438
1438
  end
1439
1439
 
1440
+ NATIVE_ROOT_ADOPT =
1441
+ NATIVE_READ_LAYER &&
1442
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.292.0")
1443
+
1444
+ # The engine deep-copies the foreign subtree into the target
1445
+ # document's pool (leptris#1495) and the binding memoizes the
1446
+ # INSTALLED handle — a fresh pointer distinct from the consumed
1447
+ # source. Answer the installed binding element; callers re-point
1448
+ # wrappers at it.
1449
+ def adopt_root(doc, element)
1450
+ return nil unless NATIVE_ROOT_ADOPT
1451
+
1452
+ binding_doc = to_binding(doc)
1453
+ binding_doc.root = to_binding(element)
1454
+ binding_doc.root
1455
+ end
1456
+
1440
1457
  # Element-only children through the engine-level filter: the
1441
1458
  # C face mints element-kind natives directly (klass-children),
1442
1459
  # so interleaved text/comment nodes cost nothing. Entity
@@ -27,19 +27,28 @@ module Moxml
27
27
  end
28
28
 
29
29
  def value
30
- val = @native.value.to_s
31
- # Same guard as Element#text: entity-free documents skip the
32
- # marker restore scans. The memo rides the owning element's
33
- # (attr natives have no entity probe); a detached attribute
34
- # wrapper falls back to the unconditional restore.
35
- parent = @parent_node
36
- if parent.nil? || parent.entity_bearing?
37
- adapter.restore_entities(val)
38
- else
39
- val
30
+ # Engines allocate a fresh String per read (Nokogiri attr.value);
31
+ # hydration walks re-read the same wrappers, so the value is
32
+ # memoized. A held wrapper recomputes when its owner element's
33
+ # value generation moves (element-side writes bump it locally —
34
+ # a context-wide bump would evict every wrapper per bulk-build
35
+ # write). Parentless wrappers (xpath ResultAttrs) are immutable
36
+ # captures and memo unconditionally.
37
+ if @parent_node
38
+ generation = @parent_node.attribute_value_generation
39
+ @value = nil if @value_gen != generation
40
+ @value_gen = generation
41
+ end
42
+ @value ||= begin
43
+ val = @native.value.to_s
44
+ parent = @parent_node
45
+ if parent.nil? || parent.entity_bearing?
46
+ adapter.restore_entities(val)
47
+ else
48
+ val
49
+ end
40
50
  end
41
51
  end
42
-
43
52
  alias content value
44
53
 
45
54
  # Returns raw native value without entity marker restoration.
@@ -55,6 +64,7 @@ module Moxml
55
64
  else
56
65
  @parent_node&.invalidate_attribute_value_cache!
57
66
  end
67
+ @value = nil
58
68
  adapter.set_attribute_value(@native, new_value)
59
69
  end
60
70
 
@@ -29,6 +29,21 @@ module Moxml
29
29
  (owner.is_a?(Document) && owner.root.equal?(element))
30
30
  if owner.is_a?(Document) && attached &&
31
31
  !owner.equal?(self) && element.context.equal?(context)
32
+ installed = adapter.adopt_root(@native, element.native)
33
+ if installed
34
+ # The engine deep-copies the foreign subtree into this
35
+ # document's pool (leptris >= 1.9.292, engine #1495) and the
36
+ # binding memoizes the INSTALLED handle — a fresh pointer.
37
+ # Re-point the source wrapper so later writes reach the live
38
+ # tree, and recompute its scope-derived memos.
39
+ unless installed.equal?(element.native)
40
+ element.refresh_native!(Moxml::Node.wrap(installed, context).native)
41
+ element.invalidate_namespace_cache!
42
+ end
43
+ context.bump_children_generation
44
+ invalidate_children_cache!
45
+ return
46
+ end
32
47
  # libleptris refuses a root owned by another document (the
33
48
  # engine's set_root ownership check), while Nokogiri adopts.
34
49
  # Cross-document CHILD attaches are supported, so build the
data/lib/moxml/element.rb CHANGED
@@ -189,6 +189,7 @@ module Moxml
189
189
  raise ArgumentError, "children= accepts String, Node, or Array"
190
190
  end
191
191
  adapter.replace_children(@native, natives)
192
+ @text = nil
192
193
  context.bump_children_generation
193
194
  invalidate_children_cache!
194
195
  end
@@ -329,25 +330,48 @@ module Moxml
329
330
  end
330
331
 
331
332
  def text
332
- val = adapter.text_content(@native)
333
- # Entity-free documents (the common case) skip the marker
334
- # restore scans entirely — the per-wrapper entity_bearing?
335
- # memo rides the adapter's serialize generation.
336
- entity_bearing? ? adapter.restore_entities(val) : val
333
+ # Generation-gated memo: engines allocate a fresh String per read
334
+ # (Nokogiri node.text), and hydration walks re-read the same
335
+ # wrappers — Element#text was a top string allocation site in
336
+ # large-document metanorma compiles. Text only changes through
337
+ # this wrapper's writers (text=/children=), all of which clear
338
+ # the memo.
339
+ @text = nil if @text_gen != context.children_generation
340
+ @text_gen = context.children_generation
341
+ @text ||= begin
342
+ val = adapter.text_content(@native)
343
+ # Entity-free documents (the common case) skip the marker
344
+ # restore scans entirely — the per-wrapper entity_bearing?
345
+ # memo rides the adapter's serialize generation.
346
+ entity_bearing? ? adapter.restore_entities(val) : val
347
+ end
337
348
  end
338
349
 
339
350
  alias content text
340
351
 
341
352
  def text=(content)
353
+ @text = nil
354
+ # A text write replaces the whole child list; the generation
355
+ # bump also retires @elements/@children memos across wrappers.
356
+ context.bump_children_generation
342
357
  adapter.set_text_content(@native, normalize_xml_value(content))
343
358
  invalidate_children_cache!
344
359
  end
345
360
 
346
361
  alias content= text=
347
362
 
363
+ # Generation-gated memo (the #321 shape): the native read
364
+ # allocates a fresh String per call and hydration walks re-read
365
+ # the same wrappers — 122k births over 10 passes of 2,000
366
+ # elements before this memo, 5 after. All inner-text writers are
367
+ # children mutations, which bump the children generation.
348
368
  def inner_text
349
- text = raw_inner_text
350
- entity_bearing? ? adapter.restore_entities(text) : text
369
+ @inner_text = nil if @inner_text_gen != context.children_generation
370
+ @inner_text_gen = context.children_generation
371
+ @inner_text ||= begin
372
+ text = raw_inner_text
373
+ entity_bearing? ? adapter.restore_entities(text) : text
374
+ end
351
375
  end
352
376
 
353
377
  # Returns inner text without entity marker restoration.
@@ -363,6 +387,11 @@ module Moxml
363
387
  def inner_xml=(xml)
364
388
  wrapper = "_moxml_inner_#{Process.pid}_#{object_id}"
365
389
  doc = context.parse("<#{wrapper}>#{xml}</#{wrapper}>")
390
+ @text = nil
391
+ # Whole-list replacement rides the children generation like
392
+ # children= does (moxml#310) — text and elements memos across
393
+ # wrappers recompute on next read.
394
+ context.bump_children_generation
366
395
  adapter.replace_children(@native, doc.root.children.map(&:native))
367
396
  invalidate_children_cache!
368
397
  end
@@ -415,13 +444,23 @@ module Moxml
415
444
  # too when the attribute set changes. No context generation
416
445
  # bump — that would evict every wrapper's caches document-wide
417
446
  # on each write of a bulk build.
447
+ # Local counter for held Attribute wrappers: value writes bump it
448
+ # (no context-wide bump — bulk builds would evict every wrapper's
449
+ # caches per write), and Attribute#value recomputes when its
450
+ # owner's counter moves.
451
+ def attribute_value_generation
452
+ @attribute_value_generation ||= 0
453
+ end
454
+
418
455
  def invalidate_attribute_value_cache!
419
456
  @attribute_cache = nil
457
+ @attribute_value_generation = attribute_value_generation + 1
420
458
  end
421
459
 
422
460
  def invalidate_local_attribute_cache!
423
461
  @attributes = nil
424
462
  @attribute_cache = nil
463
+ @attribute_value_generation = attribute_value_generation + 1
425
464
  end
426
465
 
427
466
  # Called by the namespace-scoped attribute paths (xmlns writes
data/lib/moxml/text.rb CHANGED
@@ -5,8 +5,14 @@ module Moxml
5
5
  include Node
6
6
 
7
7
  def content
8
- text = raw_content
9
- entity_bearing? ? adapter.restore_entities(text) : text
8
+ # Engines allocate a fresh String per read (Nokogiri
9
+ # node.text); hydration walks re-read the same text wrappers —
10
+ # this read was the single largest string allocation site in
11
+ # large-document metanorma compiles. Cleared by content=.
12
+ @content ||= begin
13
+ text = raw_content
14
+ entity_bearing? ? adapter.restore_entities(text) : text
15
+ end
10
16
  end
11
17
 
12
18
  # Returns raw content without entity marker restoration.
@@ -15,6 +21,7 @@ module Moxml
15
21
  end
16
22
 
17
23
  def content=(text)
24
+ @content = nil
18
25
  adapter.set_text_content(@native, normalize_xml_value(text))
19
26
  end
20
27
 
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.104"
4
+ VERSION = "0.5.106"
5
5
  end
@@ -46,5 +46,19 @@ RSpec.describe Moxml::Adapter::Base do
46
46
  expect(Moxml::Adapter::Leptris.children_accepts_entity_flag?).to be(true)
47
47
  expect(Moxml::Adapter::Nokogiri.children_accepts_entity_flag?).to be(true)
48
48
  end
49
+
50
+ it "assumes the keyword when Method#parameters returns no data" do
51
+ # Opal without arity checking answers a bare JS undefined for
52
+ # parameters — not even nil?. Exercise the decision through the
53
+ # real values the probe can see.
54
+ probe = described_class.children_params_flag(nil)
55
+ expect(probe).to be(true)
56
+ expect(described_class.children_params_flag([%i[req node]])).to be(false)
57
+ expect(
58
+ described_class.children_params_flag(
59
+ [%i[req node], %i[key entity_bearing]],
60
+ ),
61
+ ).to be(true)
62
+ end
49
63
  end
50
64
  end
@@ -312,6 +312,22 @@ RSpec.describe "Nokogiri-compatible node sugar" do
312
312
  end
313
313
  end
314
314
 
315
+ describe "Document#root= native adoption re-points the source wrapper" do
316
+ it "writes through the original wrapper reach the installed tree" do
317
+ ctx = Moxml.new(:leptris)
318
+ other = ctx.parse('<r xmlns="urn:x" a="1"><p>hi<b>t</b></p></r>')
319
+ doc = ctx.create_document
320
+ src_root = other.root
321
+ doc.root = src_root
322
+ # the engine deep-copies; the wrapper now addresses the installed
323
+ # handle — a later write must serialize into the new document
324
+ src_root["a"] = "2"
325
+ expect(doc.to_xml(indent: 0)).to include('a="2"')
326
+ expect(doc.root["a"]).to eq("2")
327
+ expect(doc.root.namespace_uri.to_s).to eq("urn:x")
328
+ end
329
+ end
330
+
315
331
  describe "Document#root= with a foreign root" do
316
332
  it "adopts the subtree from another document" do
317
333
  doc = Moxml.parse("<old><x/></old>")
@@ -396,4 +412,50 @@ RSpec.describe "Nokogiri-compatible node sugar" do
396
412
  expect(doc.at("//term/definition/p").text).to eq("a")
397
413
  end
398
414
  end
415
+
416
+ describe "wrapper read memos" do
417
+ it "Element#text memoizes and invalidates on text=/children=" do
418
+ doc = Moxml.parse("<r><a>x<b>y</b>z</a></r>")
419
+ a = doc.at("//a")
420
+ first = a.text
421
+ expect(a.text).to equal(first)
422
+ a.text = "new"
423
+ expect(a.text).to eq("new")
424
+ a.children = "<c>w</c>"
425
+ expect(a.text).to eq("w")
426
+ end
427
+
428
+ it "Element#inner_text memoizes and invalidates on child mutations" do
429
+ doc = Moxml.parse("<r><a>x<b>y</b>z</a></r>")
430
+ a = doc.at("//a")
431
+ first = a.inner_text
432
+ expect(a.inner_text).to equal(first)
433
+ a.add_child(doc.create_element("c"))
434
+ expect(a.inner_text).not_to equal(first)
435
+ a.text = "plain"
436
+ expect(a.inner_text).to eq("plain")
437
+ end
438
+
439
+ it "Attribute#value memoizes and invalidates on value=" do
440
+ doc = Moxml.parse(%(<r a="1"/>))
441
+ attr = doc.root.attributes.first
442
+ first = attr.value
443
+ expect(attr.value).to equal(first)
444
+ attr.value = "2"
445
+ expect(attr.value).to eq("2")
446
+ expect(doc.root["a"]).to eq("2")
447
+ end
448
+ end
449
+
450
+ describe "Text#content memo" do
451
+ it "memoizes and invalidates on content=" do
452
+ doc = Moxml.parse("<r>hello</r>")
453
+ t = doc.root.children.first
454
+ first = t.content
455
+ expect(t.content).to equal(first)
456
+ expect(t.text).to equal(first)
457
+ t.content = "bye"
458
+ expect(t.content).to eq("bye")
459
+ end
460
+ end
399
461
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.104
4
+ version: 0.5.106
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API