moxml 0.5.61 → 0.5.63

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: 432ffb6c040c2b5689552a050b01835b792cde27ddc816981fd186fd83b9df7d
4
- data.tar.gz: 917f7712e1799209f6d1c3f832fb5a3de492da5e570959a9d8bf2a8b4902d537
3
+ metadata.gz: 60493a9fbb6b03a4db31f83e99f269c5d5cf1797f5e0e94f4b6139616905e45e
4
+ data.tar.gz: 4df319d1a67b69f2a08e99cf338131c55584b1e68cb4b46687a23c2f6329263c
5
5
  SHA512:
6
- metadata.gz: b6f0a69c52df888bf9afbc2706e755d9844d8d65247720d887cc6fb34c8d855964f278d798b947bfb028ce58f32253ff6ed92101fcc2cf45fe28695170c23405
7
- data.tar.gz: 90b841bd8eb58119a38dce5333a53f9b37abc70755e69a887c52ab4565d1101727a2dcc40903a4671b92a10d4ba1bd67ab712ac97d37bdd04861359dd4b09bba
6
+ metadata.gz: 6690afe362a4af5781c54271c29daf1af651a6bc34975fd078a9c1d56ec56ac6c5af13a63746a801c206162aa25c7d09314f5bca55db48d356d83586466eda7f
7
+ data.tar.gz: a53a4a73958a40338ed54fbeb6c9098264e5f480b8125888b7471c9d6d11ec9dde455fc35e7de8847c3246fa834e607ab16f8eed5f2f47089547a45ff5b17f8b
@@ -59,3 +59,8 @@ jobs:
59
59
  RUN_PERFORMANCE: "1"
60
60
  RUBY_YJIT_ENABLE: "1"
61
61
  run: bundle exec rake spec:performance
62
+
63
+ - name: Plan parity lock (plan vs raw-nokogiri CPU)
64
+ env:
65
+ LEPTRIS_BENCH_LOCK: "1"
66
+ run: bundle exec rake benchmark:plan
data/Rakefile CHANGED
@@ -253,6 +253,16 @@ namespace :benchmark do
253
253
  sh "ruby", "benchmark/pipeline_bench.rb"
254
254
  end
255
255
 
256
+ # Plan-bench regression lock (#249 follow-up): Moxml::Plan vs the
257
+ # wrapper walk vs raw nokogiri on the consumer shape. With
258
+ # LEPTRIS_BENCH_LOCK=1 (CI) it exits 1 when plan time exceeds
259
+ # 1.5x raw nokogiri — the "raw-nokogiri CPU parity" regression
260
+ # tripwire.
261
+ desc "Plan materialization benchmark with raw-nokogiri parity lock"
262
+ task :plan do
263
+ sh "bundle exec ruby benchmark/plan_bench.rb"
264
+ end
265
+
256
266
  desc "Generate adapter benchmark report"
257
267
  task :report do
