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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3890 -0
- data/CLAUDE.md +115 -0
- data/CONTEXT.md +39 -0
- data/LICENSE.md +33 -0
- data/README.adoc +755 -0
- data/Rakefile +407 -0
- data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
- data/TODO.perf/02-auto-enable-native-layer.md +19 -0
- data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
- data/TODO.perf/04-ext-bound-serialization.md +15 -0
- data/TODO.perf/05-native-surface-completion.md +19 -0
- data/TODO.perf/06-perf-battery-native-legs.md +16 -0
- data/TODO.perf/07-native-read-floor.md +31 -0
- data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
- data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
- data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
- data/TODO.perf/11-moxml-gap-battery.md +18 -0
- data/TODO.perf/12-document-lifetime-in-c.md +47 -0
- data/TODO.perf/13-structural-memos.md +49 -0
- data/TODO.perf/14-insert-family-c-bound.md +31 -0
- data/TODO.perf/15-compiled-expression-cache.md +35 -0
- data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
- data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
- data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
- data/TODO.perf/19-lazy-node-pointer.md +39 -0
- data/TODO.perf/20-css-translation-cache.md +24 -0
- data/TODO.perf/21-key-memo-consult.md +20 -0
- data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
- data/TODO.perf/23-cbound-value-mutations.md +27 -0
- data/TODO.perf/24-immutable-read-lanes.md +22 -0
- data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
- data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
- data/TODO.perf/27-c-yield-traversal.md +38 -0
- data/TODO.perf/28-address-fills.md +25 -0
- data/TODO.perf/29-ns-xpath-compiled.md +23 -0
- data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
- data/TODO.perf/31-battery-rounds-4-8.md +23 -0
- data/TODO.perf/32-clean-host-floor-table.md +29 -0
- data/TODO.perf/33-cbound-root-set.md +29 -0
- data/TODO.perf/34-fragment-fast-lane.md +41 -0
- data/TODO.perf/35-parse-default-c-face.md +24 -0
- data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
- data/TODO.perf/37-document-lazy-pointer.md +28 -0
- data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
- data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
- data/TODO.restructure/02-deep-copy-seam.md +21 -0
- data/TODO.restructure/03-evaluation-context-seam.md +18 -0
- data/TODO.restructure/04-spec-mece-restructure.md +20 -0
- data/TODO.restructure/05-memory-documentation.md +13 -0
- data/TODO.restructure/06-architecture-map-refresh.md +12 -0
- data/TODO.restructure/07-external-gates.md +16 -0
- data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
- data/TODO.restructure/09-iteration-scope.md +29 -0
- data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
- data/TODO.restructure/11-construction-factory-diet.md +16 -0
- data/TODO.restructure/12-resultattr-merge.md +12 -0
- data/TODO.restructure/13-spec-mece-followup.md +9 -0
- data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
- data/TODO.restructure/15-readme-feature-documentation.md +10 -0
- data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
- data/TODO.restructure/17-perf-drift-record.md +12 -0
- data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
- data/TODO.restructure/19-gitignore-hygiene.md +10 -0
- data/TODO.restructure/20-utf8proc-enablement.md +34 -0
- data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
- data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
- data/docs/adr/0001-lockstep-mirror.md +17 -0
- data/docs/adr/0002-utf8-at-the-seam.md +19 -0
- data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
- data/docs/adr/0004-lifetime-guard.md +21 -0
- data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
- data/docs/adr/0006-ruby-variant-policy.md +25 -0
- data/ext/build_windows_native.rb +42 -0
- data/ext/leptris/native/extconf.rb +55 -0
- data/ext/leptris/native/native.c +2646 -0
- data/leptris.gemspec +43 -0
- data/lib/leptris/version.rb +5 -0
- data/lib/leptris/xml/attr.rb +76 -0
- data/lib/leptris/xml/c_string_array.rb +37 -0
- data/lib/leptris/xml/cdata.rb +27 -0
- data/lib/leptris/xml/comment.rb +27 -0
- data/lib/leptris/xml/css_to_xpath.rb +192 -0
- data/lib/leptris/xml/descriptor.rb +214 -0
- data/lib/leptris/xml/diff.rb +74 -0
- data/lib/leptris/xml/doc_type.rb +54 -0
- data/lib/leptris/xml/document.rb +716 -0
- data/lib/leptris/xml/document_fragment.rb +63 -0
- data/lib/leptris/xml/element.rb +771 -0
- data/lib/leptris/xml/entity_reference.rb +13 -0
- data/lib/leptris/xml/evaluation_context.rb +32 -0
- data/lib/leptris/xml/ffi.rb +1527 -0
- data/lib/leptris/xml/iteration_scope.rb +56 -0
- data/lib/leptris/xml/iterparse.rb +144 -0
- data/lib/leptris/xml/namespace.rb +43 -0
- data/lib/leptris/xml/native.so +0 -0
- data/lib/leptris/xml/native_layer.rb +92 -0
- data/lib/leptris/xml/node.rb +686 -0
- data/lib/leptris/xml/node_set.rb +212 -0
- data/lib/leptris/xml/parse_options.rb +97 -0
- data/lib/leptris/xml/plan_value.rb +121 -0
- data/lib/leptris/xml/processing_instruction.rb +90 -0
- data/lib/leptris/xml/pull.rb +212 -0
- data/lib/leptris/xml/relaxng.rb +126 -0
- data/lib/leptris/xml/result_attr.rb +39 -0
- data/lib/leptris/xml/result_text.rb +36 -0
- data/lib/leptris/xml/sax/document.rb +45 -0
- data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
- data/lib/leptris/xml/sax/parser.rb +326 -0
- data/lib/leptris/xml/sax/recorder.rb +374 -0
- data/lib/leptris/xml/sax.rb +14 -0
- data/lib/leptris/xml/schematron.rb +88 -0
- data/lib/leptris/xml/searchable.rb +361 -0
- data/lib/leptris/xml/serialization.rb +180 -0
- data/lib/leptris/xml/text.rb +32 -0
- data/lib/leptris/xml/xpath.rb +89 -0
- data/lib/leptris/xml/xquery.rb +77 -0
- data/lib/leptris/xml/xslt.rb +85 -0
- data/lib/leptris/xml.rb +108 -0
- data/lib/leptris.rb +53 -0
- data/lib/libleptris.so +0 -0
- data/lib/libutf8proc.so.3 +0 -0
- data/scripts/gem_smoke.rb +36 -0
- 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
|