moxml 0.5.80 → 0.5.82

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: 656e065f07ecc3aea2624682345254907cab3bee557d14a74e7a9a1e6d3e53f2
4
- data.tar.gz: d3606b240493ed82eb131bb8dd37e7d011bcd4d7d12d8907407c5408fcecb970
3
+ metadata.gz: 2945a0f85059a2979b59e3623e208fcc7bbd0195b2e00b581502adb5fa9f76ac
4
+ data.tar.gz: a5ca2152292ba794be5bc6edd766e36bf769b86fa63ce235a396645245b38bf7
5
5
  SHA512:
6
- metadata.gz: d956c3ff27cae1eedce7a4cc9cb53f57a7d12a58999b74ddebb7bc4a3c99661e2cea010a5a2cc9682315ed5d4bfe06b4122453127d6682ecb96fcfd4401202e2
7
- data.tar.gz: 478679f5fc817ba910aa0adc927ca0e22009ec1cc787b1388cef9ed3ff570da51c83eaa5e5a99f9b4b9c21e2d6d0d8dbed280925035bc00b580ba242776e7766
6
+ metadata.gz: 9c57a2679fa6b1230ba4965f868c40de1eb98a8a4dac5dfe52a24a81f131d4ebb566707150b6d04e1f8b25d7e08bd284eae75d848b4488a0eed9c999b25662d5
7
+ data.tar.gz: 76107d50a3f3bfa76d2926931cbc54d49aa7e7e14d68f0aa932fb93513597d254f5063daf0341780bf9a2783472a884444b884fe50fa16e2248f08e0dcc68978
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gemspec
9
9
  gem "benchmark"
10
10
  gem "benchmark-ips"
11
11
  gem "get_process_mem"
12
- gem "leptris", "~> 1.9.228"
12
+ gem "leptris", "~> 1.9.238"
13
13
  gem "libxml-ruby", ">= 5.0"
14
14
  gem "nokogiri", "~> 1.18"
15
15
  gem "openssl", "~> 3.0"
data/Rakefile CHANGED
@@ -263,6 +263,14 @@ namespace :benchmark do
263
263
  sh "bundle exec ruby benchmark/plan_bench.rb"
264
264
  end
265
265
 
266
+ # SAX bench row (leptris#1298 / #279): the bulk record drain vs
267
+ # the callback Recorder. With LEPTRIS_BENCH_LOCK=1 (CI) it exits 1
268
+ # unless records-walk allocations stay under half the recorder's.
269
+ desc "SAX parse benchmark: record drain vs callback recorder"
270
+ task :sax do
271
+ sh "bundle exec ruby benchmark/sax_bench.rb"
272
+ end
273
+
266
274
  desc "Generate adapter benchmark report"
267
275
  task :report do