258
268
  ruby "benchmarks/generate_report.rb"
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Plan-bench regression lock (leptris follow-up to #249): the plan
4
+ # path must stay at or near raw-nokogiri CPU parity on the consumer
5
+ # shape. Run via `rake benchmark:plan` or with LEPTRIS_BENCH_LOCK=1
6
+ # to enforce the ceiling (CI): plan <= nokogiri * 1.5 — failures
7
+ # mean an adapter or plan change regressed the compiled walk.
8
+ require "moxml"
9
+ require "nokogiri"
10
+
11
+ Field = Struct.new(:name, :unit, :value)
12
+ Record = Struct.new(:id, :kind, :fields)
13
+ Catalog = Struct.new(:records)
14
+
15
+ parts = [%(<?xml version="1.0"?><catalog>)]
16
+ 150.times do |i|
17
+ parts << %(<record id="r#{i}" kind="k#{i % 3}">)
18
+ 6.times { |j| parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>) }
19
+ parts << %(</record>)
20
+ end
21
+ parts << %(</catalog>)
22
+ XML = parts.join
23
+
24
+ CTX = Moxml.new(:leptris)
25
+
26
+ STRUCT_PLAN = Moxml::StructPlan.new do
27
+ element "record", Record, attrs: { "id" => :id, "kind" => :kind }, children: :fields
28
+ element "field", Field, attrs: { "name" => :name, "unit" => :unit }, text: :value
29
+ end
30
+
31
+ PLAN = Moxml::Plan.new do
32
+ on("record") { |attrs, _t, fields| Record.new(attrs["id"], attrs["kind"], fields) }
33
+ on("field") { |attrs, text, _k| Field.new(attrs["name"], attrs["unit"], text) }
34
+ end
35
+
36
+ def plan_run
37
+ records = PLAN.parse(XML, CTX)
38
+ raise "shape" unless records.size == 150 && records[7].fields[3].value == "value 7.3"
39
+
40
+ records
41
+ end
42
+
43
+ def struct_run
44
+ records = STRUCT_PLAN.parse(XML, CTX)
45
+ raise "shape" unless records.size == 150 && records[7].fields[3].value == "value 7.3"
46
+
47
+ records
48
+ end
49
+
50
+ def wrapper_run
51
+ doc = CTX.parse(XML)
52
+ catalog = Catalog.new([])
53
+ doc.root.children.each do |rec|
54
+ next unless rec.is_a?(Moxml::Element)
55
+
56
+ record = Record.new(rec["id"], rec["kind"], [])
57
+ rec.children.each do |f|
58
+ next unless f.is_a?(Moxml::Element)
59
+
60
+ record.fields << Field.new(f["name"], f["unit"], f.text)
61
+ end
62
+ catalog.records << record
63
+ end
64
+ raise "shape" unless catalog.records.size == 150
65
+
66
+ catalog
67
+ end
68
+
69
+ def nokogiri_run
70
+ doc = Nokogiri::XML(XML)
71
+ catalog = Catalog.new([])
72
+ doc.root.children.each do |rec|
73
+ next unless rec.is_a?(Nokogiri::XML::Element)
74
+
75
+ record = Record.new(rec["id"], rec["kind"], [])
76
+ rec.children.each do |f|
77
+ next unless f.is_a?(Nokogiri::XML::Element)
78
+
79
+ record.fields << Field.new(f["name"], f["unit"], f.text)
80
+ end
81
+ catalog.records << record
82
+ end
83
+ raise "shape" unless catalog.records.size == 150
84
+
85
+ catalog
86
+ end
87
+
88
+ REPS = 200
89
+
90
+ def bench(&block)
91
+ times = []
92
+ allocs = []
93
+ REPS.times do
94
+ GC.start
95
+ a0 = GC.stat(:total_allocated_objects)
96
+ t0 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
97
+ yield
98
+ times << (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - t0)
99
+ allocs << (GC.stat(:total_allocated_objects) - a0)
100
+ end
101
+ [times.sort[times.size / 2] * 1e6, allocs.min]
102
+ end
103
+
104
+ # The parity lock needs the lean plan rows (binding 1.9.194.2+);
105
+ # on older bindings the plan runs the generic fallback and the
106
+ # ceiling is meaningless — report and exit clean.
107
+ if Gem::Version.new(Leptris::VERSION) < Gem::Version.new("1.9.194.2")
108
+ puts "SKIP parity lock: leptris #{Leptris::VERSION} lacks snapshot_rows (needs 1.9.194.2)"
109
+ exit 0
110
+ end
111
+
112
+ # warm
113
+ plan_run
114
+ struct_run
115
+ wrapper_run
116
+ nokogiri_run
117
+
118
+ struct_us = nil
119
+ struct_allocs = nil
120
+ if Gem::Version.new(Leptris::VERSION) >= Gem::Version.new("1.9.197.1")
121
+ struct_us, struct_allocs = bench { struct_run }
122
+ end
123
+ plan_us, plan_allocs = bench { plan_run }
124
+ wrap_us, wrap_allocs = bench { wrapper_run }
125
+ nk_us, nk_allocs = bench { nokogiri_run }
126
+
127
+ if struct_us
128
+ puts format("struct (parse+materialize) %<t>8.0f us %<a>7d allocs",
129
+ t: struct_us, a: struct_allocs)
130
+ end
131
+ puts format("plan (parse+materialize) %<t>8.0f us %<a>7d allocs",
132
+ t: plan_us, a: plan_allocs)
133
+ puts format("wrapper (parse+walk) %<t>8.0f us %<a>7d allocs",
134
+ t: wrap_us, a: wrap_allocs)
135
+ puts format("nokogiri (parse+walk) %<t>8.0f us %<a>7d allocs",
136
+ t: nk_us, a: nk_allocs)
137
+ puts format("plan/nokogiri %<r>.2fx (ceiling 1.50x for the CI lock)",
138
+ r: plan_us / nk_us)
139
+
140
+ exit(1) if ENV["LEPTRIS_BENCH_LOCK"] && plan_us > nk_us * 1.5
@@ -358,15 +358,29 @@ 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.
361
+ # Plan row stream (Moxml::Plan) — ADAPTER CONTRACT. Yields
362
+ # |name, attrs_pairs (flat [k, v, ...]), first-text, depth|
363
+ # per element in document order (pre-order); first-text is
364
+ # the element's first text child or nil. Adapters implement
365
+ # this natively off their engine nodes (nokogiri, ox, oga,
366
+ # rexml, libxml, leptris do) — Moxml::Plan executes entirely
367
+ # on these rows with no wrapper materialization. This default
368
+ # returns nil, which makes Moxml::Plan fall back to the
369
+ # generic wrapper walk — correct but slow; last resort only.
366
370
  def plan_rows(_native)
