moxml 0.5.58 → 0.5.60

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: 03fd3940f1334b3f417e4847f35e87497581404372209de4426819a8acc6d363
4
- data.tar.gz: 805f6966495456ff6e36c4f1be3df9fcbbf1c89cfcf2ddc6a4153c37e1bcec71
3
+ metadata.gz: 70fbb27b6a314c772f06699958b27c017a5033c33a62f84ff98d15c3c8a866a0
4
+ data.tar.gz: 4222b8eb5f128d74bc55a3284596f11bc975684eae28ff3f0cb8fe56bb2a4899
5
5
  SHA512:
6
- metadata.gz: 872ba59e6f8b72779d2ea43c8af201052a69be9d75ad1add3fa2618bc58e81cee0503ce352d06c8b46c64cc6340859006b36afeadef4302f29c81e2760285baa
7
- data.tar.gz: 01eed98fc6ac76efb35d2456c9af57487bbdddf13092a09b0667d11e6e2d48a1905865046aab6a59ca8a89523fc4337a705c1b1cf95d98342d56cb4494c0d91e
6
+ metadata.gz: '08307faaef4bfdfbda347f2f860f71f4aedabfc1b30bf81bcdfe876296ee68df6138e42efcf92c04cb94f3696a8ff33b038ce40bf826ad87491a1ee07364e2c3'
7
+ data.tar.gz: 1f084671c8983ed53172652e6322670b09550758b76378b30905bf1c1c30e7cc531d9fdd46edc3ecd7d9bf2ffa6cbd23e79a989a9d0237eae020191265c29641
@@ -358,6 +358,15 @@ namespace_validation_mode: :strict)
358
358
  nil
359
359
  end
360
360
 
361
+ # Plan row stream (Moxml::Plan): yields |name, attrs_pairs
362
+ # (flat [k, v, ...]), first-text, depth| per element in
363
+ # document order (pre-order). Returns true when the adapter
364
+ # provided the stream; nil/false lets the plan run its
365
+ # generic wrapper walk.
366
+ def plan_rows(_native)
367
+ nil
368
+ end
369
+
361
370
  # Deterministic native-memory release for adapters backed by
362
371
  # C trees (issue #134). GC-managed engines no-op; released
363
372
  # documents raise the engine's use-after-free error on
@@ -222,10 +222,30 @@ module Moxml
222
222
  ELEMENT_CONTRACT_READ =
223
223
  ::Moxml::ElementBehavior.instance_method(:[])
224
224
 
225
+ # Marker presence is a DOCUMENT fact, but the wrapper
226
+ # memo re-derives it per node (the per-read machinery
227
+ # was ~a quarter of the consumer walk). Doc-level
228
+ # WeakMap, generation-stamped into one Integer
229
+ # (immediates make safe weak values): one C document
230
+ # read plus one map hit per bare read. Entries die
231
+ # with their documents; a stale generation re-derives.
232
+ ENTITY_DOC_MEMO = ::ObjectSpace::WeakMap.new
233
+
234
+ def doc_entity_bearing?
235
+ doc = NN_DOCUMENT.bind_call(self)
236
+ memo = ENTITY_DOC_MEMO[doc]
237
+ gen = ::Moxml::Adapter::Leptris.serialize_generation
238
+ return memo.allbits?(1) if memo && (memo >> 1) == gen
239
+
240
+ bearing = ::Moxml::Adapter::Leptris.entity_bearing?(self)
241
+ ENTITY_DOC_MEMO[doc] = (gen << 1) | (bearing ? 1 : 0)
242
+ bearing
243
+ end
244
+
225
245
  def [](key)
226
246
  if key.is_a?(String) && !key.include?(":")
227
247
  value = NN_ATTRIBUTE.bind_call(self, key)
228
- return value unless value.is_a?(String) && entity_bearing?
248
+ return value unless value.is_a?(String) && doc_entity_bearing?
229
249
 
230
250
  return ::Moxml::Adapter::Leptris.restore_entities(value)
231
251
  end
@@ -235,7 +255,7 @@ module Moxml
235
255
 
236
256
  def text
237
257
  value = NN_CONTENT.bind_call(self)
238
- value.is_a?(String) && entity_bearing? ? ::Moxml::Adapter::Leptris.restore_entities(value) : value
258
+ value.is_a?(String) && doc_entity_bearing? ? ::Moxml::Adapter::Leptris.restore_entities(value) : value
239
259
  end
240
260
  end
241
261
  ELEMENT.include(Reads)
@@ -473,6 +493,50 @@ module Moxml
473
493
  extend Markers
474
494
  extend Materialize
