leptris 1.9.33-aarch64-linux → 1.9.35-aarch64-linux

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: 179f524963c6e09db30ac71cd0a6c20cee7a2b38f8bcd268bd1c551ccc3b25dd
4
- data.tar.gz: 2d1c23f938f82cf6c052c3b008c0d79309522d53f5284aaba18807ed52cc7a4f
3
+ metadata.gz: a5d2cc0c763678c71c93628564323985c5e55f27ea126aa9f003319e4a7a7524
4
+ data.tar.gz: 8545c65e4579fe342f87938cf4f24f63dd02b7900bbf4bad7e040934a35b750e
5
5
  SHA512:
6
- metadata.gz: f525496783584c2eec01c84ff0e8845a07f9b05e61e918042caa133ec7a464aae61b3ed8b9be449e4a015af0255629014054dc6101ae8376a1e5c3e0d40f7c79
7
- data.tar.gz: 60402a2294e25dffd28f416b09ba1f74325acc76b2e00cdf7c6fa6573d7ba6b17fd8edcecafaecf0449a53dc87cdc07669c382bf7a4bb8f1eba0f2c641ee6abc
6
+ metadata.gz: 4c936cd0a55e640174407bcbc38a774679949d327c00dfd36ac058828094033405bdf5e9960f87d4e390c67b8ae58aea9aa2dd7f3a61adbae626f41488fadb8b
7
+ data.tar.gz: 84e79fc4a1bb6d563aefee5a44ae7b78e19488e3991a167221f20bbaff441cd289bf5c3e459d0ddfdec1d00f67429705e77aec5175169360496fce29f8fa84bf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.35] - 2026-08-28
9
+
10
+ ### Added
11
+
12
+ - **libleptris 1.9.9 lockstep**: pin bumped; symbol audit 249/249
13
+ with `leptris_document_remove_pi` and
14
+ `leptris_document_serialize_ext` attached. Engine fixes ride
15
+ along: CDATA `]]>` runs split across node boundaries, XSLT
16
+ suite 144 -> 152/205, attribute-axis entity expansion.
17
+ - **Document-level PIs fully mutable (leptris-ruby#92, upstream
18
+ #612)**: parse-created document-level PIs carry document
19
+ linkage — `target=`/`data=` work like on any tree PI (the
20
+ 1.9.32 contract-error mapping is removed). `PI#unlink` on a
21
+ document-level PI routes through the new document-level removal,
22
+ identity-matched by index so the right same-target PI comes out.
23
+ New `Document#remove_pi(target_or_index)` — by target or 0-based
24
+ index, returns the removed (pool-owned) PI or nil.
25
+ - **`Document#to_xml(indent_text: true)`** — display-form
26
+ serialization (upstream #129): text and mixed content indent;
27
+ output is display-oriented and not round-trip-guaranteed.
28
+
29
+ ### Changed
30
+
31
+ - **Inline memo on `Element#[]`**: the external battery caught
32
+ attribute reads ~1.6x behind Nokogiri — six method dispatches
33
+ per read through the attributes/memo_hit? tower. The guard is
34
+ now spelled inline over a values hash materialized alongside the
35
+ Attr objects (same ADR-0003 semantics, misses included).
36
+
37
+ ### Meta
38
+
39
+ - External head-to-head battery vs Nokogiri 1.19.4 (big.xml):
40
+ parse 12.3x, css 4.8x, xpath nodeset 3.0x, serialize 2.3x,
41
+ at_css 1.4x, SAX both shapes (round XXVI). Two shapes remain
42
+ behind and are C-side-bound: cold full walk 1.4x (needs
43
+ node_children out_kinds to skip per-node get_type — filing
44
+ upstream) and scalar xpath 1.5x (engine-side scalar eval).
45
+
46
+ ## [1.9.34] - 2026-08-28
47
+
48
+ ### Changed
49
+
50
+ - **SAX fully wins every shape** (leptris-ruby's parity-not-win,
51
+ upstream #594's framing): on the all-events handler profile the
52
+ binding previously LOST to Nokogiri by 14% (170 vs 149 ms per
53
+ 1.9 MB parse) — the ffi gem's per-event callback trampoline
54
+ could not match a compiled C extension. Two things changed:
55
+ - The engine's C-side recorder cost no longer scales with
56
+ element count (~12 ms per 250k-event document now), so a bulk
57
+ transport can win.
58
+ - New `SAX::Recorder#dispatch(handler, kinds)` — the two-level
59
+ drain driving handler METHOD calls with the callback
60
+ transport's exact shapes (pairs arrays, one-arg arity
61
+ dispatch, UTF-8, PI normalization). `SAX::Parser` picks the
62
+ transport by override weight (characters 0.6, start/end 0.2;
63
+ ≥ 0.8 → bulk): one overridden hot kind stays on callbacks
64
+ (the engine skips C-side emission for unattached kinds:
65
+ text-only 23 ms vs 43 ms), two or more go bulk (start+end+chars
66
+ 167 → **122 ms**, ahead of Nokogiri's 142 ms on the same
67
+ machine; head-to-head 123 vs 133). Handlers cannot tell which
68
+ transport served them — spec-pinned byte-identical call
69
+ sequences.
70
+ - Drain repairs that made it possible: kind-strip two-level
71
+ drain (no more 2.25M-Integer unpack array per single-feed
72
+ drain), `FFI::Struct.size` hoisted out of the loop (250k
73
+ layout lookups per parse), unused fields yield nil instead of
74
+ empty-String allocations, a characters fast path in the drain
75
+ loop.
76
+
8
77
  ## [1.9.33] - 2026-08-28
9
78
 
10
79
  ### Changed
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.8"
11
+ LIBLEPTRIS_VERSION = "1.9.9"
12
12
 
13
13
  CMAKE_FLAGS = %w[
14
14
  -DCMAKE_BUILD_TYPE=Release
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.33"
4
+ VERSION = "1.9.35"
5
5
  end
@@ -266,9 +266,16 @@ class Leptris::XML::Document
266
266
  end
267
267
  alias_method :internal_subset, :doctype
268
268
 
269
- def to_xml(indent: 0, no_decl: false, encoding: nil)
269
+ # indent_text: true selects the display form (libleptris 1.9.9,
270
+ # #129): text and mixed content indent too — the output is
271
+ # display-oriented and not round-trip-guaranteed.
272
+ def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false)
270
273
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
271
274
  return "" if @c_ptr.nil?
275
+ if indent_text
276
+ return Leptris::XML::Serialization.to_xml_display(
277
+ @c_ptr, indent: indent, no_decl: no_decl, encoding: encoding)
278
+ end
272
279
  Leptris::XML::Serialization.to_xml(
273
280
  Leptris::XML::FFI.method(:leptris_document_serialize_into), @c_ptr,
274
281
  indent: indent, no_decl: no_decl, encoding: encoding)
@@ -338,6 +345,25 @@ class Leptris::XML::Document
338
345
  result
339
346
  end
340
347
 
348
+ # Removes a document-level processing instruction — by target
349
+ # (String/Symbol) or by 0-based index among the document's PIs
350
+ # (Integer) (libleptris 1.9.9, upstream #612). Returns the removed
351
+ # PI — pool-owned and valid until #free — or nil when nothing
352
+ # matched.
353
+ def remove_pi(target_or_index)
354
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
355
+ if target_or_index.is_a?(Integer)
356
+ ptr = Leptris::XML::FFI.leptris_document_remove_pi(
357
+ @c_ptr, nil, target_or_index)
358
+ else
359
+ ptr = Leptris::XML::FFI.leptris_document_remove_pi(
360
+ @c_ptr, target_or_index.to_s, 0)
361
+ end
362
+ return nil if ptr.null?
363
+ @version += 1
364
+ Leptris::XML::Node.wrap(ptr, self)
365
+ end
366
+
341
367
  # Append a document-level processing instruction. Returns self.
342
368
  def add_pi(target, data = "")
343
369
  witness = Leptris::XML::FFI.leptris_document_add_pi(
@@ -44,8 +44,18 @@ class Leptris::XML::Element < Leptris::XML::Node
44
44
  # written prefix never matters — the hash cannot answer them.
45
45
  name = key.to_s
46
46
  if @document && !name.include?(":")
47
- cached = attributes[name]
48
- return cached.nil? ? nil : cached.value
47
+ # The hottest read in the library: the memo guard is spelled
48
+ # INLINE rather than through attributes/#memo_hit? — six
49
+ # method dispatches per read was ~40% of the read cost, and
50
+ # the external head-to-head (nokogiri's one C call) was
51
+ # winning on it. Same three-line memo semantics (ADR 0003),
52
+ # incl. the nil-miss — the hash answers misses too.
53
+ values = @attr_values
54
+ unless values && @attributes_version == @document.version
55
+ attributes
56
+ values = @attr_values
57
+ end
58
+ return values[name]
49
59
  end
50
60
  ensure_alive!
51
61
  Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
@@ -133,9 +143,14 @@ class Leptris::XML::Element < Leptris::XML::Node
133
143
  def attributes
134
144
  return @attributes if memo_hit?(@attributes_version)
135
145
  result = {}
136
- each_attribute { |attr| result[attr.name] = attr }
146
+ values = {}
147
+ each_attribute do |attr|
148
+ result[attr.name] = attr
149
+ values[attr.name] = attr.value
150
+ end
137
151
  if @document
138
152
  @attributes = result
153
+ @attr_values = values
139
154
  @attributes_version = @document.version
140
155
  end
141
156
  result
@@ -72,6 +72,12 @@ module Leptris
72
72
  :encoding, :pointer
73
73
  end
74
74
 
75
+ # LeptrisSerializeExtOptions (1.9.9, #129): display-form
76
+ # serialization knobs beyond the ABI-frozen options struct.
77
+ class SerializeExtStruct < ::FFI::Struct
78
+ layout :indent_text, :int
79
+ end
80
+
75
81
  # Mirrors LeptrisParseOptions (libleptris >= 1.9.0 carries the
76
82
  # recover field; earlier fields unchanged since v1.6.0).
77
83
  class ParseOptionsStruct < ::FFI::Struct
@@ -156,6 +162,11 @@ module Leptris
156
162
  [:leptris_document, :size_t], :string
157
163
  attach_function :leptris_document_pi_data,
158
164
  [:leptris_document, :size_t], :string
165
+ # Document-level PI removal by target or index (1.9.9, #612):
166
+ # unlinks from the child chain; the node is pool-owned and
167
+ # stays valid until document free.
168
+ attach_function :leptris_document_remove_pi,
169
+ [:leptris_document, :string, :size_t], :leptris_node_ref
159
170
  attach_function :leptris_document_add_pi,
160
171
  [:leptris_document, :string, :string], :pointer
161
172
  # Document-level comments (libleptris 1.9.3, #578): parsed
@@ -611,6 +622,11 @@ module Leptris
611
622
  # (leptris#541). buf=NULL is a size query; the size-query +
612
623
  # fill pair reuses one serialization through a per-document
613
624
  # cache invalidated on mutation.
625
+ # Display-form variant (1.9.9, #129): the ext struct carries
626
+ # indent_text; returns an OWNED string — read via
627
+ # read_owned_string (the seam frees it).
628
+ attach_function :leptris_document_serialize_ext,
629
+ [:leptris_document, :pointer, :pointer], :pointer
614
630
  attach_function :leptris_document_serialize_into,
615
631
  [:leptris_document, :pointer, :size_t, :pointer, :pointer], :size_t
616
632
  attach_function :leptris_document_get_dtd,
@@ -263,12 +263,8 @@ class Leptris::XML::Node
263
263
 
264
264
  def unlink
265
265
  ensure_writable!
266
- rc = Leptris::XML::FFI.leptris_node_unlink(@c_ptr)
267
- if rc == Leptris::XML::FFI::LEPTRIS_ERROR_NOT_FOUND && processing_instruction?
268
- raise Leptris::XML::Error,
269
- Leptris::XML::ProcessingInstruction::READ_ONLY_NATIVE
270
- end
271
- Leptris::XML::FFI.check_status(rc)
266
+ Leptris::XML::FFI.check_status(
267
+ Leptris::XML::FFI.leptris_node_unlink(@c_ptr))
272
268
  @parent = nil
273
269
  self
274
270
  end
@@ -25,34 +25,58 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
25
25
  result
26
26
  end
27
27
 
28
- # Parse-created document-level PIs carry no document linkage in
29
- # libleptris (leptris-ruby#92): the C setters reject them with
30
- # INVALID_ARG while tree-level and Document#add_pi PIs mutate
31
- # fine. Map that failure to the contract instead of a bare
32
- # "Invalid argument".
33
- READ_ONLY_NATIVE =
34
- "this PI is a parse-created document-level PI — its node has " \
35
- "no document linkage in libleptris, so target/data cannot be " \
36
- "written and it cannot be unlinked (leptris-ruby#92); " \
37
- "tree-level and Document#add_pi PIs are mutable"
38
-
28
+ # Since libleptris 1.9.9 (#612) parse-created document-level PIs
29
+ # carry document linkage — the setters work on them like on any
30
+ # tree-level PI.
39
31
  def target=(new_target)
40
32
  ensure_writable!
41
- rc = Leptris::XML::FFI.leptris_pi_node_set_target(
42
- @c_ptr, new_target.to_s)
43
- raise Leptris::XML::Error, READ_ONLY_NATIVE if
44
- rc == Leptris::XML::FFI::LEPTRIS_ERROR_INVALID_ARG
45
- Leptris::XML::FFI.check_status(rc)
33
+ Leptris::XML::FFI.check_status(
34
+ Leptris::XML::FFI.leptris_pi_node_set_target(@c_ptr, new_target.to_s))
46
35
  new_target
47
36
  end
48
37
 
49
38
  def data=(new_data)
50
39
  ensure_writable!
51
- rc = Leptris::XML::FFI.leptris_pi_node_set_data(
52
- @c_ptr, new_data.to_s)
53
- raise Leptris::XML::Error, READ_ONLY_NATIVE if
54
- rc == Leptris::XML::FFI::LEPTRIS_ERROR_INVALID_ARG
55
- Leptris::XML::FFI.check_status(rc)
40
+ Leptris::XML::FFI.check_status(
41
+ Leptris::XML::FFI.leptris_pi_node_set_data(@c_ptr, new_data.to_s))
56
42
  new_data
57
43
  end
44
+
45
+ # Document-level PIs have no tree parent for leptris_node_unlink
46
+ # — route through the document-level removal (libleptris 1.9.9,
47
+ # #612), identity-matched by index so the right same-target PI
48
+ # comes out.
49
+ def unlink
50
+ ensure_writable!
51
+ rc = Leptris::XML::FFI.leptris_node_unlink(@c_ptr)
52
+ if rc == Leptris::XML::FFI::LEPTRIS_ERROR_NOT_FOUND
53
+ index = document_pi_index
54
+ if index.negative?
55
+ raise Leptris::XML::Error,
56
+ "PI not found in its document — already removed?"
57
+ end
58
+ removed = @document.remove_pi(index)
59
+ unless removed && removed.c_ptr == @c_ptr
60
+ raise Leptris::XML::Error, "document-level PI removal missed"
61
+ end
62
+ @parent = nil
63
+ return self
64
+ end
65
+ Leptris::XML::FFI.check_status(rc)
66
+ @parent = nil
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ # This PI's 0-based index among the document's PIs (document
73
+ # order — the same enumeration leptris_document_remove_pi uses).
74
+ def document_pi_index
75
+ pi_index = -1
76
+ @document.children.each do |child|
77
+ pi_index += 1 if child.pi?
78
+ return pi_index if child.c_ptr == @c_ptr
79
+ end
80
+ -1
81
+ end
58
82
  end
@@ -18,6 +18,7 @@ class Leptris::XML::SAX::Parser
18
18
  def document=(handler)
19
19
  @document = handler
20
20
  @handler_struct = nil
21
+ @dispatched_kinds = nil
21
22
  end
22
23
 
23
24
  # The handler struct (eleven FFI::Function callbacks + the C
@@ -40,16 +41,97 @@ class Leptris::XML::SAX::Parser
40
41
 
41
42
  def parse_memory(string)
42
43
  string = string.dup.force_encoding("UTF-8")
43
- handler_struct = self.handler_struct
44
- rc = Leptris::XML::FFI.leptris_sax_parse(
45
- string, string.bytesize, handler_struct.pointer, nil)
46
- if rc != 0
47
- raise Leptris::XML::ParseError,
48
- "leptris_sax_parse failed (rc=#{rc})"
44
+ if bulk_dispatch?
45
+ parse_memory_bulk(string)
46
+ else
47
+ handler_struct = self.handler_struct
48
+ rc = Leptris::XML::FFI.leptris_sax_parse(
49
+ string, string.bytesize, handler_struct.pointer, nil)
50
+ if rc != 0
51
+ raise Leptris::XML::ParseError,
52
+ "leptris_sax_parse failed (rc=#{rc})"
53
+ end
49
54
  end
50
55
  self
51
56
  end
52
57
 
58
+ # Bulk transport for many-override handlers: record the whole
59
+ # document C-side (~12ms per 250k events), then dispatch through
60
+ # the recorder's drain — 250k ffi callback trampolines cost
61
+ # ~150ms, so this wins outright for handlers that consume most of
62
+ # the event stream (and beats Nokogiri's C extension on the same
63
+ # shape). The delivered call shapes are IDENTICAL to the callback
64
+ # transport (pairs arrays, arity dispatch, UTF-8, PI-data
65
+ # normalization) — the transports are interchangeable to the
66
+ # handler.
67
+ #
68
+ # Transport choice, from the round-XXVI crossover measurements:
69
+ # ONE overridden hot kind pays less through the callbacks (the
70
+ # engine skips C-side emission for unattached kinds entirely:
71
+ # text-only 23ms vs 43ms), while two or more hot kinds win through
72
+ # the recorder (start+chars 138 -> 99ms, start+end+chars 167 ->
73
+ # 122ms vs Nokogiri's 142ms). The weights are document-shape
74
+ # priors — text-heavy documents with tagged structure.
75
+ HOT_KIND_WEIGHTS = {
76
+ characters: 0.6, start_element: 0.2, end_element: 0.2,
77
+ }.freeze
78
+ private_constant :HOT_KIND_WEIGHTS
79
+ DISPATCH_WEIGHT_THRESHOLD = 0.8
80
+ private_constant :DISPATCH_WEIGHT_THRESHOLD
81
+
82
+ def parse_memory_bulk(string)
83
+ recorder = Leptris::XML::SAX::Recorder.open
84
+ begin
85
+ rc = recorder.feed(string, final: true)
86
+ recorder.dispatch(@document, dispatched_kinds)
87
+ if rc != 0
88
+ raise Leptris::XML::ParseError,
89
+ "leptris_sax_parse failed (rc=#{rc})"
90
+ end
91
+ ensure
92
+ recorder.free
93
+ end
94
+ self
95
+ end
96
+
97
+ # The handler's overridden-kind map — the same decisions
98
+ # build_handler_struct makes, in dispatch form. Memoized with the
99
+ # handler struct; document= invalidates both.
100
+ def dispatched_kinds
101
+ @dispatched_kinds ||= begin
102
+ kinds = {}
103
+ {
104
+ start_document: :start_document,
105
+ end_document: :end_document,
106
+ start_element: :start_element,
107
+ end_element: :end_element,
108
+ characters: :characters,
109
+ comment: :comment,
110
+ cdata_block: :cdata,
111
+ processing_instruction: :pi,
112
+ start_prefix_mapping: :start_prefix,
113
+ end_prefix_mapping: :end_prefix,
114
+ error: :error,
115
+ }.each do |method_name, kind|
116
+ next unless overridden?(method_name)
117
+ kinds[kind] =
118
+ if method_name == :start_element &&
119
+ @document.method(:start_element).arity == 1
120
+ :one_arg
121
+ else
122
+ true
123
+ end
124
+ end
125
+ kinds
126
+ end
127
+ end
128
+
129
+ def bulk_dispatch?
130
+ dispatched_kinds.keys.sum do |kind|
131
+ HOT_KIND_WEIGHTS.fetch(kind, 0.0)
132
+ end >= DISPATCH_WEIGHT_THRESHOLD
133
+ end
134
+
53
135
  def parse_io(io)
54
136
  handler_struct = self.handler_struct
55
137
  parser_ptr = Leptris::XML::FFI.leptris_sax_parser_create(
@@ -88,6 +88,161 @@ class Leptris::XML::SAX::Recorder
88
88
  each_event(*Array(kinds), &block)
89
89
  end
90
90
 
91
+ # Which kinds carry each string field (the record layout leaves
92
+ # the other at offset 0 / length 0): slicing a zero-length run
93
+ # allocates a wasted empty String per event — 150k+ of them on a
94
+ # text-heavy document. Unused fields yield nil (the callback
95
+ # transport delivers no name for characters either).
96
+ HAS_NAME = {
97
+ start_element: true, end_element: true,
98
+ pi: true, start_prefix: true, end_prefix: true,
99
+ }.freeze
100
+ HAS_TEXT = {
101
+ characters: true, comment: true, cdata: true,
102
+ pi: true, start_prefix: true, error: true,
103
+ }.freeze
104
+
105
+ # Layout offsets (the struct stays the ABI's single source of
106
+ # truth) and per-count kind-strip templates: one byte per record,
107
+ # skipping the stride — the kind codes are 0..10, so the strip
108
+ # unpacks to cache-resident Fixnums, unlike the old whole-record
109
+ # unpack whose 2.25M-Integer array for a single-feed drain was
110
+ # most of the loop's allocation profile.
111
+ RECORD = Leptris::XML::FFI::SaxEventRecord
112
+ NAME_OFF = RECORD.offset_of(:name_off)
113
+ NAME_LEN = RECORD.offset_of(:name_len)
114
+ TEXT_OFF = RECORD.offset_of(:text_off)
115
+ TEXT_LEN = RECORD.offset_of(:text_len)
116
+ ATTRS_OFF = RECORD.offset_of(:attrs_off)
117
+ ATTR_COUNT = RECORD.offset_of(:attr_count)
118
+ LINE_OFF = RECORD.offset_of(:line)
119
+ COLUMN_OFF = RECORD.offset_of(:column)
120
+ RECORD_STRIDE = RECORD.size
121
+ SAX_EVENT_CHARACTERS_CODE = Leptris::XML::FFI::SAX_EVENT_CHARACTERS
122
+ NUL = "\0".freeze
123
+ KIND_TEMPLATE_FOR = ::Hash.new do |cache, count|
124
+ cache[count] = ("C x#{RECORD_STRIDE - 1}" * count)
125
+ end
126
+
127
+ # Dispatch drain: delivers the recorded events as handler method
128
+ # calls with the CALLBACK transport's exact shapes — pairs arrays
129
+ # (not hashes), one-argument start_element arity dispatch, UTF-8
130
+ # strings, PI-data whitespace normalization. +dispatched+ maps
131
+ # kind symbols to true for the kinds the handler overrides (the
132
+ # attach-only-overridden policy carried onto the bulk transport:
133
+ # other records cost one strip read). The C recorder costs ~12ms
134
+ # per 250k-event document while 250k ffi callback trampolines
135
+ # cost ~150ms — this is how a many-override handler beats the
136
+ # callback transport (and Nokogiri's C extension) outright.
137
+ def dispatch(handler, dispatched)
138
+ count_ptr = ::FFI::MemoryPointer.new(:size_t)
139
+ len_ptr = ::FFI::MemoryPointer.new(:size_t)
140
+ begin
141
+ records_ptr = Leptris::XML::FFI.leptris_sax_recorder_records(
142
+ @handle, count_ptr)
143
+ return self if records_ptr.null? || count_ptr.read_uint64.zero?
144
+ arena_ptr = Leptris::XML::FFI.leptris_sax_recorder_arena(
145
+ @handle, len_ptr)
146
+ arena = arena_ptr.read_bytes(len_ptr.read_uint64)
147
+ count = count_ptr.read_uint64
148
+ records = records_ptr.read_bytes(count * RECORD_STRIDE)
149
+ kinds = records.unpack(KIND_TEMPLATE_FOR[count])
150
+
151
+ one_arg_start = dispatched[:start_element] == :one_arg
152
+ i = 0
153
+ while i < count
154
+ code = kinds[i]
155
+ kind = KIND_BY_CODE[code]
156
+ if kind && dispatched[kind]
157
+ base = i * RECORD_STRIDE
158
+ case kind
159
+ when :characters
160
+ handler.characters(slice(arena,
161
+ records_ptr.get_uint32(base + TEXT_OFF),
162
+ records_ptr.get_uint32(base + TEXT_LEN)))
163
+ when :start_element
164
+ name = slice(arena,
165
+ records_ptr.get_uint32(base + NAME_OFF),
166
+ records_ptr.get_uint32(base + NAME_LEN))
167
+ if one_arg_start
168
+ handler.start_element(name)
169
+ else
170
+ handler.start_element(name, attrs_pairs(arena,
171
+ records_ptr.get_uint32(base + ATTRS_OFF),
172
+ records_ptr.get_uint32(base + ATTR_COUNT)))
173
+ end
174
+ when :end_element
175
+ handler.end_element(slice(arena,
176
+ records_ptr.get_uint32(base + NAME_OFF),
177
+ records_ptr.get_uint32(base + NAME_LEN)))
178
+ when :comment
179
+ handler.comment(slice(arena,
180
+ records_ptr.get_uint32(base + TEXT_OFF),
181
+ records_ptr.get_uint32(base + TEXT_LEN)))
182
+ when :cdata
183
+ handler.cdata_block(slice(arena,
184
+ records_ptr.get_uint32(base + TEXT_OFF),
185
+ records_ptr.get_uint32(base + TEXT_LEN)))
186
+ when :pi
187
+ handler.processing_instruction(slice(arena,
188
+ records_ptr.get_uint32(base + NAME_OFF),
189
+ records_ptr.get_uint32(base + NAME_LEN)),
190
+ Leptris::XML::FFI.read_pi_data(slice(arena,
191
+ records_ptr.get_uint32(base + TEXT_OFF),
192
+ records_ptr.get_uint32(base + TEXT_LEN))))
193
+ when :start_prefix
194
+ handler.start_prefix_mapping(slice(arena,
195
+ records_ptr.get_uint32(base + NAME_OFF),
196
+ records_ptr.get_uint32(base + NAME_LEN)),
197
+ slice(arena,
198
+ records_ptr.get_uint32(base + TEXT_OFF),
199
+ records_ptr.get_uint32(base + TEXT_LEN)))
200
+ when :end_prefix
201
+ handler.end_prefix_mapping(slice(arena,
202
+ records_ptr.get_uint32(base + NAME_OFF),
203
+ records_ptr.get_uint32(base + NAME_LEN)))
204
+ when :error
205
+ handler.error(slice(arena,
206
+ records_ptr.get_uint32(base + TEXT_OFF),
207
+ records_ptr.get_uint32(base + TEXT_LEN)),
208
+ records_ptr.get_uint32(base + LINE_OFF),
209
+ records_ptr.get_uint32(base + COLUMN_OFF))
210
+ when :start_document
211
+ handler.start_document
212
+ when :end_document
213
+ handler.end_document
214
+ end
215
+ end
216
+ i += 1
217
+ end
218
+ ensure
219
+ count_ptr.free
220
+ len_ptr.free
221
+ end
222
+ self
223
+ end
224
+
225
+ # attrs as [name, value] pairs in source order — the callback
226
+ # transport's shape (walk_attr_array).
227
+ def attrs_pairs(arena, off, count)
228
+ return [] if count.zero?
229
+ pairs = []
230
+ pos = off
231
+ nul = NUL
232
+ utf8 = Encoding::UTF_8
233
+ while count > 0
234
+ name_end = arena.index(nul, pos)
235
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
236
+ pos = name_end + 1
237
+ value_end = arena.index(nul, pos)
238
+ pairs << [name,
239
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)]
240
+ pos = value_end + 1
241
+ count -= 1
242
+ end
243
+ pairs
244
+ end
245
+
91
246
  # Drains the current chunk's records: bulk-reads the record array
92
247
  # and the packed arena (two FFI calls), then slices every string
93
248
  # from the arena in Ruby — no per-string FFI. With +kinds+, records
@@ -108,27 +263,48 @@ class Leptris::XML::SAX::Recorder
108
263
  arena = arena_ptr.read_bytes(len_ptr.read_uint64)
109
264
  count = count_ptr.read_uint64
110
265
 
111
- # The whole record block in ONE read: read_bytes + String#unpack
112
- # (C-fast) — two FFI calls per chunk total (records + arena),
113
- # the true bulk discipline. Template per record: kind byte,
114
- # 7 pad, then the eight uint32 fields (little-endian hosts —
115
- # the layout the header freezes for FFI mirrors).
116
- stride = Leptris::XML::FFI::SaxEventRecord.size
117
- fields = records_ptr.read_bytes(count * stride)
118
- .unpack("C x7 V8" * count)
266
+ # Two-level drain: ONE bulk read + kind-strip unpack decides
267
+ # which records to touch, then each yielded event's fields are
268
+ # read individually (get_uint32 at layout offsets) — an
269
+ # unwanted record costs one strip read, an unused FIELD costs
270
+ # nothing, and no per-chunk field array is ever built.
271
+ records = records_ptr.read_bytes(count * RECORD_STRIDE)
272
+ kinds = records.unpack(KIND_TEMPLATE_FOR[count])
119
273
 
274
+ has_name = HAS_NAME
275
+ has_text = HAS_TEXT
276
+ drain = wanted.nil?
120
277
  i = 0
121
278
  while i < count
122
- kind = KIND_BY_CODE[fields[i * 9]]
123
- if kind && (wanted.nil? || wanted[kind])
124
- base = i * 9 + 1
125
- text = slice(arena, fields[base + 2], fields[base + 3])
126
- text = Leptris::XML::FFI.read_pi_data(text) if kind == :pi
127
- yield kind,
128
- slice(arena, fields[base], fields[base + 1]),
129
- text,
130
- attrs_from(arena, fields[base + 4], fields[base + 5]),
131
- fields[base + 6], fields[base + 7]
279
+ code = kinds[i]
280
+ base = i * RECORD_STRIDE
281
+ if code == SAX_EVENT_CHARACTERS_CODE && drain
282
+ # The dominant branch (text-heavy documents): no name, no
283
+ # attrs, no per-kind gate lookups — kind, text, position.
284
+ yield :characters, nil,
285
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
286
+ records_ptr.get_uint32(base + TEXT_LEN)),
287
+ nil,
288
+ records_ptr.get_uint32(base + LINE_OFF),
289
+ records_ptr.get_uint32(base + COLUMN_OFF)
290
+ else
291
+ kind = KIND_BY_CODE[code]
292
+ if kind && (drain || wanted[kind])
293
+ name = if has_name[kind]
294
+ slice(arena, records_ptr.get_uint32(base + NAME_OFF),
295
+ records_ptr.get_uint32(base + NAME_LEN))
296
+ end
297
+ text = if has_text[kind]
298
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
299
+ records_ptr.get_uint32(base + TEXT_LEN))
300
+ end
301
+ text = Leptris::XML::FFI.read_pi_data(text) if kind == :pi
302
+ yield kind, name, text,
303
+ attrs_from(arena, records_ptr.get_uint32(base + ATTRS_OFF),
304
+ records_ptr.get_uint32(base + ATTR_COUNT)),
305
+ records_ptr.get_uint32(base + LINE_OFF),
306
+ records_ptr.get_uint32(base + COLUMN_OFF)
307
+ end
132
308
  end
133
309
  i += 1
134
310
  end
@@ -153,9 +329,9 @@ class Leptris::XML::SAX::Recorder
153
329
  kinds.each_with_object({}) { |kind, set| set[kind] = true }
154
330
  end
155
331
 
156
- # Zero-length slices are empty strings, not nil — the default
157
- # namespace prefix is legitimately "" (START_PREFIX: name may be
158
- # ""), matching the callback transport's delivery.
332
+ # The default-namespace prefix is legitimately "" (START_PREFIX:
333
+ # name may be zero-length) — zero-length slices stay "" where the
334
+ # field is carried (unused fields never reach here).
159
335
  def slice(arena, off, len)
160
336
  arena.byteslice(off, len).force_encoding(Encoding::UTF_8)
161
337
  end
@@ -166,15 +342,17 @@ class Leptris::XML::SAX::Recorder
166
342
  return nil if count.zero?
167
343
  hash = {}
168
344
  pos = off
169
- count.times do
170
- name_end = arena.index("\0", pos)
171
- name = arena.byteslice(pos, name_end - pos)
345
+ nul = NUL
346
+ utf8 = Encoding::UTF_8
347
+ while count > 0
348
+ name_end = arena.index(nul, pos)
349
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
172
350
  pos = name_end + 1
173
- value_end = arena.index("\0", pos)
174
- value = arena.byteslice(pos, value_end - pos)
351
+ value_end = arena.index(nul, pos)
352
+ hash[name] =
353
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)
175
354
  pos = value_end + 1