367
371
  nil
368
372
  end
369
373
 
374
+ # Struct-plan executor (Moxml::StructPlan): +spec+ compiles
375
+ # the consumer's shape as {name => [Struct class, attrs
376
+ # Hash (name => slot Symbol), text slot, children slot]}.
377
+ # Returns the array of top-level minted structs, or nil when
378
+ # the adapter has no C executor (the StructPlan then runs
379
+ # its Moxml::Plan fallback over plan_rows).
380
+ def plan_structs(_native, _spec)
381
+ nil
382
+ end
383
+
370
384
  # Deterministic native-memory release for adapters backed by
371
385
  # C trees (issue #134). GC-managed engines no-op; released
372
386
  # documents raise the engine's use-after-free error on
@@ -537,6 +537,32 @@ module Moxml
537
537
  NATIVE_READ_LAYER &&
538
538
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.193.4")
539
539
 
540
+ # leptris-ruby#266 (binding 1.9.197.1): Native.plan_structs —
541
+ # the C struct executor behind Moxml::StructPlan.
542
+ NATIVE_PLAN_STRUCTS =
543
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.197.1")
544
+
545
+ def self.plan_structs(native, spec)
546
+ return nil unless NATIVE_PLAN_STRUCTS
547
+
548
+ doc = if native.is_a?(::Leptris::XML::Document)
549
+ native
550
+ else
551
+ doc_for(native)
552
+ end
553
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
554
+
555
+ root_binding = if native.is_a?(::Leptris::XML::Document)
556
+ doc.root
557
+ else
558
+ to_binding(native)
559
+ end
560
+ return nil unless root_binding
561
+
562
+ ::Leptris::XML::Native.plan_structs(doc, root_binding.c_address,
563
+ spec)
564
+ end
565
+
540
566
  def self.plan_rows(native)
541
567
  return nil unless NATIVE_READ_LAYER
542
568
 
@@ -1312,6 +1312,38 @@ module Moxml
1312
1312
  dst[attr_name] = attr.value
1313
1313
  end
1314
1314
  end
1315
+
1316
+ # Plan row stream (Moxml::Plan contract): pre-order element
1317
+ # rows straight off the LibXML nodes — no wrapper minting.
1318
+ def plan_rows(native)
1319
+ root = if native.is_a?(::LibXML::XML::Document)
1320
+ r = native.root
1321
+ return nil unless r
1322
+
1323
+ r
1324
+ else
1325
+ native
1326
+ end
1327
+
1328
+ walk = lambda do |el, depth|
1329
+ attrs = []
1330
+ el.attributes.each do |attr|
1331
+ attrs << attr.name << attr.value
1332
+ end
1333
+ first_text = nil
1334
+ el.children.each do |c|
1335
+ first_text ||= c.content if c.text?
1336
+ end
1337
+ yield(el.name, attrs, first_text, depth)
1338
+ el.children.each do |c|
1339
+ walk.call(c, depth + 1) if c.element?
1340
+ end
1341
+ end
1342
+ walk.call(root, 0)
1343
+ true
1344
+ end
1345
+
1346
+ public :plan_rows
1315
1347
  end