268
276
  ruby "benchmarks/generate_report.rb"
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SAX bench row (leptris#1298 follow-up / moxml#279 item 4): the
4
+ # bulk record drain vs the callback Recorder on the canon parse-lane
5
+ # shape. Run via `rake benchmark:sax`; with LEPTRIS_BENCH_LOCK=1
6
+ # (CI) it exits 1 unless the drain stays materially cheaper than the
7
+ # recorder — records-walk allocations must land under half the
8
+ # recorder's, so the win stays visible and a regression re-opens
9
+ # the lane.
10
+ require "moxml"
11
+
12
+ item = "<item n=\"1\" kind=\"k\" id=\"i1\">value text</item>"
13
+ DOC = "<r>#{item * 225}<t>tail</t></r>".freeze # rubocop:disable Lint/ConstantDefinitionInBlock
14
+ DOCS = [DOC, DOC].freeze # rubocop:disable Lint/ConstantDefinitionInBlock
15
+
16
+ module SaxBenchHandlers
17
+ class NullHandler < Moxml::SAX::Handler
18
+ def on_start_element(_name, _attrs = {}, _namespaces = {}); end
19
+
20
+ def on_end_element(_name); end
21
+
22
+ def on_characters(_text); end
23
+ end
24
+
25
+ class RecordWalkHandler < Moxml::SAX::Handler
26
+ attr_reader :elements
27
+
28
+ def initialize
29
+ super
30
+ @elements = 0
31
+ end
32
+
33
+ # The hot-loop shape: walk the records, materialize one name per
34
+ # element (the #1298 target — consumer-chosen allocations).
35
+ def on_sax_records(table)
36
+ i = 0
37
+ while i < table.count
38
+ @elements += 1 if table.kind(i).zero? && table.view(i)
39
+ i += 1
40
+ end
41
+ :claimed
42
+ end
43
+ end
44
+ end
45
+
46
+ CTX = Moxml.new(:leptris).freeze # rubocop:disable Lint/ConstantDefinitionInBlock
47
+ BRIDGE = Moxml::Adapter::Leptris::LeptrisSAXBridge # rubocop:disable Lint/ConstantDefinitionInBlock
48
+
49
+ def allocations
50
+ GC.start
51
+ before = GC.stat(:total_allocated_objects)
52
+ GC.disable
53
+ yield
54
+ GC.enable
55
+ GC.stat(:total_allocated_objects) - before
56
+ end
57
+
58
+ def cpu_micros
59
+ GC.start
60
+ t0 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :microsecond)
61
+ yield
62
+ Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :microsecond) - t0
63
+ end
64
+
65
+ # warm both lanes
66
+ handler = SaxBenchHandlers::NullHandler.new
67
+ 3.times { DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, handler) } }
68
+ 3.times { DOCS.each { |doc| Leptris::XML::SAX::Parser.new(BRIDGE.new(handler)).parse(doc) } }
69
+ walker = SaxBenchHandlers::RecordWalkHandler.new
70
+ 3.times { DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, walker) } }
71
+
72
+ recorder_allocs = allocations do
73
+ DOCS.each { |doc| Leptris::XML::SAX::Parser.new(BRIDGE.new(handler)).parse(doc) }
74
+ end
75
+ replay_allocs = allocations do
76
+ DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, handler) }
77
+ end
78
+ walk_allocs = allocations do
79
+ DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, walker) }
80
+ end
81
+
82
+ replay_time = cpu_micros { 5.times { DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, handler) } } }
83
+ recorder_time = cpu_micros { 5.times { DOCS.each { |doc| Leptris::XML::SAX::Parser.new(BRIDGE.new(handler)).parse(doc) } } }
84
+ walk_time = cpu_micros { 5.times { DOCS.each { |doc| Moxml::Adapter::Leptris.sax_parse(doc, walker) } } }
85
+
86
+ puts format("recorder %<allocs>6d allocs %<time>6dµs", allocs: recorder_allocs, time: recorder_time / 5)
87
+ puts format("drain replay %<allocs>6d allocs %<time>6dµs", allocs: replay_allocs, time: replay_time / 5)
88
+ puts format("drain records-walk %<allocs>6d allocs %<time>6dµs", allocs: walk_allocs, time: walk_time / 5)
89
+ puts "(#{walker.elements} elements walked cumulatively; 452 per rep)"
90
+
91
+ if ENV["LEPTRIS_BENCH_LOCK"]
92
+ if walk_allocs >= recorder_allocs / 2
93
+ warn "LEPTRIS_BENCH_LOCK: records-walk allocs (#{walk_allocs}) " \
94
+ "not under half the recorder's (#{recorder_allocs}) — the " \
95
+ "#1298 win regressed"
96
+ exit 1
97
+ end
98
+ puts "lock: records-walk under half the recorder's allocations ✓"
99
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ # Replays the engine's bulk SAX record table (leptris#1298) to
7
+ # the bridge protocol. One drain crossing replaces the
8
+ # per-event recorder replay; the only String materializations
9
+ # left are the name/value/text data the handler actually
10
+ # consumes. End events reconstruct from the records' parent
11
+ # indices with a name-carrying stack — pre-order records make
12
+ # a parent mismatch the exact close boundary, and
13
+ # self-closing elements pop at their next sibling, matching
14
+ # the callback engine's event stream.
15
+ #
16
+ # @private
17
+ class SaxRecordReplay
18
+ KIND_ELEMENT = ::Leptris::XML::SAX::Records::KIND_ELEMENT
19
+
20
+ def initialize(bridge)
21
+ @bridge = bridge
22
+ end
23
+
24
+ def run(table)
25
+ @bridge.start_document
26
+ idx_stack = []
27
+ name_stack = []
28
+ count = table.count
29
+ i = 0
30
+ while i < count
31
+ while !idx_stack.empty? && table.parent(i) != idx_stack.last
32
+ idx_stack.pop
33
+ @bridge.end_element(name_stack.pop)
34
+ end
35
+ if table.kind(i) == KIND_ELEMENT
36
+ name = table.view(i)
37
+ pairs = []
38
+ k = table.attr_first(i)
39
+ last = k + table.attr_count_of(i)
40
+ while k < last
41
+ v = table.attr_value(k)
42
+ pairs << [table.attr_name(k),
43
+ table.attr_value_has_ws?(k) ? v.tr("\t\n\r", " ") : v]
44
+ k += 1
45
+ end
46
+ @bridge.start_element(name, pairs)
47
+ idx_stack << i
48
+ name_stack << name
49
+ else
50
+ @bridge.characters(table.view(i))
51
+ end
52
+ i += 1
53
+ end
54
+ @bridge.end_element(name_stack.pop) until name_stack.empty?
55
+ @bridge.end_document
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -130,6 +130,15 @@ module Moxml
130
130
  defined?(::Leptris::XML::NativeNode) &&
