moxml 0.5.80 → 0.5.81

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: 957697bcb855cea040ac0d8b2f202a7cd8fd230482137602fcf7d50fc2712ebb
4
+ data.tar.gz: 22815ac3e9920a8939b0076f3b4222a154e0420d4fe1a21d9b7e56bd9b0818b8
5
5
  SHA512:
6
- metadata.gz: d956c3ff27cae1eedce7a4cc9cb53f57a7d12a58999b74ddebb7bc4a3c99661e2cea010a5a2cc9682315ed5d4bfe06b4122453127d6682ecb96fcfd4401202e2
7
- data.tar.gz: 478679f5fc817ba910aa0adc927ca0e22009ec1cc787b1388cef9ed3ff570da51c83eaa5e5a99f9b4b9c21e2d6d0d8dbed280925035bc00b580ba242776e7766
6
+ metadata.gz: 422b076bb191e01b27693af02a66d4e985f574dbcc49451c9af395c1905f9cabfdb2faf0e1b15303a5444c17a8fba98e70e5bea7d5c61f1f152d9fa09a550329
7
+ data.tar.gz: 2d75b66d322414ea8c99b79fe1ed78c7aa758b90b7f5b7f56a685e9f861bca12d6f641803aa04ced575a812677a085619bd40ee435963b035cdbe005a36d1b55
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"
@@ -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.81"
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.81
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
@@ -160,6 +160,7 @@ files:
160
160
  - lib/moxml/adapter/leptris/markers.rb
161
161
  - lib/moxml/adapter/leptris/materialize.rb
162
162
  - lib/moxml/adapter/leptris/sax_bridge.rb
163
+ - lib/moxml/adapter/leptris/sax_record_replay.rb
163
164
  - lib/moxml/adapter/leptris/serialize.rb
164
165
  - lib/moxml/adapter/libxml.rb
165
166
  - lib/moxml/adapter/libxml/entity_ref_registry.rb
@@ -300,6 +301,7 @@ files:
300
301
  - sig/moxml.rbs
301
302
  - spec/consistency/README.md
302
303
  - spec/consistency/adapter_parity_spec.rb
304
+ - spec/consistency/eol_normalization_spec.rb
303
305
  - spec/consistency/round_trip_spec.rb
304
306
  - spec/examples/README.md
305
307
  - spec/examples/attribute_examples_spec.rb
@@ -435,6 +437,7 @@ files:
435
437
  - spec/moxml/adapter/entity_restoration_spec.rb
436
438
  - spec/moxml/adapter/headed_ox_spec.rb
437
439
  - spec/moxml/adapter/leptris_doc_parts_spec.rb
440
+ - spec/moxml/adapter/leptris_sax_records_spec.rb
438
441
  - spec/moxml/adapter/leptris_spec.rb
439
442
  - spec/moxml/adapter/libxml_internals_spec.rb
440
443
  - spec/moxml/adapter/libxml_spec.rb