1316
1348
 
1317
1349
  # Bridge between LibXML SAX and Moxml SAX
@@ -516,6 +516,25 @@ module Moxml
516
516
  attachments.set(native_doc, :xml_decl, nil)
517
517
  end
518
518
 
519
+ # Plan row stream (Moxml::Plan contract): pre-order element
520
+ # rows |name, attrs_pairs, first-text, depth| straight off
521
+ # the libxml2-backed nodes — no wrapper minting.
522
+ def plan_rows(native)
523
+ root = native.is_a?(::Nokogiri::XML::Document) ? native.root : native
524
+ return nil unless root
525
+
526
+ walk = lambda do |el, depth|
527
+ attrs = []
528
+ el.attribute_nodes.each { |a| attrs << a.name << a.value }
529
+ first_text = nil
530
+ el.children.each { |c| first_text ||= c.content if c.text? }
531
+ yield(el.name, attrs, first_text, depth)
532
+ el.element_children.each { |c| walk.call(c, depth + 1) }
533
+ end
534
+ walk.call(root, 0)
535
+ true
536
+ end
537
+
519
538
  private
520
539
 
521
540
  def build_declaration_attrs(version, encoding, standalone)
@@ -533,6 +552,10 @@ module Moxml
533
552
  hsh.merge(attr_name => value)
534
553
  end
535
554
  end
555
+
556
+ # Plan row stream (Moxml::Plan contract): pre-order element
557
+ # rows |name, attrs_pairs, first-text, depth| straight off
558
+ # the libxml2-backed nodes — no wrapper minting.
536
559
  end
537
560
 
538
561
  # Bridge between Nokogiri SAX and Moxml SAX
@@ -643,6 +643,34 @@ module Moxml
643
643
 
644
644
  ::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml
645
645
  end
646
+
647
+ # Plan row stream (Moxml::Plan contract): pre-order element
648
+ # rows over Oga's Ruby node objects.
649
+ def plan_rows(native)
650
+ root = if native.is_a?(::Oga::XML::Document)
651
+ native.children.find { |c| c.is_a?(::Oga::XML::Element) }
652
+ else
653
+ native
654
+ end
655
+ return nil unless root.is_a?(::Oga::XML::Element)
656
+
657
+ walk = lambda do |el, depth|
658
+ attrs = []
659
+ el.attributes.each { |a| attrs << a.name << a.value }
660
+ first_text = nil
661
+ el.children.each do |c|
662
+ first_text ||= c.text if c.is_a?(::Oga::XML::Text)
663
+ end
664
+ yield(el.name, attrs, first_text, depth)
665
+ el.children.each do |c|
666
+ walk.call(c, depth + 1) if c.is_a?(::Oga::XML::Element)
667
+ end
668
+ end
669
+ walk.call(root, 0)
670
+ true
671
+ end
672
+
673
+ public :plan_rows
646
674
  end
647
675
 
648
676
  # Bridge between a parsed Oga DOM and Moxml SAX events.
@@ -955,6 +955,35 @@ module Moxml
955
955
  output << "</#{elem.name}>"
956
956
  output
957
957
  end
958
+
959
+ # Plan row stream (Moxml::Plan contract): pre-order element
960
+ # rows over Ox's Ruby node objects.
961
+ def plan_rows(native)
962
+ root = if native.is_a?(::Ox::Document)
963
+ nodes = native.nodes.grep(::Ox::Element)
964
+ nodes.first
965
+ else
966
+ native
967
+ end
968
+ return nil unless root.is_a?(::Ox::Element)
969
+
970
+ walk = lambda do |el, depth|
971
+ attrs = []
972
+ (el.attributes || {}).each { |k, v| attrs << k.to_s << v.to_s }
973
+ first_text = nil
974
+ (el.nodes || []).each do |c|
975
+ first_text ||= c if c.is_a?(::String)
976
+ end
977
+ yield(el.name.to_s, attrs, first_text, depth)
978
+ (el.nodes || []).each do |c|
979
+ walk.call(c, depth + 1) if c.is_a?(::Ox::Element)
980
+ end
981
+ end
982
+ walk.call(root, 0)
983
+ true
984
+ end
985
+
986
+ public :plan_rows
958
987
  end