131
131
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.163.2")
132
132
 
133
+ # Bulk SAX record drain (leptris#1298): leptris_sax_records_parse
134
+ # + Leptris::XML::SAX::Records — one crossing per document,
135
+ # handlers replay or walk the table. Ships in the binding ride
136
+ # 1.9.240.0 (1.9.239.0 predates the walker — the gate must name
137
+ # the carrying release, not the engine surface). Dev floor pins
138
+ # ~> 1.9.238; older bindings take the callback Parser below.
139
+ NATIVE_SAX_RECORDS =
140
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.240")
141
+
133
142
  # Defined unconditionally: the body self-guards, and call
134
143
  # sites must never depend on the layer having loaded
135
144
  # (issue #217 — binding-only installs crashed on the
@@ -522,6 +531,7 @@ module Moxml
522
531
  autoload :Materialize, "moxml/adapter/leptris/materialize"
523
532
  autoload :Serialize, "moxml/adapter/leptris/serialize"
524
533
  autoload :LeptrisSAXBridge, "moxml/adapter/leptris/sax_bridge"
534
+ autoload :SaxRecordReplay, "moxml/adapter/leptris/sax_record_replay"
525
535
  extend Serialize
526
536
  extend DocumentParts
527
537
  extend Markers
@@ -1501,7 +1511,8 @@ module Moxml
1501
1511
 
1502
1512
  flat = attrs.flatten
1503
1513
  addr = ::Leptris::XML::Native.create_element_with_attrs(
1504
- doc.c_address, binding_parent.c_address, name.to_s, flat)
1514
+ doc.c_address, binding_parent.c_address, name.to_s, flat
1515
+ )
1505
1516
  return nil if addr.nil? || addr.zero?
1506
1517
 
1507
1518
  # The binding's own wrap produces the proper binding
@@ -1510,7 +1521,7 @@ module Moxml
1510
1521
  # included — the SAME native, so the wrapper identity
1511
1522
  # holds across later traversals.
1512
1523
  canonical_native(doc,
1513
- ::Leptris::XML::Node.wrap(::FFI::Pointer.new(addr), doc))
1524
+ ::Leptris::XML::Node.wrap(::FFI::Pointer.new(addr), doc))
1514
1525
  end
1515
1526
 
1516
1527
  def attributes(element)
@@ -2114,7 +2125,25 @@ module Moxml
2114
2125
  def sax_parse(xml, handler)
2115
2126
  bridge = LeptrisSAXBridge.new(handler)
2116
2127
  xml_string = xml.is_a?(IO) || xml.is_a?(::StringIO) ? xml.read : xml.to_s
2117
- ::Leptris::XML::SAX::Parser.new(bridge).parse(xml_string)
2128
+ # Bulk record drain (leptris#1298, NATIVE_SAX_RECORDS):
2129
+ # one crossing per document. The handler decides the shape
2130
+ # polymorphically — Handler#on_sax_records answers
2131
+ # :unhandled, so classic handlers get the exact callback
2132
+ # stream replayed from the table; hot loops override and
2133
+ # walk the records (nil table = outside the drain subset
2134
+ # or pre-1.9.238 binding — callback path).
2135
+ table = NATIVE_SAX_RECORDS ? ::Leptris::XML::SAX::Records.open(xml_string) : nil
2136
+ if table
2137
+ begin
2138
+ return if handler.on_sax_records(table) != :unhandled
2139
+
2140
+ SaxRecordReplay.new(bridge).run(table)
2141
+ ensure
2142
+ table.free
2143
+ end
2144
+ else
2145
+ ::Leptris::XML::SAX::Parser.new(bridge).parse(xml_string)
2146
+ end
2118
2147
  rescue ::Leptris::XML::ParseError, ::Leptris::XML::Error => e
2119
2148
  handler.on_error(Moxml::ParseError.new(e.message))