176
- hash[name.force_encoding(Encoding::UTF_8)] =
177
- value.force_encoding(Encoding::UTF_8)
355
+ count -= 1
178
356
  end
179
357
  hash
180
358
  end
@@ -33,6 +33,25 @@ module Leptris::XML::Serialization
33
33
  Leptris::XML::FFI.serialize_into_string(ffi_function, c_ptr, opts.pointer)
34
34
  end
35
35
 
36
+ # Display-form document serialization (libleptris 1.9.9, #129):
37
+ # the ext struct's indent_text hands ALL whitespace to the
38
+ # formatter — text and mixed content indent too. Display-oriented:
39
+ # the output is NOT guaranteed to round-trip byte-exactly.
40
+ INDENT_TEXT_EXT = begin
41
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
42
+ ext[:indent_text] = 1
43
+ ext
44
+ end
45
+ private_constant :INDENT_TEXT_EXT
46
+
47
+ def self.to_xml_display(c_ptr, indent: 0, no_decl: false, encoding: nil)
48
+ opts, encoding_anchor = build_options(
49
+ indent: indent, no_decl: no_decl, encoding: encoding)
50
+ str_ptr = Leptris::XML::FFI.leptris_document_serialize_ext(
51
+ c_ptr, opts.pointer, INDENT_TEXT_EXT.pointer)
52
+ Leptris::XML::FFI.read_owned_string(str_ptr)
53
+ end
54
+
36
55
  # +ffi_function+ is a bound FFI function taking
37
56
  # (c_ptr, version, mode, ns_ptr, flags): leptris_c14n_canonicalize_ex
38
57
  # or leptris_c14n_canonicalize_subtree_ex.
data/lib/libleptris.so CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.33
4
+ version: 1.9.35
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.