959
988
  end
960
989
 
@@ -694,6 +694,24 @@ module Moxml
694
694
  )
695
695
  formatter.write(node, output)
696
696
  end
697
+
698
+ # Plan row stream (Moxml::Plan contract): pre-order element
699
+ # rows over REXML's Ruby node objects.
700
+ def plan_rows(native)
701
+ root = native.is_a?(::REXML::Document) ? native.root : native
702
+ return nil unless root
703
+
704
+ walk = lambda do |el, depth|
705
+ attrs = []
706
+ el.attributes.each { |k, v| attrs << k << v }
707
+ yield(el.name, attrs, el.text, depth)
708
+ el.elements.each { |c| walk.call(c, depth + 1) }
709
+ end
710
+ walk.call(root, 0)
711
+ true
712
+ end
713
+
714
+ public :plan_rows
697
715
  end
698
716
  end
699
717
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Struct-compiled materialization — the fully compiled grammar:
5
+ # declare the document shape once as STRUCTS; adapters with a C
6
+ # executor (leptris 1.9.197.1+) mint the typed objects directly
7
+ # with zero Ruby frames per element; everything else falls back to
8
+ # an equivalent Moxml::Plan over the row stream (spec-pinned
9
+ # equal).
10
+ #
11
+ # plan = Moxml::StructPlan.new do
12
+ # element "record", Record, attrs: { "id" => :id, "kind" => :kind },
13
+ # children: :fields
14
+ # element "field", Field, attrs: { "name" => :name, "unit" => :unit },
15
+ # text: :value
16
+ # end
17
+ # records = plan.parse(xml, ctx) # Array of Record
18
+ #
19
+ # Slot symbols must be the Struct's member names. Semantics equal
20
+ # Moxml::Plan: unmatched elements are barriers — their matched
21
+ # descendants surface at top level.
22
+ class StructPlan
23
+ Element = Struct.new(:klass, :attrs, :text, :children)
24
+
25
+ def initialize(&block)
26
+ @elements = {}
27
+ instance_eval(&block) if block
28
+ end
29
+
30
+ # Registers a struct element. +attrs+ maps attribute names to
31
+ # member slots; +text+ and +children+ name the member slots
32
+ # receiving the first text child and the matched children array.
33
+ # Returns self so registrations chain.
34
+ def element(name, klass, attrs: {}, text: nil, children: nil)
35
+ @elements[name] = Element.new(klass, attrs, text, children)
36
+ self
37
+ end
38
+
39
+ # The compiled spec the C executor consumes:
40
+ # {name => [klass, attrs, text slot, children slot]}.
41
+ def compile
42
+ @elements.each_with_object({}) do |(name, el), spec|
43
+ spec[name] = [el.klass, el.attrs, el.text, el.children]
44
+ end
45
+ end
46
+
47
+ def parse(xml, context)
48
+ materialize(context.parse(xml))
49
+ end
50
+
51
+ def materialize(node)
52
+ adapter = node.context.config.adapter
53
+ roots = adapter.plan_structs(node.native, compile)
54
+ return roots if roots
55
+
56
+ plan.materialize(node)
57
+ end
58
+
59
+ private
60
+
61
+ # The fallback: the same element table as block handlers over
62
+ # the plan row stream. instance_eval'd DSL — capture the table
63
+ # in a local (the Plan's self has no @elements).
64
+ def plan
65
+ @plan ||= begin
66
+ elements = @elements
67
+ Moxml::Plan.new do
68
+ elements.each do |name, el|
69
+ on(name) do |attrs, text, children|
70
+ obj = el.klass.new
71
+ el.attrs.each { |aname, slot| obj[slot] = attrs[aname] }
72
+ obj[el.text] = text if el.text
73
+ obj[el.children] = children if el.children
74
+ obj
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ 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.61"
4
+ VERSION = "0.5.63"
5
5
  end