2120
2149
  end
data/lib/moxml/cdata.rb CHANGED
@@ -11,5 +11,10 @@ module Moxml
11
11
  def content=(text)
12
12
  adapter.set_cdata_content(@native, normalize_xml_value(text))
13
13
  end
14
+
15
+ # See Moxml::Text#text — Node#text's base is "" for non-elements.
16
+ def text
17
+ content
18
+ end
14
19
  end
15
20
  end
data/lib/moxml/comment.rb CHANGED
@@ -13,5 +13,10 @@ module Moxml
13
13
  adapter.validate_comment_content(text)
14
14
  adapter.set_comment_content(@native, text)
15
15
  end
16
+
17
+ # See Moxml::Text#text — Node#text's base is "" for non-elements.
18
+ def text
19
+ content
20
+ end
16
21
  end
17
22
  end
@@ -108,6 +108,21 @@ module Moxml
108
108
  def on_warning(message)
109
109
  # Override in subclass if needed
110
110
  end
111
+
112
+ # Bulk SAX record surface (leptris#1298, leptris adapter only).
113
+ # On bindings with the drain the adapter hands the WHOLE
114
+ # document as a Leptris::XML::SAX::Records table — one crossing,
115
+ # no per-event marshaling; hot loops walk the records and
116
+ # materialize only what they consume (this default answers
117
+ # :unhandled, and the adapter replays the classic callback
118
+ # stream from the table instead — the events are identical).
119
+ #
120
+ # @param table [Leptris::XML::SAX::Records] record table, valid
121
+ # only during the call
122
+ # @return [Object] anything but :unhandled claims the table
123
+ def on_sax_records(_table)
124
+ :unhandled
125
+ end
111
126
  end
112
127
  end
113
128
  end
data/lib/moxml/text.rb CHANGED
@@ -21,5 +21,13 @@ module Moxml
21
21
  def to_s
22
22
  content
23
23
  end
24
+
25
+ # Node#text's base returns "" for non-element nodes; a text
26
+ # node's text IS its content (leptris's Reads layer already
27
+ # answered content here — the wrapper contract now matches on
28
+ # every adapter).
29
+ def text
30
+ content
31
+ end
24
32
  end
25
33
  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.80"
4
+ VERSION = "0.5.82"
5
5
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # XML 1.0 §2.11 End-of-Line Handling: literal CRLF and lone CR
6
+ # normalize to LF in parsed character data, before tokenization —
7
+ # so CDATA content normalizes too, while character references
8
+ # (&#13;) sit OUTSIDE the rule and must survive as CR. Attribute
9
+ # values normalize EOL first, then §3.3.3 maps the break to a
10
+ # space (libxml2 parity: "x\r\ny" -> "x y").
11
+ #
12
+ # Adapter status (verified 2026-09-25):
13
+ # nokogiri/libxml - conform on all three surfaces
14
+ # rexml - text/cdata conform; attrs EOL-only ("x\ny")
15
+ # ox - text/cdata conform; attrs raw; &#13;
16
+ # over-normalized to \n (upstream)
17
+ # oga - preserves raw CR everywhere (upstream)
18
+ # leptris - fixed in engine 1.9.240 (leptris#1355,
19
+ # leptris-ruby#326); skipped below until the
20
+ # binding release vending that engine rides
21
+ # (gate: binding 1.9.240).
22
+ RSpec.describe "XML line-ending normalization" do
23
+ ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
24
+
25
+ def with_adapter(name)
26
+ ctx = Moxml.new(name)
27
+ yield ctx
28
+ rescue LoadError
29
+ skip "adapter #{name} unavailable"
30
+ end
31
+
32
+ def leptris_fixed?(name)
33
+ return true unless name == :leptris
34
+
35
+ defined?(Leptris::VERSION) &&
36
+ Gem::Version.new(Leptris::VERSION) >= Gem::Version.new("1.9.240")
37
+ end
38
+
39
+ it "normalizes CRLF and lone CR to LF in text content" do
40
+ ADAPTERS.each do |name|
41
+ next unless leptris_fixed?(name)
42
+
43
+ skip "oga preserves raw CR (upstream)" if name == :oga
44
+
45
+ with_adapter(name) do |ctx|
46
+ text = ctx.parse("<r>a\r\nb\rc</r>").root.children.first
47
+ expect(text.content).to eq("a\nb\nc"), "#{name}: #{text.content.inspect}"
48
+ end
49
+ end
50
+ end
51
+
52
+ it "normalizes EOL inside CDATA content" do
53
+ ADAPTERS.each do |name|
54
+ next unless leptris_fixed?(name)
55
+
56
+ skip "oga preserves raw CR (upstream)" if name == :oga
57
+
58
+ with_adapter(name) do |ctx|
59
+ cdata = ctx.parse("<r><![CDATA[a\r\nb]]></r>").root.children.first
60
+ expect(cdata.content).to eq("a\nb"), "#{name}: #{cdata.content.inspect}"
61
+ end
62
+ end
63
+ end
64
+
65
+ it "preserves CR from character references" do
66
+ ADAPTERS.each do |name|
67
+ next unless leptris_fixed?(name)
68
+
69
+ skip "ox collapses &#13; to LF (upstream)" if name == :ox
70
+ skip "oga preserves raw CR (upstream)" if name == :oga
71
+
72
+ with_adapter(name) do |ctx|
73
+ text = ctx.parse("<r>a&#13;b</r>").root.children.first
74
+ expect(text.content).to eq("a\rb"), "#{name}: #{text.content.inspect}"
75
+ end
76
+ end
77
+ end
78
+
79
+ it "collapses attribute CRLF to a single space (libxml2 parity)" do
80
+ conforming = %i[nokogiri libxml leptris]
81
+ conforming.each do |name|
82
+ next unless leptris_fixed?(name)
83
+
84
+ with_adapter(name) do |ctx|
85
+ root = ctx.parse("<r a=\"x\r\ny\" b=\"x\ty\"/>").root
86
+ expect(root["a"]).to eq("x y"), "#{name}: attr CRLF #{root['a'].inspect}"
87
+ expect(root["b"]).to eq("x y"), "#{name}: attr tab #{root['b'].inspect}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -295,16 +295,24 @@ def normalize_type_attribute(name, value)
295
295
  end