475
495
 
496
+ # Plan row stream (Moxml::Plan): the engine's one-pass C
497
+ # snapshot is pre-order with the first text child on the row —
498
+ # exactly the plan's shape, with no wrapper minting at all.
499
+ # Marker-bearing documents stay on the generic path (the bulk
500
+ # stream has no marker split).
501
+ NATIVE_PLAN_ROWS =
502
+ NATIVE_READ_LAYER &&
503
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.193.4")
504
+
505
+ def self.plan_rows(native)
506
+ return nil unless NATIVE_READ_LAYER
507
+
508
+ doc = if native.is_a?(::Leptris::XML::Document)
509
+ native
510
+ else
511
+ doc_for(native)
512
+ end
513
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
514
+
515
+ root_binding = if native.is_a?(::Leptris::XML::Document)
516
+ doc.root
517
+ else
518
+ to_binding(native)
519
+ end
520
+ return nil unless root_binding
521
+
522
+ rows = if NATIVE_PLAN_ROWS
523
+ ::Leptris::XML::Native.snapshot_rows(doc,
524
+ root_binding.c_address)
525
+ else
526
+ doc.snapshot(root_binding)
527
+ end
528
+ rows.each do |row|
529
+ if row.is_a?(::Array)
530
+ yield(row[0], row[1], row[2], row[3])
531
+ else
532
+ next unless row[:kind] == "element"
533
+
534
+ yield(row[:name], row[:attrs].flatten, row[:text], row[:depth])
535
+ end
536
+ end
537
+ true
538
+ end
539
+
476
540
  class << self
477
541
  def attachments
478
542
  @attachments ||= Moxml::NativeAttachment.new
data/lib/moxml/node.rb CHANGED
@@ -164,30 +164,18 @@ module Moxml
164
164
  # entity-marker flag flips at parse and entity-reference mint,
165
165
  # both adapter-level, and the generation bump is the invalidation
166
166
  # signal. Adapters with static answers (base class) never bump.
167
+ # The adapter resolves the owning document itself (doc_for is a
168
+ # single C read on the native layer); wrappers no longer climb.
167
169
  def entity_bearing?
168
170
  gen = adapter.serialize_generation
169
171
  if @entity_bearing_gen == gen
170
172
  @entity_bearing
171
173
  else
172
174
  @entity_bearing_gen = gen
173
- @entity_bearing = adapter.entity_bearing?(@native, document_native_for_markers)
175
+ @entity_bearing = adapter.entity_bearing?(@native)
174
176
  end
175
177
  end
176
178
 
177
- # Binding document through the wrapper parent chain — pure Ruby
178
- # attr reads; the native-layer fallback climbs C parents per
179
- # call (the doc_for walk was the largest wrapper-layer cost on
180
- # the consumer pipeline after the native adoption).
181
- def document_native_for_markers
182
- node = self
183
- while node
184
- return node.native if node.document?
185
-
186
- node = node.parent_node
187
- end
188
- nil
189
- end
190
-
191
179
  def xpath(expression, namespaces = {})
192
180
  result = adapter.xpath(@native, expression, namespaces)
193
181
  # Adapter contract: Array<native> | LazyNodeSet | scalar.
