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 +4 -4
- data/CHANGELOG.md +69 -0
- data/Rakefile +1 -1
- data/lib/leptris/version.rb +1 -1
- data/lib/leptris/xml/document.rb +27 -1
- data/lib/leptris/xml/element.rb +18 -3
- data/lib/leptris/xml/ffi.rb +16 -0
- data/lib/leptris/xml/node.rb +2 -6
- data/lib/leptris/xml/processing_instruction.rb +45 -21
- data/lib/leptris/xml/sax/parser.rb +88 -6
- data/lib/leptris/xml/sax/recorder.rb +206 -28
- data/lib/leptris/xml/serialization.rb +19 -0
- data/lib/libleptris.so +0 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5d2cc0c763678c71c93628564323985c5e55f27ea126aa9f003319e4a7a7524
|
|
4
|
+
data.tar.gz: 8545c65e4579fe342f87938cf4f24f63dd02b7900bbf4bad7e040934a35b750e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
11
|
+
LIBLEPTRIS_VERSION = "1.9.9"
|
|
12
12
|
|
|
13
13
|
CMAKE_FLAGS = %w[
|
|
14
14
|
-DCMAKE_BUILD_TYPE=Release
|
data/lib/leptris/version.rb
CHANGED
data/lib/leptris/xml/document.rb
CHANGED
|
@@ -266,9 +266,16 @@ class Leptris::XML::Document
|
|
|
266
266
|
end
|
|
267
267
|
alias_method :internal_subset, :doctype
|
|
268
268
|
|
|
269
|
-
|
|
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(
|
data/lib/leptris/xml/element.rb
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
data/lib/leptris/xml/ffi.rb
CHANGED
|
@@ -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,
|
data/lib/leptris/xml/node.rb
CHANGED
|
@@ -263,12 +263,8 @@ class Leptris::XML::Node
|
|
|
263
263
|
|
|
264
264
|
def unlink
|
|
265
265
|
ensure_writable!
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
yield
|
|
128
|
-
slice(arena,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
174
|
-
|
|
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
|
-
|
|
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
|