296
296
  end
297
297
 
298
- def test_element_content(element)
299
- return nil unless element
298
+ def test_element_content(node)
299
+ return nil unless node
300
+
301
+ # :text_content holds a Text node (from //text()) — Text#text now
302
+ # returns real content on every adapter, so this branch actually
303
+ # fires. Text nodes have no name/attributes/children.
304
+ unless node.is_a?(Moxml::Element)
305
+ return { name: nil, attributes: {}, text: node.text.to_s.strip,
306
+ namespace: nil, children_count: 0, xpath: [] }
307
+ end
300
308
 
301
309
  {
302
- name: element.name,
303
- attributes: universal_attributes(element),
304
- text: element.text.to_s.strip,
305
- namespace: element.namespace&.uri,
306
- children_count: element.children.size,
307
- xpath: element.xpath("//*"),
310
+ name: node.name,
311
+ attributes: universal_attributes(node),
312
+ text: node.text.to_s.strip,
313
+ namespace: node.namespace&.uri,
314
+ children_count: node.children.size,
315
+ xpath: node.xpath("//*"),
308
316
  }
309
317
  end
310
318
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Bulk SAX record drain (leptris#1298): on bindings carrying
6
+ # Leptris::XML::SAX::Records the leptris adapter drains the document
7
+ # in one crossing. Classic handlers must receive the SAME event
8
+ # stream the callback Recorder produces (the replay reconstructs end
9
+ # events from the records' parent indices), handlers overriding
10
+ # on_sax_records claim the raw table instead, and raw attribute
11
+ # whitespace is normalized (3.3.3) exactly where the callback path
12
+ # normalizes it.
13
+ module SaxDrainHandlers
14
+ class EventCollector < Moxml::SAX::Handler
15
+ attr_reader :events
16
+
17
+ def initialize
18
+ super
19
+ @events = []
20
+ end
21
+
22
+ def on_start_document
23
+ @events << [:start_document]
24
+ end
25
+
26
+ def on_end_document
27
+ @events << [:end_document]
28
+ end
29
+
30
+ def on_start_element(name, attributes = {}, _namespaces = {})
31
+ @events << [:start, name, attributes]
32
+ end
33
+
34
+ def on_end_element(name)
35
+ @events << [:end, name]
36
+ end
37
+
38
+ def on_characters(text)
39
+ @events << [:chars, text]
40
+ end
41
+ end
42
+
43
+ class TableClaimant < Moxml::SAX::Handler
44
+ attr_reader :tables
45
+
46
+ def initialize
47
+ super
48
+ @tables = []
49
+ end
50
+
51
+ def on_sax_records(table)
52
+ @tables << table
53
+ :claimed
54
+ end
55
+ end
56
+ end
57
+
58
+ RSpec.describe "leptris SAX record drain", if: defined?(Leptris::XML::SAX::Records) do
59
+ let(:context) { Moxml.new(:leptris) }
60
+ let(:xml) do
61
+ '<r a="x y" xmlns="urn:default"><item n="1">t1</item>' \
62
+ '<item n="2">t2<b>k</b></item><last/></r>'
63
+ end
64
+
65
+ it "replays the callback event stream through the drain" do
66
+ drain_events = SaxDrainHandlers::EventCollector.new
67
+ context.sax_parse(xml, drain_events)
68
+
69
+ callback_events = SaxDrainHandlers::EventCollector.new
70
+ bridge = Moxml::Adapter::Leptris::LeptrisSAXBridge.new(callback_events)
71
+ Leptris::XML::SAX::Parser.new(bridge).parse(xml)
72
+
73
+ expect(drain_events.events).to eq(callback_events.events)
74
+ expect(drain_events.events.first).to eq([:start_document])
75
+ expect(drain_events.events).to include([:start, "item", { "n" => "1" }])
76
+ expect(drain_events.events).to include([:chars, "t2"])
77
+ expect(drain_events.events.last).to eq([:end_document])
78
+ end
79
+
80
+ it "hands claimed tables to handlers that opt in" do
81
+ claimant = SaxDrainHandlers::TableClaimant.new
82
+ context.sax_parse("<r><a>n</a></r>", claimant)
83
+
84
+ expect(claimant.tables.size).to eq(1)
85
+ expect(claimant.tables.first.count).to eq(3)
86
+ end
87
+ end
@@ -14,6 +14,23 @@ RSpec.describe Moxml::Text do
14
14
  end
15
15
  end
16
16
 
17
+ describe "#text" do
18
+ it "matches content on a text node" do
19
+ text = doc.root.children.first
20
+ expect(text.text).to eq("plain text")
21
+ end
22
+
23
+ it "is consistent across adapters" do
24
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
25
+ ctx = Moxml.new(adapter_name)
26
+ d = ctx.parse("<root>hello world</root>")
27
+ text = d.root.children.first
28
+ expect(text.text).to eq("hello world"),
29
+ "Text#text for #{adapter_name} adapter"
30
+ end
31
+ end
32
+ end
33
+
17
34
  describe "#to_xml" do
18
35
  it "returns XML representation" do
19
36
  text = doc.root.children.first
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.80
4
+ version: 0.5.82
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-24 00:00:00.000000000 Z
11
+ date: 2026-09-25 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
@@ -41,6 +41,7 @@ files:
41
41
  - Rakefile
42
42
  - benchmark/pipeline_bench.rb
43
43
  - benchmark/plan_bench.rb
44
+ - benchmark/sax_bench.rb
44
45
  - benchmarks/.gitignore
45
46
  - benchmarks/generate_report.rb
46
47
  - bin/console
@@ -160,6 +161,7 @@ files:
160
161
  - lib/moxml/adapter/leptris/markers.rb
161
162
  - lib/moxml/adapter/leptris/materialize.rb
162
163
  - lib/moxml/adapter/leptris/sax_bridge.rb
164
+ - lib/moxml/adapter/leptris/sax_record_replay.rb
163
165
  - lib/moxml/adapter/leptris/serialize.rb
164
166
  - lib/moxml/adapter/libxml.rb
165
167
  - lib/moxml/adapter/libxml/entity_ref_registry.rb
@@ -300,6 +302,7 @@ files:
300
302
  - sig/moxml.rbs
301
303
  - spec/consistency/README.md
302
304
  - spec/consistency/adapter_parity_spec.rb
305
+ - spec/consistency/eol_normalization_spec.rb
303
306
  - spec/consistency/round_trip_spec.rb
304
307
  - spec/examples/README.md
305
308
  - spec/examples/attribute_examples_spec.rb
@@ -435,6 +438,7 @@ files:
435
438
  - spec/moxml/adapter/entity_restoration_spec.rb
436
439
  - spec/moxml/adapter/headed_ox_spec.rb
437
440
  - spec/moxml/adapter/leptris_doc_parts_spec.rb
441
+ - spec/moxml/adapter/leptris_sax_records_spec.rb
438
442
  - spec/moxml/adapter/leptris_spec.rb
439
443
  - spec/moxml/adapter/libxml_internals_spec.rb
440
444
  - spec/moxml/adapter/libxml_spec.rb