data/lib/moxml/plan.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Plan-compiled materialization: declare the document shape once,
5
+ # execute it against the adapter's bulk row stream — no wrapper
6
+ # tree, no NodeSet, no per-node contract dispatch. The compiled
7
+ # grammar instead of the interpreted walk.
8
+ #
9
+ # plan = Moxml::Plan.new do
10
+ # on("record") { |attrs, _text, fields| Record.new(attrs["id"], attrs["kind"], fields) }
11
+ # on("field") { |attrs, text, _kids| Field.new(attrs["name"], attrs["unit"], text) }
12
+ # end
13
+ # records = plan.parse(xml, ctx)
14
+ #
15
+ # Handlers run bottom-up as each element completes; +children+ is
16
+ # the array of handler values for matched child elements whose
17
+ # parent ALSO matched (values of unmatched parents are dropped —
18
+ # matching a parent is the consumer's decision to keep a subtree).
19
+ # +attrs+ is a Hash of the element's attributes (local name =>
20
+ # value); +text+ is the first text child's content, or nil.
21
+ #
22
+ # Name matching is global (flat table, not path-relative): element
23
+ # names unique per role — the dominant consumer shape — work as-is;
24
+ # distinct roles sharing a name need distinct documents or a
25
+ # rename upstream.
26
+ #
27
+ # Adapters with a bulk stream answer plan_rows (leptris: the
28
+ # engine's one-pass C snapshot — pre-order, first-text on the
29
+ # row); the rest run the generic pre-order wrapper walk below,
30
+ # emitting the identical stream — spec-pinned equal.
31
+ class Plan
32
+ def initialize(&block)
33
+ @handlers = {}
34
+ instance_eval(&block) if block
35
+ end
36
+
37
+ # Registers a handler for elements named +name+ (local name).
38
+ # Returns self so registrations chain.
39
+ def on(name, &handler)
40
+ raise ArgumentError, "on(#{name.inspect}) requires a block" unless handler
41
+
42
+ @handlers[name] = handler
43
+ self
44
+ end
45
+
46
+ # Parses +xml+ and materializes the plan against it.
47
+ def parse(xml, context)
48
+ materialize(context.parse(xml))
49
+ end
50
+
51
+ # Materializes the plan against a document (or element) wrapper.
52
+ # Returns the values of top-level matched elements.
53
+ def materialize(node)
54
+ results = []
55
+ frames = [] # per open depth: children values (nil = unmatched)
56
+ meta = [] # per open depth: [name, attrs, text] (nil = unmatched)
57
+
58
+ close_top = lambda do
59
+ m = meta.pop
60
+ children = frames.pop
61
+ next unless m
62
+
63
+ value = @handlers[m[0]].call(m[1], m[2], children)
64
+ if frames.empty? || frames.last.nil?
65
+ results << value
66
+ else
67
+ frames.last << value
68
+ end
69
+ end
70
+
71
+ row = lambda do |name, attrs_pairs, text, depth|
72
+ close_top.call while frames.size > depth
73
+ handler = @handlers[name]
74
+ if handler
75
+ attrs = {}
76
+ i = 0
77
+ while i < attrs_pairs.length
78
+ attrs[attrs_pairs[i]] = attrs_pairs[i + 1]
79
+ i += 2
80
+ end
81
+ frames << []
82
+ meta << [name, attrs, text]
83
+ else
84
+ frames << nil
85
+ meta << nil
86
+ end
87
+ end
88
+
89
+ adapter = node.context.config.adapter
90
+ ran = adapter.plan_rows(node.native) do |name, pairs, text, depth|
91
+ row.call(name, pairs, text, depth)
92
+ end
93
+ unless ran
94
+ walk_wrappers(node) do |name, pairs, text, depth|
95
+ row.call(name, pairs, text, depth)
96
+ end
97
+ end
98
+
99
+ close_top.call while meta.any?
100
+ results
101
+ end
102
+
103
+ private
104
+
105
+ # Generic pre-order walk over the wrapper tree — the fallback
106
+ # stream for adapters without a bulk path. Same protocol as
107
+ # plan_rows: |name, attrs_pairs (flat [k, v, ...]), first-text,
108
+ # depth|.
109
+ def walk_wrappers(node, depth = 0)
110
+ case node
111
+ when Document
112
+ (root = node.root) && walk_wrappers(root, depth) { |*a| yield(*a) }
113
+ when Element
114
+ first_text = nil
115
+ node.children.each do |child|
116
+ first_text ||= child.content if child.is_a?(Text)
117
+ end
118
+ yield(node.name, attribute_pairs(node), first_text, depth)
119
+ node.children.each do |child|
120
+ walk_wrappers(child, depth + 1) { |*a| yield(*a) } if child.is_a?(Element)
121
+ end
122
+ end
123
+ end
124
+
125
+ def attribute_pairs(element)
126
+ pairs = []
127
+ element.attributes.each do |attr|
128
+ pairs << attr.name << attr.value
129
+ end
130
+ pairs
131
+ end
132
+ end
133
+ end
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.58"
4
+ VERSION = "0.5.60"
5
5
  end
data/lib/moxml.rb CHANGED
@@ -93,6 +93,7 @@ module Moxml
93
93
  autoload :XmlUtils, "moxml/xml_utils"
94
94
  autoload :XmlEmitter, "moxml/xml_emitter"
95
95
  autoload :Materializer, "moxml/materializer"
96
+ autoload :Plan, "moxml/plan"
96
97
  autoload :Adapter, "moxml/adapter"
97
98
  autoload :XPath, "moxml/xpath"
