leptris 1.9.197.2-arm-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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2646 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. metadata +214 -0
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require "pathname"
5
+ require "stringio"
6
+
7
+ class Leptris::XML::SAX::Parser
8
+ CHUNK_SIZE = 4096
9
+ private_constant :CHUNK_SIZE
10
+
11
+ attr_reader :document, :encoding
12
+
13
+ # streaming: true (default, since 1.9.40) delivers through the
14
+ # engine transports — bulk/callback picked by override weight
15
+ # (leptris-ruby#95's attribute corruption is fixed upstream in
16
+ # libleptris 1.9.18, verified on the issue fixture). streaming:
17
+ # false delivers from a DOM parse (SAX::DomDispatch) — the
18
+ # correctness-first fallback kept for engines older than 1.9.18
19
+ # and for consumers wanting the DOM view's shapes.
20
+ def initialize(handler = Leptris::XML::SAX::Document.new,
21
+ encoding = nil, streaming: true)
22
+ @document = handler
23
+ @encoding = encoding
24
+ @streaming = streaming
25
+ end
26
+
27
+ # Swapping the handler invalidates the memoized callback struct so
28
+ # the next parse dispatches to the new handler.
29
+ def document=(handler)
30
+ @document = handler
31
+ @handler_struct = nil
32
+ @dispatched_kinds = nil
33
+ end
34
+
35
+ # The handler struct (eleven FFI::Function callbacks + the C
36
+ # struct) is built once per handler and reused across parses; the
37
+ # memoized struct is anchored against GC by the instance itself.
38
+ def handler_struct
39
+ @handler_struct ||= build_handler_struct
40
+ end
41
+
42
+ # Parse a string, IO, or file path. Dispatches to parse_memory /
43
+ # parse_io / parse_file based on the argument type.
44
+ def parse(input)
45
+ case input
46
+ when String then parse_memory(input)
47
+ when IO, StringIO, Pathname then parse_io(input)
48
+ else
49
+ raise ArgumentError, "SAX parser expects a String or IO, got #{input.class}"
50
+ end
51
+ end
52
+
53
+ def parse_memory(string)
54
+ # Only re-encode when the input is not already UTF-8 — a
55
+ # dup+force_encoding on a multi-MB document is a full copy
56
+ # before any parse work starts.
57
+ unless string.encoding == Encoding::UTF_8 && string.valid_encoding?
58
+ string = string.dup.force_encoding(Encoding::UTF_8)
59
+ end
60
+ unless @streaming
61
+ Leptris::XML::SAX::DomDispatch.parse(
62
+ @document, dispatched_kinds, string)
63
+ return self
64
+ end
65
+ if bulk_dispatch?
66
+ parse_memory_bulk(string)
67
+ else
68
+ handler_struct = self.handler_struct
69
+ rc = Leptris::XML::FFI.leptris_sax_parse(
70
+ string, string.bytesize, handler_struct.pointer, nil)
71
+ if rc != 0
72
+ raise Leptris::XML::ParseError,
73
+ "leptris_sax_parse failed (rc=#{rc})"
74
+ end
75
+ end
76
+ self
77
+ end
78
+
79
+ # Bulk transport for many-override handlers: record the whole
80
+ # document C-side (~12ms per 250k events), then dispatch through
81
+ # the recorder's drain — 250k ffi callback trampolines cost
82
+ # ~150ms, so this wins outright for handlers that consume most of
83
+ # the event stream (and beats Nokogiri's C extension on the same
84
+ # shape). The delivered call shapes are IDENTICAL to the callback
85
+ # transport (pairs arrays, arity dispatch, UTF-8, PI-data
86
+ # normalization) — the transports are interchangeable to the
87
+ # handler.
88
+ #
89
+ # Transport choice, from the round-XXVI crossover measurements:
90
+ # ONE overridden hot kind pays less through the callbacks (the
91
+ # engine skips C-side emission for unattached kinds entirely:
92
+ # text-only 23ms vs 43ms), while two or more hot kinds win through
93
+ # the recorder (start+chars 138 -> 99ms, start+end+chars 167 ->
94
+ # 122ms vs Nokogiri's 142ms). The weights are document-shape
95
+ # priors — text-heavy documents with tagged structure.
96
+ HOT_KIND_WEIGHTS = {
97
+ characters: 0.6, start_element: 0.2, end_element: 0.2,
98
+ }.freeze
99
+ private_constant :HOT_KIND_WEIGHTS
100
+ DISPATCH_WEIGHT_THRESHOLD = 0.8
101
+ private_constant :DISPATCH_WEIGHT_THRESHOLD
102
+
103
+ def parse_memory_bulk(string)
104
+ recorder = Leptris::XML::SAX::Recorder.open
105
+ begin
106
+ rc = recorder.feed(string, final: true)
107
+ recorder.dispatch(@document, dispatched_kinds)
108
+ if rc != 0
109
+ raise Leptris::XML::ParseError,
110
+ "leptris_sax_parse failed (rc=#{rc})"
111
+ end
112
+ ensure
113
+ recorder.free
114
+ end
115
+ self
116
+ end
117
+
118
+ # The handler's overridden-kind map — the same decisions
119
+ # build_handler_struct makes, in dispatch form. Memoized with the
120
+ # handler struct; document= invalidates both.
121
+ def dispatched_kinds
122
+ @dispatched_kinds ||= begin
123
+ kinds = {}
124
+ {
125
+ start_document: :start_document,
126
+ end_document: :end_document,
127
+ start_element: :start_element,
128
+ end_element: :end_element,
129
+ characters: :characters,
130
+ comment: :comment,
131
+ cdata_block: :cdata,
132
+ processing_instruction: :pi,
133
+ start_prefix_mapping: :start_prefix,
134
+ end_prefix_mapping: :end_prefix,
135
+ error: :error,
136
+ }.each do |method_name, kind|
137
+ next unless overridden?(method_name)
138
+ kinds[kind] =
139
+ if method_name == :start_element &&
140
+ @document.method(:start_element).arity == 1
141
+ :one_arg
142
+ else
143
+ true
144
+ end
145
+ end
146
+ kinds
147
+ end
148
+ end
149
+
150
+ def bulk_dispatch?
151
+ dispatched_kinds.keys.sum do |kind|
152
+ HOT_KIND_WEIGHTS.fetch(kind, 0.0)
153
+ end >= DISPATCH_WEIGHT_THRESHOLD
154
+ end
155
+
156
+ def parse_io(io)
157
+ unless @streaming
158
+ parse_memory(io.read)
159
+ return self
160
+ end
161
+ handler_struct = self.handler_struct
162
+ parser_ptr = Leptris::XML::FFI.leptris_sax_parser_create(
163
+ handler_struct.pointer, nil)
164
+ if parser_ptr.null?
165
+ raise Leptris::XML::Error, "leptris_sax_parser_create failed"
166
+ end
167
+ begin
168
+ while (chunk = io.read(CHUNK_SIZE))
169
+ rc = Leptris::XML::FFI.leptris_sax_parser_feed(
170
+ parser_ptr, chunk, chunk.bytesize, 0)
171
+ if rc != 0
172
+ raise Leptris::XML::ParseError, "leptris_sax_parser_feed failed (rc=#{rc})"
173
+ end
174
+ end
175
+ # Final flush
176
+ rc = Leptris::XML::FFI.leptris_sax_parser_feed(parser_ptr, "", 0, 1)
177
+ if rc != 0
178
+ raise Leptris::XML::ParseError, "leptris_sax_parser_feed (final) failed (rc=#{rc})"
179
+ end
180
+ ensure
181
+ Leptris::XML::FFI.leptris_sax_parser_free(parser_ptr)
182
+ end
183
+ self
184
+ end
185
+
186
+ def parse_file(path)
187
+ if @streaming
188
+ File.open(path, "r") { |f| parse_io(f) }
189
+ else
190
+ parse_memory(File.read(path))
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ # Build a LeptrisSAXHandler struct populated with FFI::Function callbacks
197
+ # that dispatch to the Ruby handler. Only the callbacks the handler
198
+ # actually overrides are attached: the C engine skips NULL callbacks,
199
+ # so cost scales with the handler's declared interest, not with the
200
+ # document's event mix (measured 4.9x for an elements-only handler on
201
+ # a 1.9 MB stream).
202
+ def build_handler_struct
203
+ s = Leptris::XML::FFI::SAXHandler.new
204
+ handler = @document # capture in closures
205
+
206
+ if overridden?(:start_document)
207
+ s[:start_document] = callback(:void, [:pointer]) do |_|
208
+ handler.start_document
209
+ end
210
+ end
211
+
212
+ if overridden?(:end_document)
213
+ s[:end_document] = callback(:void, [:pointer]) do |_|
214
+ handler.end_document
215
+ end
216
+ end
217
+
218
+ if overridden?(:start_element)
219
+ # Arity-declared interest: a start_element that takes exactly
220
+ # one argument declares name-only — the attr walk (2N pointer
221
+ # reads + N string pairs per start; 3.7x of an elements-only
222
+ # parse on the 1.9 MB stream) never runs. It is also the only
223
+ # way a 1-arg handler can receive events: the two-argument
224
+ # call raises ArgumentError.
225
+ if @document.method(:start_element).arity == 1
226
+ s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, _|
227
+ handler.start_element(utf8(name))
228
+ end
229
+ else
230
+ s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, attrs_ptr|
231
+ attrs = walk_attr_array(attrs_ptr)
232
+ handler.start_element(utf8(name), attrs)
233
+ end
234
+ end
235
+ end
236
+
237
+ if overridden?(:end_element)
238
+ s[:end_element] = callback(:void, [:pointer, :string]) do |_, name|
239
+ handler.end_element(utf8(name))
240
+ end
241
+ end
242
+
243
+ if overridden?(:characters)
244
+ s[:characters] = callback(:void, [:pointer, :pointer, :size_t]) do |_, text_ptr, len|
245
+ handler.characters(text_ptr.read_bytes(len).force_encoding(Encoding::UTF_8))
246
+ end
247
+ end
248
+
249
+ if overridden?(:comment)
250
+ s[:comment] = callback(:void, [:pointer, :string]) do |_, comment|
251
+ handler.comment(utf8(comment))
252
+ end
253
+ end
254
+
255
+ if overridden?(:cdata_block)
256
+ s[:cdata] = callback(:void, [:pointer, :string]) do |_, cdata|
257
+ handler.cdata_block(utf8(cdata))
258
+ end
259
+ end
260
+
261
+ if overridden?(:processing_instruction)
262
+ s[:processing_instruction] = callback(:void, [:pointer, :string, :string]) do |_, target, data|
263
+ handler.processing_instruction(
264
+ utf8(target), Leptris::XML::FFI.read_pi_data(utf8(data)))
265
+ end
266
+ end
267
+
268
+ if overridden?(:start_prefix_mapping)
269
+ s[:start_prefix_mapping] = callback(:void, [:pointer, :string, :string]) do |_, prefix, uri|
270
+ handler.start_prefix_mapping(utf8(prefix), utf8(uri))
271
+ end
272
+ end
273
+
274
+ if overridden?(:end_prefix_mapping)
275
+ s[:end_prefix_mapping] = callback(:void, [:pointer, :string]) do |_, prefix|
276
+ handler.end_prefix_mapping(utf8(prefix))
277
+ end
278
+ end
279
+
280
+ if overridden?(:error)
281
+ s[:error] = callback(:void, [:pointer, :string, :int, :int]) do |_, msg, line, col|
282
+ handler.error(utf8(msg), line, col)
283
+ end
284
+ end
285
+
286
+ s
287
+ end
288
+
289
+ # True when the handler defines the event method beyond the no-op
290
+ # every SAX::Document carries — subclasses, included modules, and
291
+ # duck-typed handlers all qualify; a handler that overrides nothing
292
+ # attaches nothing and the parse runs at the C floor.
293
+ def overridden?(name)
294
+ @document.method(name).owner != Leptris::XML::SAX::Document
295
+ rescue NameError
296
+ false
297
+ end
298
+
299
+ def callback(return_type, params, blocking: true, &block)
300
+ ::FFI::Function.new(return_type, params, blocking: blocking, &block)
301
+ end
302
+
303
+ # FFI's implicit :string conversion does not set encoding — every
304
+ # callback string param crosses the seam as ASCII-8BIT unless
305
+ # corrected here (the headers contract UTF-8; nil for NULL).
306
+ def utf8(str)
307
+ str.nil? ? nil : str.force_encoding(Encoding::UTF_8)
308
+ end
309
+
310
+ # The C `const char** attrs` is a NULL-terminated flat array of
311
+ # name/value pairs. Build the [name, value] pairs in one pass —
312
+ # no intermediate flat string array, no each_slice enumerator.
313
+ def walk_attr_array(attrs_ptr)
314
+ ptr_size = ::FFI.type_size(:pointer)
315
+ pairs = []
316
+ i = 0
317
+ loop do
318
+ name_ptr = attrs_ptr.get_pointer(i * ptr_size)
319
+ break if name_ptr.null?
320
+ value_ptr = attrs_ptr.get_pointer((i + 1) * ptr_size)
321
+ pairs << [utf8(name_ptr.read_string), utf8(value_ptr.read_string)]
322
+ i += 2
323
+ end
324
+ pairs
325
+ end
326
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # Chunked event recorder (libleptris 1.9.4, issue #585): a SAX
6
+ # transport that buffers events C-side — fixed LeptrisSaxEventRecord
7
+ # entries plus a packed string arena — and drains them in one bulk
8
+ # read per fed chunk. The handler receives the identical calls the
9
+ # callback API makes; only the transport changes, and the callback
10
+ # count becomes O(chunks), not O(events): through FFI, per-event
11
+ # C-to-Ruby dispatch cost more than the parse itself.
12
+ #
13
+ # Leptris::XML::SAX::Recorder.parse(xml) do |kind, name, text, attrs|
14
+ # # kind: :start_element, :characters, ... (see KINDS)
15
+ # end
16
+ #
17
+ class Leptris::XML::SAX::Recorder
18
+ KINDS = {
19
+ Leptris::XML::FFI::SAX_EVENT_START_DOCUMENT => :start_document,
20
+ Leptris::XML::FFI::SAX_EVENT_END_DOCUMENT => :end_document,
21
+ Leptris::XML::FFI::SAX_EVENT_START_ELEMENT => :start_element,
22
+ Leptris::XML::FFI::SAX_EVENT_END_ELEMENT => :end_element,
23
+ Leptris::XML::FFI::SAX_EVENT_CHARACTERS => :characters,
24
+ Leptris::XML::FFI::SAX_EVENT_COMMENT => :comment,
25
+ Leptris::XML::FFI::SAX_EVENT_CDATA => :cdata,
26
+ Leptris::XML::FFI::SAX_EVENT_PI => :pi,
27
+ Leptris::XML::FFI::SAX_EVENT_START_PREFIX => :start_prefix,
28
+ Leptris::XML::FFI::SAX_EVENT_END_PREFIX => :end_prefix,
29
+ Leptris::XML::FFI::SAX_EVENT_ERROR => :error,
30
+ }.freeze
31
+
32
+ # Kind symbol per code byte — Array indexing in the drain loop,
33
+ # not a Hash lookup per event.
34
+ KIND_BY_CODE = Array.new(KINDS.size) { |code| KINDS[code] }.freeze
35
+
36
+ def self.open
37
+ raw = Leptris::XML::FFI.leptris_sax_recorder_new
38
+ if raw.null?
39
+ raise Leptris::XML::Error, "leptris_sax_recorder_new failed"
40
+ end
41
+ new(raw)
42
+ end
43
+
44
+ # One-shot parse over a complete document, yielding Record structs.
45
+ # +kinds+ filters the drain (see #each_event) — pass e.g.
46
+ # `kinds: [:start_element]` to slice strings only for the events
47
+ # you consume.
48
+ def self.parse(xml_or_io, kinds: nil)
49
+ recorder = open
50
+ begin
51
+ unless xml_or_io.is_a?(String)
52
+ recorder.feed_stream(xml_or_io, kinds: kinds) { |*args| yield(*args) }
53
+ else
54
+ recorder.feed(xml_or_io.to_s, final: true)
55
+ recorder.each_event(*Array(kinds)) { |*args| yield(*args) }
56
+ end
57
+ ensure
58
+ recorder.free
59
+ end
60
+ end
61
+
62
+ def initialize(handle)
63
+ @handle = handle
64
+ end
65
+
66
+ def free
67
+ return if @handle.nil?
68
+ Leptris::XML::FFI.leptris_sax_recorder_free(@handle)
69
+ @handle = nil
70
+ end
71
+
72
+ # Resets a finished recorder for the next document (libleptris
73
+ # 1.9.10, upstream #594): fresh parser state, record/arena
74
+ # buffers retained — a one-document-per-parse loop reuses ONE
75
+ # recorder without new/free churn. Records restart empty.
76
+ def reset
77
+ rc = Leptris::XML::FFI.leptris_sax_recorder_reset(@handle)
78
+ if rc != 0
79
+ raise Leptris::XML::Error, "leptris_sax_recorder_reset failed"
80
+ end
81
+ self
82
+ end
83
+
84
+ # Feed one chunk; records/arena reset at feed entry, so drain
85
+ # after every feed. Returns self.
86
+ def feed(chunk, final: false)
87
+ unless chunk.encoding == Encoding::UTF_8 && chunk.valid_encoding?
88
+ chunk = chunk.dup.force_encoding(Encoding::UTF_8)
89
+ end
90
+ rc = Leptris::XML::FFI.leptris_sax_recorder_feed(
91
+ @handle, chunk, chunk.bytesize, final ? 1 : 0)
92
+ rc
93
+ end
94
+
95
+ # Feed an IO in chunks, draining events after each chunk.
96
+ def feed_stream(io, chunk_size = 65_536, kinds: nil, &block)
97
+ while (chunk = io.read(chunk_size))
98
+ feed(chunk)
99
+ each_event(*Array(kinds), &block)
100
+ end
101
+ feed("", final: true)
102
+ each_event(*Array(kinds), &block)
103
+ end
104
+
105
+ # Which kinds carry each string field (the record layout leaves
106
+ # the other at offset 0 / length 0): slicing a zero-length run
107
+ # allocates a wasted empty String per event — 150k+ of them on a
108
+ # text-heavy document. Unused fields yield nil (the callback
109
+ # transport delivers no name for characters either).
110
+ HAS_NAME = {
111
+ start_element: true, end_element: true,
112
+ pi: true, start_prefix: true, end_prefix: true,
113
+ }.freeze
114
+ HAS_TEXT = {
115
+ characters: true, comment: true, cdata: true,
116
+ pi: true, start_prefix: true, error: true,
117
+ }.freeze
118
+
119
+ # Layout offsets (the struct stays the ABI's single source of
120
+ # truth) and per-count kind-strip templates: one byte per record,
121
+ # skipping the stride — the kind codes are 0..10, so the strip
122
+ # unpacks to cache-resident Fixnums, unlike the old whole-record
123
+ # unpack whose 2.25M-Integer array for a single-feed drain was
124
+ # most of the loop's allocation profile.
125
+ RECORD = Leptris::XML::FFI::SaxEventRecord
126
+ NAME_OFF = RECORD.offset_of(:name_off)
127
+ NAME_LEN = RECORD.offset_of(:name_len)
128
+ TEXT_OFF = RECORD.offset_of(:text_off)
129
+ TEXT_LEN = RECORD.offset_of(:text_len)
130
+ ATTRS_OFF = RECORD.offset_of(:attrs_off)
131
+ ATTR_COUNT = RECORD.offset_of(:attr_count)
132
+ LINE_OFF = RECORD.offset_of(:line)
133
+ COLUMN_OFF = RECORD.offset_of(:column)
134
+ RECORD_STRIDE = RECORD.size
135
+ SAX_EVENT_CHARACTERS_CODE = Leptris::XML::FFI::SAX_EVENT_CHARACTERS
136
+ NUL = "\0".freeze
137
+ KIND_TEMPLATE_FOR = ::Hash.new do |cache, count|
138
+ cache[count] = ("C x#{RECORD_STRIDE - 1}" * count)
139
+ end
140
+
141
+ # Dispatch drain: delivers the recorded events as handler method
142
+ # calls with the CALLBACK transport's exact shapes — pairs arrays
143
+ # (not hashes), one-argument start_element arity dispatch, UTF-8
144
+ # strings, PI-data whitespace normalization. +dispatched+ maps
145
+ # kind symbols to true for the kinds the handler overrides (the
146
+ # attach-only-overridden policy carried onto the bulk transport:
147
+ # other records cost one strip read). The C recorder costs ~12ms
148
+ # per 250k-event document while 250k ffi callback trampolines
149
+ # cost ~150ms — this is how a many-override handler beats the
150
+ # callback transport (and Nokogiri's C extension) outright.
151
+ def dispatch(handler, dispatched)
152
+ count_ptr = ::FFI::MemoryPointer.new(:size_t)
153
+ len_ptr = ::FFI::MemoryPointer.new(:size_t)
154
+ begin
155
+ records_ptr = Leptris::XML::FFI.leptris_sax_recorder_records(
156
+ @handle, count_ptr)
157
+ return self if records_ptr.null? || count_ptr.read_uint64.zero?
158
+ arena_ptr = Leptris::XML::FFI.leptris_sax_recorder_arena(
159
+ @handle, len_ptr)
160
+ arena = arena_ptr.read_bytes(len_ptr.read_uint64)
161
+ count = count_ptr.read_uint64
162
+ # Kind strip in ONE bulk read + ONE unpack per drain: the
163
+ # per-count template reads the first byte of each record,
164
+ # skipping the stride (KIND_TEMPLATE_FOR is memoized per
165
+ # count). Kind codes are 0..10 — unpack yields immediate
166
+ # Fixnums, so the strip allocates nothing. This replaces a
167
+ # get_uint8 crossing per EVENT (all events, dispatched or
168
+ # not) with one memory read per drain — the SAX gap to
169
+ # Nokogiri's per-event C callback lives exactly here.
170
+ kinds = records_ptr.get_bytes(0, count * RECORD_STRIDE)
171
+ .unpack(KIND_TEMPLATE_FOR[count])
172
+ one_arg_start = dispatched[:start_element] == :one_arg
173
+ i = 0
174
+ while i < count
175
+ kind = kinds[i] && KIND_BY_CODE[kinds[i]]
176
+ if kind && dispatched[kind]
177
+ base = i * RECORD_STRIDE
178
+ case kind
179
+ when :characters
180
+ handler.characters(slice(arena,
181
+ records_ptr.get_uint32(base + TEXT_OFF),
182
+ records_ptr.get_uint32(base + TEXT_LEN)))
183
+ when :start_element
184
+ name = slice(arena,
185
+ records_ptr.get_uint32(base + NAME_OFF),
186
+ records_ptr.get_uint32(base + NAME_LEN))
187
+ if one_arg_start
188
+ handler.start_element(name)
189
+ else
190
+ handler.start_element(name, attrs_pairs(arena,
191
+ records_ptr.get_uint32(base + ATTRS_OFF),
192
+ records_ptr.get_uint32(base + ATTR_COUNT)))
193
+ end
194
+ when :end_element
195
+ handler.end_element(slice(arena,
196
+ records_ptr.get_uint32(base + NAME_OFF),
197
+ records_ptr.get_uint32(base + NAME_LEN)))
198
+ when :comment
199
+ handler.comment(slice(arena,
200
+ records_ptr.get_uint32(base + TEXT_OFF),
201
+ records_ptr.get_uint32(base + TEXT_LEN)))
202
+ when :cdata
203
+ handler.cdata_block(slice(arena,
204
+ records_ptr.get_uint32(base + TEXT_OFF),
205
+ records_ptr.get_uint32(base + TEXT_LEN)))
206
+ when :pi
207
+ handler.processing_instruction(slice(arena,
208
+ records_ptr.get_uint32(base + NAME_OFF),
209
+ records_ptr.get_uint32(base + NAME_LEN)),
210
+ Leptris::XML::FFI.read_pi_data(slice(arena,
211
+ records_ptr.get_uint32(base + TEXT_OFF),
212
+ records_ptr.get_uint32(base + TEXT_LEN))))
213
+ when :start_prefix
214
+ handler.start_prefix_mapping(slice(arena,
215
+ records_ptr.get_uint32(base + NAME_OFF),
216
+ records_ptr.get_uint32(base + NAME_LEN)),
217
+ slice(arena,
218
+ records_ptr.get_uint32(base + TEXT_OFF),
219
+ records_ptr.get_uint32(base + TEXT_LEN)))
220
+ when :end_prefix
221
+ handler.end_prefix_mapping(slice(arena,
222
+ records_ptr.get_uint32(base + NAME_OFF),
223
+ records_ptr.get_uint32(base + NAME_LEN)))
224
+ when :error
225
+ handler.error(slice(arena,
226
+ records_ptr.get_uint32(base + TEXT_OFF),
227
+ records_ptr.get_uint32(base + TEXT_LEN)),
228
+ records_ptr.get_uint32(base + LINE_OFF),
229
+ records_ptr.get_uint32(base + COLUMN_OFF))
230
+ when :start_document
231
+ handler.start_document
232
+ when :end_document
233
+ handler.end_document
234
+ end
235
+ end
236
+ i += 1
237
+ end
238
+ ensure
239
+ count_ptr.free
240
+ len_ptr.free
241
+ end
242
+ self
243
+ end
244
+
245
+ # attrs as [name, value] pairs in source order — the callback
246
+ # transport's shape (walk_attr_array).
247
+ def attrs_pairs(arena, off, count)
248
+ return [] if count.zero?
249
+ pairs = []
250
+ pos = off
251
+ nul = NUL
252
+ utf8 = Encoding::UTF_8
253
+ while count > 0
254
+ name_end = arena.index(nul, pos)
255
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
256
+ pos = name_end + 1
257
+ value_end = arena.index(nul, pos)
258
+ pairs << [name,
259
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)]
260
+ pos = value_end + 1
261
+ count -= 1
262
+ end
263
+ pairs
264
+ end
265
+
266
+ # Drains the current chunk's records: bulk-reads the record array
267
+ # and the packed arena (two FFI calls), then slices every string
268
+ # from the arena in Ruby — no per-string FFI. With +kinds+, records
269
+ # of other kinds are skipped BEFORE any string is sliced: an
270
+ # unwanted event costs one Array read, and cost scales with what
271
+ # the consumer asked for, not with the document's event mix.
272
+ def each_event(*kinds)
273
+ wanted = kinds.empty? ? nil : wanted_set(kinds)
274
+ count_ptr = ::FFI::MemoryPointer.new(:size_t)
275
+ len_ptr = ::FFI::MemoryPointer.new(:size_t)
276
+ begin
277
+ records_ptr = Leptris::XML::FFI.leptris_sax_recorder_records(
278
+ @handle, count_ptr)
279
+ return self if records_ptr.null? || count_ptr.read_uint64.zero?
280
+
281
+ arena_ptr = Leptris::XML::FFI.leptris_sax_recorder_arena(
282
+ @handle, len_ptr)
283
+ arena = arena_ptr.read_bytes(len_ptr.read_uint64)
284
+ count = count_ptr.read_uint64
285
+
286
+ # Kind is the first byte of each record — read off the C
287
+ # pointer. The previous path copied every record and unpacked
288
+ # a Fixnum array just for the kind strip.
289
+ has_name = HAS_NAME
290
+ has_text = HAS_TEXT
291
+ drain = wanted.nil?
292
+ i = 0
293
+ while i < count
294
+ base = i * RECORD_STRIDE
295
+ code = records_ptr.get_uint8(base)
296
+ if code == SAX_EVENT_CHARACTERS_CODE && drain
297
+ # The dominant branch (text-heavy documents): no name, no
298
+ # attrs, no per-kind gate lookups — kind, text, position.
299
+ yield :characters, nil,
300
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
301
+ records_ptr.get_uint32(base + TEXT_LEN)),
302
+ nil,
303
+ records_ptr.get_uint32(base + LINE_OFF),
304
+ records_ptr.get_uint32(base + COLUMN_OFF)
305
+ else
306
+ kind = KIND_BY_CODE[code]
307
+ if kind && (drain || wanted[kind])
308
+ name = if has_name[kind]
309
+ slice(arena, records_ptr.get_uint32(base + NAME_OFF),
310
+ records_ptr.get_uint32(base + NAME_LEN))
311
+ end
312
+ text = if has_text[kind]
313
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
314
+ records_ptr.get_uint32(base + TEXT_LEN))
315
+ end
316
+ text = Leptris::XML::FFI.read_pi_data(text) if kind == :pi
317
+ yield kind, name, text,
318
+ attrs_from(arena, records_ptr.get_uint32(base + ATTRS_OFF),
319
+ records_ptr.get_uint32(base + ATTR_COUNT)),
320
+ records_ptr.get_uint32(base + LINE_OFF),
321
+ records_ptr.get_uint32(base + COLUMN_OFF)
322
+ end
323
+ end
324
+ i += 1
325
+ end
326
+ ensure
327
+ count_ptr.free
328
+ len_ptr.free
329
+ end
330
+ self
331
+ end
332
+
333
+ private
334
+
335
+ def wanted_set(kinds)
336
+ unknown = kinds.each_with_object([]) do |kind, acc|
337
+ acc << kind unless KINDS.value?(kind)
338
+ end
339
+ unless unknown.empty?
340
+ raise ArgumentError,
341
+ "unknown event kinds #{unknown.inspect} " \
342
+ "(known: #{KINDS.values.inspect})"
343
+ end
344
+ kinds.each_with_object({}) { |kind, set| set[kind] = true }
345
+ end
346
+
347
+ # The default-namespace prefix is legitimately "" (START_PREFIX:
348
+ # name may be zero-length) — zero-length slices stay "" where the
349
+ # field is carried (unused fields never reach here).
350
+ def slice(arena, off, len)
351
+ arena.byteslice(off, len).force_encoding(Encoding::UTF_8)
352
+ end
353
+
354
+ # attrs_off addresses name\0value\0... pairs, attr_count pairs;
355
+ # nil when the element carries none.
356
+ def attrs_from(arena, off, count)
357
+ return nil if count.zero?
358
+ hash = {}
359
+ pos = off
360
+ nul = NUL
361
+ utf8 = Encoding::UTF_8
362
+ while count > 0
363
+ name_end = arena.index(nul, pos)
364
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
365
+ pos = name_end + 1
366
+ value_end = arena.index(nul, pos)
367
+ hash[name] =
368
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)
369
+ pos = value_end + 1
370
+ count -= 1
371
+ end
372
+ hash
373
+ end
374
+ end