data/lib/moxml.rb CHANGED
@@ -94,6 +94,7 @@ module Moxml
94
94
  autoload :XmlEmitter, "moxml/xml_emitter"
95
95
  autoload :Materializer, "moxml/materializer"
96
96
  autoload :Plan, "moxml/plan"
97
+ autoload :StructPlan, "moxml/struct_plan"
97
98
  autoload :Adapter, "moxml/adapter"
98
99
  autoload :XPath, "moxml/xpath"
99
100
  autoload :SAX, "moxml/sax"
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Moxml::Plan adapter-contract conformance (leptris follow-up to
6
+ # #249): every adapter must produce IDENTICAL plan results on the
7
+ # same document, whether its plan_rows is a bulk C stream (leptris,
8
+ # nokogiri, libxml) or a Ruby-native walk (ox, oga, rexml).
9
+ PlanRow = Struct.new(:id, :kind, :fields)
10
+ PlanCell = Struct.new(:name, :unit, :value)
11
+
12
+ RSpec.describe "Moxml::Plan adapter conformance" do
13
+ # Single-line fixture: no inter-element whitespace text, so
14
+ # engines that differ on insignificant text still agree.
15
+ let(:xml) do
16
+ '<catalog><record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record><record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record></catalog>'
17
+ end
18
+
19
+ let(:plan) do
20
+ Moxml::Plan.new do
21
+ on("record") do |attrs, _text, fields|
22
+ PlanRow.new(attrs["id"], attrs["kind"], fields)
23
+ end
24
+ on("field") do |attrs, text, _kids|
25
+ PlanCell.new(attrs["name"], attrs["unit"], text)
26
+ end
27
+ end
28
+ end
29
+
30
+ ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
31
+
32
+ it "executes on every available adapter" do
33
+ available = ADAPTERS.select do |a|
34
+ ctx = Moxml.new(a)
35
+ ctx.parse(xml)
36
+ true
37
+ rescue StandardError
38
+ false
39
+ end
40
+ expect(available).not_to be_empty
41
+
42
+ results = available.to_h { |a| [a, plan.parse(xml, Moxml.new(a))] }
43
+
44
+ baseline = results[available.first]
45
+ results.each do |adapter, rows|
46
+ expect(rows).to eq(baseline), "adapter #{adapter} diverged"
47
+ end
48
+
49
+ expect(baseline.size).to eq(2)
50
+ expect(baseline[0].fields.map(&:value)).to eq(["v 0.0", "v 0.1"])
51
+ end
52
+
53
+ it "skips no element and preserves order" do
54
+ rows = plan.parse(xml, Moxml.new(:nokogiri))
55
+
56
+ expect(rows.map(&:id)).to eq(%w[r0 r1])
57
+ end
58
+
59
+ it "handles depth-3 nesting identically" do
60
+ deep = Moxml::Plan.new do
61
+ on("catalog") { |_a, _t, records| records }
62
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
63
+ on("field") { |attrs, text, _| [attrs["name"], text] }
64
+ end
65
+
66
+ ADAPTERS.each do |a|
67
+ begin
68
+ ctx = Moxml.new(a)
69
+ ctx.parse(xml)
70
+ rescue StandardError
71
+ next
72
+ end
73
+ out = deep.parse(xml, ctx)
74
+ expect(out[0]).to eq(
75
+ [["r0", [["f0", "v 0.0"], ["f1", "v 0.1"]]],
76
+ ["r1", [["f0", "v 1.0"]]]],
77
+ )
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Moxml::StructPlan: the fully compiled grammar — struct slots
6
+ # declared once, minted in C on leptris (1.9.197.1+) and via the
7
+ # plan fallback everywhere else. Results must be identical to
8
+ # Moxml::Plan.
9
+ SRecord = Struct.new(:id, :kind, :fields)
10
+ SField = Struct.new(:name, :unit, :value)
11
+
12
+ RSpec.describe Moxml::StructPlan do
13
+ let(:xml) do
14
+ '<catalog><record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record><record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record></catalog>'
15
+ end
16
+ let(:plan) do
17
+ described_class.new do
18
+ element "record", SRecord,
19
+ attrs: { "id" => :id, "kind" => :kind }, children: :fields
20
+ element "field", SField,
21
+ attrs: { "name" => :name, "unit" => :unit }, text: :value
22
+ end
23
+ end
24
+ let(:block_plan) do
25
+ Moxml::Plan.new do
26
+ on("record") { |attrs, _t, fields| SRecord.new(attrs["id"], attrs["kind"], fields) }
27
+ on("field") { |attrs, text, _k| SField.new(attrs["name"], attrs["unit"], text) }
28
+ end
29
+ end
30
+
31
+ ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
32
+
33
+ def with_adapter(name)
34
+ yield Moxml.new(name)
35
+ rescue StandardError
36
+ skip "#{name} unavailable"
37
+ end
38
+
39
+ ADAPTERS.each do |adapter_name|
40
+ describe "on #{adapter_name}" do
41
+ it "materializes the same structs as Moxml::Plan" do
42
+ with_adapter(adapter_name) do |ctx|
43
+ expect(plan.parse(xml, ctx))
44
+ .to eq(block_plan.parse(xml, ctx))
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ it "treats unmatched elements as barriers like Moxml::Plan" do
51
+ barrier_xml = "<catalog><wrap><record id=\"r9\"/></wrap><record id=\"r8\"/></catalog>"
52
+ with_adapter(:leptris) do |ctx|
53
+ rows = plan.parse(barrier_xml, ctx)
54
+ expect(rows.map(&:id)).to eq(%w[r9 r8])
55
+ end
56
+ end
57
+
58
+ it "runs the C executor on leptris (not the fallback)" do
59
+ with_adapter(:leptris) do |ctx|
60
+ doc = ctx.parse(xml)
61
+ roots = Moxml::Adapter::Leptris.plan_structs(
62
+ doc.native,
63
+ "record" => [SRecord, { "id" => :id, "kind" => :kind }, nil, :fields],
64
+ "field" => [SField, { "name" => :name, "unit" => :unit }, :value, nil],
65
+ )
66
+ expect(roots).to eq(plan.parse(xml, ctx))
67
+ end
68
+ end
69
+ 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.61
4
+ version: 0.5.63
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -39,6 +39,7 @@ files:
39
39
  - README.adoc
40
40
  - Rakefile
41
41
  - benchmark/pipeline_bench.rb
42
+ - benchmark/plan_bench.rb
42
43
  - benchmarks/.gitignore
43
44
  - benchmarks/generate_report.rb
44
45
  - bin/console
@@ -270,6 +271,7 @@ files:
270
271
  - lib/moxml/signature/transform_pipeline.rb
271
272
  - lib/moxml/signature/verification_result.rb
272
273
  - lib/moxml/signature/verifier.rb
274
+ - lib/moxml/struct_plan.rb
273
275
  - lib/moxml/text.rb
274
276
  - lib/moxml/version.rb
275
277
  - lib/moxml/wrappers.rb
@@ -488,6 +490,7 @@ files:
488
490
  - spec/moxml/opal_oga_smoke_spec.rb
489
491
  - spec/moxml/opal_rexml_adapter_spec.rb
490
492
  - spec/moxml/opal_smoke_spec.rb
493
+ - spec/moxml/plan_conformance_spec.rb
491
494
  - spec/moxml/plan_spec.rb
492
495
  - spec/moxml/processing_instruction_spec.rb
493
496
  - spec/moxml/readonly_parse_spec.rb
@@ -508,6 +511,7 @@ files:
508
511
  - spec/moxml/signature/key_extractor_spec.rb
509
512
  - spec/moxml/signature/model_spec.rb
510
513
  - spec/moxml/signature/round_trip_spec.rb
514
+ - spec/moxml/struct_plan_spec.rb
511
515
  - spec/moxml/text_spec.rb
512
516
  - spec/moxml/version_spec.rb
513
517
  - spec/moxml/xml_emitter_spec.rb