98
99
  autoload :SAX, "moxml/sax"
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Moxml::Plan: shape-compiled materialization over the adapter's
6
+ # bulk row stream (leptris C snapshot) and the generic wrapper walk.
7
+ # Both paths must produce identical values.
8
+ RSpec.describe Moxml::Plan do
9
+ PlanRow = Struct.new(:id, :kind, :fields) # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
10
+ PlanCell = Struct.new(:name, :unit, :value) # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
11
+
12
+ let(:xml) do
13
+ <<~XML
14
+ <?xml version="1.0"?>
15
+ <catalog>
16
+ <record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record>
17
+ <record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record>
18
+ </catalog>
19
+ XML
20
+ end
21
+ let(:plan) do
22
+ described_class.new do
23
+ on("record") do |attrs, _text, fields|
24
+ PlanRow.new(attrs["id"], attrs["kind"], fields)
25
+ end
26
+ on("field") do |attrs, text, _kids|
27
+ PlanCell.new(attrs["name"], attrs["unit"], text)
28
+ end
29
+ end
30
+ end
31
+
32
+ shared_examples "plan materialization" do |adapter_name|
33
+ let(:ctx) { Moxml.new(adapter_name) }
34
+
35
+ it "builds the typed shape" do
36
+ rows = plan.parse(xml, ctx)
37
+
38
+ expect(rows.size).to eq(2)
39
+ expect(rows[0].id).to eq("r0")
40
+ expect(rows[0].kind).to eq("k0")
41
+ expect(rows[0].fields.size).to eq(2)
42
+ expect(rows[0].fields[0]).to eq(PlanCell.new("f0", "u0", "v 0.0"))
43
+ expect(rows[0].fields[1]).to eq(PlanCell.new("f1", "u1", "v 0.1"))
44
+ expect(rows[1].fields[0].value).to eq("v 1.0")
45
+ end
46
+
47
+ it "drops values of unmatched parents" do
48
+ orphans = described_class.new do
49
+ on("field") { |attrs, text, _| PlanCell.new(attrs["name"], attrs["unit"], text) }
50
+ end.parse(xml, ctx)
51
+
52
+ # fields match everywhere; no record handler keeps them
53
+ expect(orphans.size).to eq(3)
54
+ end
55
+
56
+ it "returns top-level values for a matched root" do
57
+ roots = described_class.new do
58
+ on("catalog") { |_a, _t, kids| [:catalog, kids] }
59
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
60
+ on("field") { |attrs, text, _| [attrs["name"], text] }
61
+ end.parse(xml, ctx)
62
+
63
+ expect(roots.size).to eq(1)
64
+ expect(roots[0][0]).to eq(:catalog)
65
+ expect(roots[0][1].size).to eq(2)
66
+ end
67
+
68
+ it "handles depth-3 nesting" do
69
+ deep = described_class.new do
70
+ on("catalog") { |_a, _t, records| records }
71
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
72
+ on("field") { |attrs, text, _| [attrs["name"], text] }
73
+ end.parse(xml, ctx)
74
+
75
+ expect(deep.size).to eq(1)
76
+ expect(deep[0]).to eq(
77
+ [["r0", [["f0", "v 0.0"], ["f1", "v 0.1"]]],
78
+ ["r1", [["f0", "v 1.0"]]]],
79
+ )
80
+ end
81
+
82
+ it "is reusable across documents" do
83
+ first = plan.parse(xml, ctx)
84
+ second = plan.parse(xml, ctx)
85
+
86
+ expect(first).to eq(second)
87
+ end
88
+ end
89
+
90
+ describe "bulk path (leptris)" do
91
+ it_behaves_like "plan materialization", :leptris
92
+ end
93
+
94
+ describe "generic path" do
95
+ it_behaves_like "plan materialization", :nokogiri
96
+ end
97
+
98
+ it "requires a handler block" do
99
+ expect { described_class.new.on("x") }
100
+ .to raise_error(ArgumentError, /requires a block/)
101
+ end
102
+ 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.58
4
+ version: 0.5.60
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-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-18 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
@@ -214,6 +214,7 @@ files:
214
214
  - lib/moxml/native_attachment/opal.rb
215
215
  - lib/moxml/node.rb
216
216
  - lib/moxml/node_set.rb
217
+ - lib/moxml/plan.rb
217
218
  - lib/moxml/processing_instruction.rb
218
219
  - lib/moxml/sax.rb
219
220
  - lib/moxml/sax/block_handler.rb
@@ -487,6 +488,7 @@ files:
487
488
  - spec/moxml/opal_oga_smoke_spec.rb
488
489
  - spec/moxml/opal_rexml_adapter_spec.rb
489
490
  - spec/moxml/opal_smoke_spec.rb
491
+ - spec/moxml/plan_spec.rb
490
492
  - spec/moxml/processing_instruction_spec.rb
491
493
  - spec/moxml/readonly_parse_spec.rb
492
494
  - spec/moxml/sax/namespace_splitter_spec.rb