leptris 1.9.3 → 1.9.5
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 +56 -0
- data/README.adoc +11 -0
- data/leptris.gemspec +2 -1
- data/lib/leptris/version.rb +1 -1
- data/lib/leptris/xml/cdata.rb +1 -0
- data/lib/leptris/xml/comment.rb +1 -0
- data/lib/leptris/xml/document.rb +6 -0
- data/lib/leptris/xml/element.rb +12 -1
- data/lib/leptris/xml/iterparse.rb +3 -0
- data/lib/leptris/xml/node.rb +27 -2
- data/lib/leptris/xml/node_set.rb +8 -10
- data/lib/leptris/xml/processing_instruction.rb +2 -0
- data/lib/leptris/xml/sax/parser.rb +34 -15
- data/lib/leptris/xml/searchable.rb +4 -1
- data/lib/leptris/xml/text.rb +1 -0
- metadata +1 -5
- data/.rspec +0 -3
- data/.rubocop.yml +0 -8
- data/benchmark/README.md +0 -12
- data/benchmark/leptris_vs_nokogiri.rb +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 996051e28883434c5ac9cf2041278a37a0ebc3ad628685838e6b743cc87e0b9d
|
|
4
|
+
data.tar.gz: 3909cce576ad3ce14fa65c4058997476ddd814ea9f819e00e0b4bbb969bd09cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61f73189528f9c0ffaff803328304baa44af1e545d6618d252ff8e46b5279a229fb204df807815a22a360bffbf107a0c32e1a33f5110cb699ae8135e06d2777e
|
|
7
|
+
data.tar.gz: 4b5b197e569e23820b74b1596b3a8f8bf2feefba09af9858bb830cbcd6339d59c196e71177760ab35eb7daeac30bd5c3edb2b384d1ff43fc56726b838bfe0f0a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,62 @@ 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.5] - 2026-08-26
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Borrowed handles validate their lender**: every c_ptr-
|
|
13
|
+
dereferencing read and mutation on Node/Element/Text/Comment/
|
|
14
|
+
CDATA/PI raises `Leptris::XML::UseAfterFreeError` once the owning
|
|
15
|
+
document has been freed (previously `doc.free; node.name` read
|
|
16
|
+
freed memory silently — verified returning `""` — and stale
|
|
17
|
+
mutations risked segfaults; only Document's own methods checked).
|
|
18
|
+
Memoized readonly results stay check-free. `Document#freed?` is
|
|
19
|
+
the single state predicate.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`benchmark/read_paths.rb`**: the committed read-path harness
|
|
24
|
+
(readonly loops, SAX, NodeSet unions, css, parse-query-serialize)
|
|
25
|
+
with a README methodology note — review-round benchmarks are now
|
|
26
|
+
reproducible from the repo.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The published gem no longer ships dev tooling (`benchmark/`,
|
|
31
|
+
`.rspec`, `.rubocop.yml`); it carries lib, gemspec, Rakefile, and
|
|
32
|
+
documentation only.
|
|
33
|
+
|
|
34
|
+
## [1.9.4] - 2026-08-25
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **SAX callback strings arrive UTF-8**: element names, comments,
|
|
39
|
+
CDATA, PI target/data, prefix mappings, and error messages now
|
|
40
|
+
cross the seam as UTF-8 (previously ASCII-8BIT — only `characters`
|
|
41
|
+
was corrected). Non-ASCII SAX content no longer leaks BINARY into
|
|
42
|
+
handler code.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **NodeSet#xpath de-churn**: search arguments parse once (not per
|
|
47
|
+
member), results accumulate into one plain array, and a single
|
|
48
|
+
NodeSet is constructed at the end — the per-member
|
|
49
|
+
`merge_node_sets` (two `to_a`s + a throwaway NodeSet per element)
|
|
50
|
+
is gone. ~16% on a 500-member union query. Batch-context eval
|
|
51
|
+
asked upstream (leptris/leptris#560).
|
|
52
|
+
- **SAX handler struct memoized** per handler; `Parser#document=`
|
|
53
|
+
invalidates it, so swapping the handler takes effect on the next
|
|
54
|
+
parse (previously the wiring was rebuilt per parse and the
|
|
55
|
+
struct ↔ handler coupling was only implied).
|
|
56
|
+
- **XPath variable bindings now raise ArgumentError** instead of
|
|
57
|
+
being silently ignored (no var-bound eval path exists; wire the
|
|
58
|
+
real thing when a use case arrives). A single trailing hash
|
|
59
|
+
remains the namespace hash (Nokogiri semantics).
|
|
60
|
+
- Failed XPath evaluations raise `XPathError` with the engine's
|
|
61
|
+
last-error detail from both the ad-hoc and compiled paths
|
|
62
|
+
(previously the ad-hoc path used the generic status string).
|
|
63
|
+
|
|
8
64
|
## [1.9.3] - 2026-08-25
|
|
9
65
|
|
|
10
66
|
### Fixed
|
data/README.adoc
CHANGED
|
@@ -411,6 +411,17 @@ Notable differences:
|
|
|
411
411
|
|
|
412
412
|
== Performance
|
|
413
413
|
|
|
414
|
+
=== Internal read-path harness
|
|
415
|
+
|
|
416
|
+
`benchmark/read_paths.rb` measures the binding's own hot paths
|
|
417
|
+
(readonly read loops, SAX, NodeSet unions, css, parse-query-
|
|
418
|
+
serialize). Machine-relative — compare before/after runs on the same
|
|
419
|
+
machine:
|
|
420
|
+
|
|
421
|
+
bundle exec ruby benchmark/read_paths.rb
|
|
422
|
+
|
|
423
|
+
=== Full-field comparison
|
|
424
|
+
|
|
414
425
|
Measured with metanorma/serialbench (Ruby 3.4.8, leptris 1.6.0,
|
|
415
426
|
libleptris 1.6.0, macOS arm64) — Leptris vs the full field:
|
|
416
427
|
|
data/leptris.gemspec
CHANGED
|
@@ -28,7 +28,8 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.files = Dir.chdir(__dir__) do
|
|
29
29
|
`git ls-files -z`.split("\x0").reject do |f|
|
|
30
30
|
(File.expand_path(f) == __FILE__) ||
|
|
31
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github
|
|
31
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ benchmark/ .git .github
|
|
32
|
+
appveyor Gemfile .rspec .rubocop.yml])
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
spec.bindir = "bin"
|
data/lib/leptris/version.rb
CHANGED
data/lib/leptris/xml/cdata.rb
CHANGED
data/lib/leptris/xml/comment.rb
CHANGED
data/lib/leptris/xml/document.rb
CHANGED
|
@@ -281,6 +281,12 @@ class Leptris::XML::Document
|
|
|
281
281
|
@readonly == true
|
|
282
282
|
end
|
|
283
283
|
|
|
284
|
+
# True once #free has run (or the GC finalizer fired) — borrowed
|
|
285
|
+
# handles check this before dereferencing their c_ptr.
|
|
286
|
+
def freed?
|
|
287
|
+
@freed.state == :freed || @c_ptr.nil?
|
|
288
|
+
end
|
|
289
|
+
|
|
284
290
|
# The most recent error recorded against this document, or nil.
|
|
285
291
|
def last_error
|
|
286
292
|
msg = Leptris::XML::FFI.leptris_document_last_error(@c_ptr)
|
data/lib/leptris/xml/element.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class Leptris::XML::Element < Leptris::XML::Node
|
|
4
4
|
def name
|
|
5
|
-
@name
|
|
5
|
+
return @name if @name
|
|
6
|
+
ensure_alive!
|
|
7
|
+
@name = Leptris::XML::FFI.leptris_element_name(@c_ptr)
|
|
6
8
|
end
|
|
7
9
|
alias_method :node_name, :name
|
|
8
10
|
|
|
@@ -16,6 +18,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
16
18
|
|
|
17
19
|
def content
|
|
18
20
|
return @content if readonly_cached?(:@content)
|
|
21
|
+
ensure_alive!
|
|
19
22
|
Leptris::XML::FFI.leptris_element_text(@c_ptr).tap do |text|
|
|
20
23
|
@content = text if @document&.readonly?
|
|
21
24
|
end
|
|
@@ -29,6 +32,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def [](key)
|
|
35
|
+
ensure_alive!
|
|
32
36
|
Leptris::XML::FFI.leptris_element_attribute(@c_ptr, key.to_s)
|
|
33
37
|
end
|
|
34
38
|
alias_method :attr, :[]
|
|
@@ -43,6 +47,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
43
47
|
alias_method :set_attribute, :[]=
|
|
44
48
|
|
|
45
49
|
def key?(name)
|
|
50
|
+
ensure_alive!
|
|
46
51
|
Leptris::XML::FFI.leptris_element_has_attribute(@c_ptr, name.to_s) != 0
|
|
47
52
|
end
|
|
48
53
|
alias_method :has_attribute?, :key?
|
|
@@ -53,11 +58,13 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
53
58
|
# in-scope declarations. uri nil/"" matches no-namespace
|
|
54
59
|
# attributes only; xmlns declarations are invisible.
|
|
55
60
|
def attribute_ns(uri, local)
|
|
61
|
+
ensure_alive!
|
|
56
62
|
Leptris::XML::FFI.leptris_element_attribute_ns(
|
|
57
63
|
@c_ptr, uri&.to_s, local.to_s)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
def has_attribute_ns?(uri, local)
|
|
67
|
+
ensure_alive!
|
|
61
68
|
Leptris::XML::FFI.leptris_element_has_attribute_ns(
|
|
62
69
|
@c_ptr, uri&.to_s, local.to_s) != 0
|
|
63
70
|
end
|
|
@@ -77,6 +84,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
77
84
|
# per-attribute namespace accessors (libleptris 1.8.0).
|
|
78
85
|
def each_attribute
|
|
79
86
|
return enum_for(:each_attribute) unless block_given?
|
|
87
|
+
ensure_alive!
|
|
80
88
|
attr = Leptris::XML::FFI.leptris_element_first_attribute(@c_ptr)
|
|
81
89
|
until attr.nil? || attr.null?
|
|
82
90
|
name = Leptris::XML::FFI.leptris_attribute_get_name(attr)
|
|
@@ -119,6 +127,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
119
127
|
# The element's own namespace prefix (e.g. "foo" for <foo:child/>),
|
|
120
128
|
# or nil when the element has none.
|
|
121
129
|
def prefix
|
|
130
|
+
ensure_alive!
|
|
122
131
|
Leptris::XML::FFI.leptris_element_prefix(@c_ptr)
|
|
123
132
|
end
|
|
124
133
|
|
|
@@ -231,6 +240,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
231
240
|
|
|
232
241
|
def namespace
|
|
233
242
|
return @namespace if readonly_cached?(:@namespace)
|
|
243
|
+
ensure_alive!
|
|
234
244
|
uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
|
|
235
245
|
result =
|
|
236
246
|
if uri.nil? || uri.empty?
|
|
@@ -249,6 +259,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
249
259
|
|
|
250
260
|
def namespace_definitions
|
|
251
261
|
return @namespace_definitions if readonly_cached?(:@namespace_definitions)
|
|
262
|
+
ensure_alive!
|
|
252
263
|
count = Leptris::XML::FFI.leptris_element_namespace_count(@c_ptr)
|
|
253
264
|
result = count.times.map do |i|
|
|
254
265
|
prefix = Leptris::XML::FFI.leptris_element_namespace_decl_prefix(@c_ptr, i)
|
|
@@ -18,6 +18,9 @@ require "ffi"
|
|
|
18
18
|
# Document; #document returns nil and they must not outlive the
|
|
19
19
|
# iteration.
|
|
20
20
|
class Leptris::XML::Iterparse
|
|
21
|
+
# NOTE: an IO argument is read fully into memory before the C
|
|
22
|
+
# iterator starts — the bounded-memory path is parse_file (C-side
|
|
23
|
+
# file streaming). The C API takes one (xml, len) buffer.
|
|
21
24
|
def self.parse(xml_or_io, &block)
|
|
22
25
|
xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
|
|
23
26
|
iterator = new(Leptris::XML::FFI.leptris_iterparse_new(xml, xml.bytesize))
|
data/lib/leptris/xml/node.rb
CHANGED
|
@@ -62,7 +62,9 @@ class Leptris::XML::Node
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def type
|
|
65
|
-
@node_type
|
|
65
|
+
return @node_type if @node_type
|
|
66
|
+
ensure_alive!
|
|
67
|
+
@node_type = Leptris::XML::FFI.leptris_node_get_type(@c_ptr)
|
|
66
68
|
end
|
|
67
69
|
alias_method :node_type, :type
|
|
68
70
|
|
|
@@ -77,13 +79,26 @@ class Leptris::XML::Node
|
|
|
77
79
|
|
|
78
80
|
def parent
|
|
79
81
|
return @parent if @parent
|
|
82
|
+
ensure_alive!
|
|
80
83
|
ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
|
|
81
84
|
return nil if ptr.null?
|
|
82
85
|
Leptris::XML::Node.wrap(ptr, @document)
|
|
83
86
|
end
|
|
84
87
|
|
|
85
|
-
#
|
|
88
|
+
# Borrowed-handle lifetime: every c_ptr dereference is valid only
|
|
89
|
+
# while the owning document lives. Parentless nodes (iterparse
|
|
90
|
+
# yields) cannot validate and are skipped.
|
|
91
|
+
def ensure_alive!
|
|
92
|
+
if @document&.freed?
|
|
93
|
+
raise Leptris::XML::UseAfterFreeError,
|
|
94
|
+
"owning document has been freed — handle used on #{inspect}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Raises ReadOnlyError when the owning document was marked readonly,
|
|
99
|
+
# UseAfterFreeError when it was freed.
|
|
86
100
|
def ensure_writable!
|
|
101
|
+
ensure_alive!
|
|
87
102
|
if @document&.readonly?
|
|
88
103
|
raise Leptris::XML::ReadOnlyError,
|
|
89
104
|
"document is readonly — mutation attempted on #{inspect}"
|
|
@@ -91,16 +106,19 @@ class Leptris::XML::Node
|
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
def line
|
|
109
|
+
ensure_alive!
|
|
94
110
|
Leptris::XML::FFI.leptris_node_line(@c_ptr)
|
|
95
111
|
end
|
|
96
112
|
|
|
97
113
|
def <=>(other)
|
|
98
114
|
return nil unless other.is_a?(Leptris::XML::Node)
|
|
99
115
|
return nil unless @document == other.document
|
|
116
|
+
ensure_alive!
|
|
100
117
|
Leptris::XML::FFI.leptris_node_compare(@c_ptr, other.c_ptr)
|
|
101
118
|
end
|
|
102
119
|
|
|
103
120
|
def child
|
|
121
|
+
ensure_alive!
|
|
104
122
|
ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
|
|
105
123
|
return nil if ptr.null?
|
|
106
124
|
Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
|
|
@@ -110,6 +128,7 @@ class Leptris::XML::Node
|
|
|
110
128
|
# Immutable in readonly mode: the batch fetch plus wrapper
|
|
111
129
|
# construction is paid once.
|
|
112
130
|
return @children if readonly_cached?(:@children)
|
|
131
|
+
ensure_alive!
|
|
113
132
|
parent = as_element_or_self
|
|
114
133
|
nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
|
|
115
134
|
Leptris::XML::Node.wrap(ptr, @document, parent: parent)
|
|
@@ -120,6 +139,7 @@ class Leptris::XML::Node
|
|
|
120
139
|
end
|
|
121
140
|
|
|
122
141
|
def next_sibling
|
|
142
|
+
ensure_alive!
|
|
123
143
|
ptr = Leptris::XML::FFI.leptris_node_next_sibling(@c_ptr)
|
|
124
144
|
return nil if ptr.null?
|
|
125
145
|
Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
|
|
@@ -127,6 +147,7 @@ class Leptris::XML::Node
|
|
|
127
147
|
alias_method :next, :next_sibling
|
|
128
148
|
|
|
129
149
|
def previous_sibling
|
|
150
|
+
ensure_alive!
|
|
130
151
|
ptr = Leptris::XML::FFI.leptris_node_previous_sibling(@c_ptr)
|
|
131
152
|
return nil if ptr.null?
|
|
132
153
|
Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
|
|
@@ -134,6 +155,7 @@ class Leptris::XML::Node
|
|
|
134
155
|
alias_method :previous, :previous_sibling
|
|
135
156
|
|
|
136
157
|
def first_element_child
|
|
158
|
+
ensure_alive!
|
|
137
159
|
ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
|
|
138
160
|
until ptr.nil? || ptr.null?
|
|
139
161
|
node = Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
|
|
@@ -189,6 +211,7 @@ class Leptris::XML::Node
|
|
|
189
211
|
# callback (libleptris #273); the per-node FFI cost is the floor.
|
|
190
212
|
def traverse
|
|
191
213
|
return enum_for(:traverse) unless block_given?
|
|
214
|
+
ensure_alive!
|
|
192
215
|
callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
|
|
193
216
|
yield Leptris::XML::Node.wrap(node_ptr, @document)
|
|
194
217
|
0
|
|
@@ -199,6 +222,7 @@ class Leptris::XML::Node
|
|
|
199
222
|
|
|
200
223
|
def path
|
|
201
224
|
return @path if readonly_cached?(:@path)
|
|
225
|
+
ensure_alive!
|
|
202
226
|
str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
|
|
203
227
|
result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
|
|
204
228
|
@path = result if @document&.readonly?
|
|
@@ -221,6 +245,7 @@ class Leptris::XML::Node
|
|
|
221
245
|
end
|
|
222
246
|
|
|
223
247
|
def dup
|
|
248
|
+
ensure_alive!
|
|
224
249
|
elem_ptr = Leptris::XML::FFI.leptris_node_as_element(@c_ptr)
|
|
225
250
|
raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
|
|
226
251
|
copy_ptr = Leptris::XML::FFI.leptris_element_copy(elem_ptr, @document.c_ptr)
|
data/lib/leptris/xml/node_set.rb
CHANGED
|
@@ -117,29 +117,27 @@ class Leptris::XML::NodeSet
|
|
|
117
117
|
end
|
|
118
118
|
alias_method :text, :inner_text
|
|
119
119
|
|
|
120
|
+
# Union semantics: evaluate the expression against each element
|
|
121
|
+
# member (one C eval per member — the engine has no multi-context
|
|
122
|
+
# eval yet; leptris/leptris ask pending) and accumulate into ONE
|
|
123
|
+
# NodeSet, instead of merging NodeSets per member.
|
|
120
124
|
def xpath(*paths)
|
|
121
125
|
handler, _ns, _vars = parse_search_args(paths)
|
|
122
126
|
raise ArgumentError, "custom XPath handlers not supported" if handler
|
|
123
127
|
expr = paths.join(" | ")
|
|
124
|
-
accumulated =
|
|
128
|
+
accumulated = []
|
|
125
129
|
each do |node|
|
|
126
130
|
next unless node.is_a?(Leptris::XML::Element)
|
|
127
131
|
result_ptr = Leptris::XML::FFI.leptris_xpath_eval(
|
|
128
132
|
@document.c_ptr, node.c_ptr, expr)
|
|
129
133
|
next if result_ptr.null?
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
accumulated.concat(
|
|
135
|
+
Leptris::XML::NodeSet.from_result(@document, result_ptr).to_a)
|
|
132
136
|
end
|
|
133
|
-
accumulated
|
|
137
|
+
Leptris::XML::NodeSet.new(@document, accumulated)
|
|
134
138
|
end
|
|
135
139
|
|
|
136
140
|
def inspect
|
|
137
141
|
to_a.inspect
|
|
138
142
|
end
|
|
139
|
-
|
|
140
|
-
private
|
|
141
|
-
|
|
142
|
-
def merge_node_sets(a, b)
|
|
143
|
-
Leptris::XML::NodeSet.new(@document, a.to_a + b.to_a)
|
|
144
|
-
end
|
|
145
143
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
|
|
4
4
|
def name
|
|
5
5
|
return @name if readonly_cached?(:@name)
|
|
6
|
+
ensure_alive!
|
|
6
7
|
Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr).tap do |target|
|
|
7
8
|
@name = target if @document&.readonly?
|
|
8
9
|
end
|
|
@@ -11,6 +12,7 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
|
|
|
11
12
|
|
|
12
13
|
def content
|
|
13
14
|
return @content if readonly_cached?(:@content)
|
|
15
|
+
ensure_alive!
|
|
14
16
|
Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s.tap do |data|
|
|
15
17
|
@content = data if @document&.readonly?
|
|
16
18
|
end
|
|
@@ -6,13 +6,27 @@ class Leptris::XML::SAX::Parser
|
|
|
6
6
|
CHUNK_SIZE = 4096
|
|
7
7
|
private_constant :CHUNK_SIZE
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
attr_reader :document, :encoding
|
|
10
10
|
|
|
11
11
|
def initialize(handler = Leptris::XML::SAX::Document.new, encoding = nil)
|
|
12
12
|
@document = handler
|
|
13
13
|
@encoding = encoding
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Swapping the handler invalidates the memoized callback struct so
|
|
17
|
+
# the next parse dispatches to the new handler.
|
|
18
|
+
def document=(handler)
|
|
19
|
+
@document = handler
|
|
20
|
+
@handler_struct = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The handler struct (eleven FFI::Function callbacks + the C
|
|
24
|
+
# struct) is built once per handler and reused across parses; the
|
|
25
|
+
# memoized struct is anchored against GC by the instance itself.
|
|
26
|
+
def handler_struct
|
|
27
|
+
@handler_struct ||= build_handler_struct
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
# Parse a string, IO, or file path. Dispatches to parse_memory /
|
|
17
31
|
# parse_io / parse_file based on the argument type.
|
|
18
32
|
def parse(input)
|
|
@@ -26,7 +40,7 @@ class Leptris::XML::SAX::Parser
|
|
|
26
40
|
|
|
27
41
|
def parse_memory(string)
|
|
28
42
|
string = string.dup.force_encoding("UTF-8")
|
|
29
|
-
handler_struct =
|
|
43
|
+
handler_struct = self.handler_struct
|
|
30
44
|
rc = Leptris::XML::FFI.leptris_sax_parse(
|
|
31
45
|
string, string.bytesize, handler_struct.pointer, nil)
|
|
32
46
|
if rc != 0
|
|
@@ -37,7 +51,7 @@ class Leptris::XML::SAX::Parser
|
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
def parse_io(io)
|
|
40
|
-
handler_struct =
|
|
54
|
+
handler_struct = self.handler_struct
|
|
41
55
|
parser_ptr = Leptris::XML::FFI.leptris_sax_parser_create(
|
|
42
56
|
handler_struct.pointer, nil)
|
|
43
57
|
if parser_ptr.null?
|
|
@@ -69,9 +83,7 @@ class Leptris::XML::SAX::Parser
|
|
|
69
83
|
private
|
|
70
84
|
|
|
71
85
|
# Build a LeptrisSAXHandler struct populated with FFI::Function callbacks
|
|
72
|
-
# that dispatch to the Ruby handler.
|
|
73
|
-
# anchored against GC via the local variable for the duration of the
|
|
74
|
-
# synchronous parse call.
|
|
86
|
+
# that dispatch to the Ruby handler.
|
|
75
87
|
def build_handler_struct
|
|
76
88
|
s = Leptris::XML::FFI::SAXHandler.new
|
|
77
89
|
handler = @document # capture in closures
|
|
@@ -86,39 +98,39 @@ class Leptris::XML::SAX::Parser
|
|
|
86
98
|
|
|
87
99
|
s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, attrs_ptr|
|
|
88
100
|
attrs = walk_attr_array(attrs_ptr)
|
|
89
|
-
handler.start_element(name, attrs)
|
|
101
|
+
handler.start_element(utf8(name), attrs)
|
|
90
102
|
end
|
|
91
103
|
|
|
92
104
|
s[:end_element] = callback(:void, [:pointer, :string]) do |_, name|
|
|
93
|
-
handler.end_element(name)
|
|
105
|
+
handler.end_element(utf8(name))
|
|
94
106
|
end
|
|
95
107
|
|
|
96
108
|
s[:characters] = callback(:void, [:pointer, :pointer, :size_t]) do |_, text_ptr, len|
|
|
97
|
-
handler.characters(text_ptr.read_bytes(len).force_encoding(
|
|
109
|
+
handler.characters(text_ptr.read_bytes(len).force_encoding(Encoding::UTF_8))
|
|
98
110
|
end
|
|
99
111
|
|
|
100
112
|
s[:comment] = callback(:void, [:pointer, :string]) do |_, comment|
|
|
101
|
-
handler.comment(comment)
|
|
113
|
+
handler.comment(utf8(comment))
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
s[:cdata] = callback(:void, [:pointer, :string]) do |_, cdata|
|
|
105
|
-
handler.cdata_block(cdata)
|
|
117
|
+
handler.cdata_block(utf8(cdata))
|
|
106
118
|
end
|
|
107
119
|
|
|
108
120
|
s[:processing_instruction] = callback(:void, [:pointer, :string, :string]) do |_, target, data|
|
|
109
|
-
handler.processing_instruction(target, data)
|
|
121
|
+
handler.processing_instruction(utf8(target), utf8(data))
|
|
110
122
|
end
|
|
111
123
|
|
|
112
124
|
s[:start_prefix_mapping] = callback(:void, [:pointer, :string, :string]) do |_, prefix, uri|
|
|
113
|
-
handler.start_prefix_mapping(prefix, uri)
|
|
125
|
+
handler.start_prefix_mapping(utf8(prefix), utf8(uri))
|
|
114
126
|
end
|
|
115
127
|
|
|
116
128
|
s[:end_prefix_mapping] = callback(:void, [:pointer, :string]) do |_, prefix|
|
|
117
|
-
handler.end_prefix_mapping(prefix)
|
|
129
|
+
handler.end_prefix_mapping(utf8(prefix))
|
|
118
130
|
end
|
|
119
131
|
|
|
120
132
|
s[:error] = callback(:void, [:pointer, :string, :int, :int]) do |_, msg, line, col|
|
|
121
|
-
handler.error(msg, line, col)
|
|
133
|
+
handler.error(utf8(msg), line, col)
|
|
122
134
|
end
|
|
123
135
|
|
|
124
136
|
s
|
|
@@ -128,6 +140,13 @@ class Leptris::XML::SAX::Parser
|
|
|
128
140
|
::FFI::Function.new(return_type, params, blocking: blocking, &block)
|
|
129
141
|
end
|
|
130
142
|
|
|
143
|
+
# FFI's implicit :string conversion does not set encoding — every
|
|
144
|
+
# callback string param crosses the seam as ASCII-8BIT unless
|
|
145
|
+
# corrected here (the headers contract UTF-8; nil for NULL).
|
|
146
|
+
def utf8(str)
|
|
147
|
+
str.nil? ? nil : str.force_encoding(Encoding::UTF_8)
|
|
148
|
+
end
|
|
149
|
+
|
|
131
150
|
# The C `const char** attrs` is a NULL-terminated flat array of
|
|
132
151
|
# name/value pairs; CStringArray owns the format, we slice into pairs.
|
|
133
152
|
def walk_attr_array(attrs_ptr)
|
|
@@ -17,7 +17,7 @@ module Leptris::XML::Searchable
|
|
|
17
17
|
end
|
|
18
18
|
if result_ptr.null?
|
|
19
19
|
raise Leptris::XML::XPathError,
|
|
20
|
-
Leptris::XML::FFI.
|
|
20
|
+
Leptris::XML::FFI.leptris_last_error.to_s
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
|
|
@@ -72,6 +72,9 @@ module Leptris::XML::Searchable
|
|
|
72
72
|
break if args.empty?
|
|
73
73
|
end
|
|
74
74
|
ns, vars = hashes.reverse
|
|
75
|
+
unless vars.nil? || vars.empty?
|
|
76
|
+
raise ArgumentError, "XPath variable bindings are not supported"
|
|
77
|
+
end
|
|
75
78
|
[handler, ns, vars]
|
|
76
79
|
end
|
|
77
80
|
|
data/lib/leptris/xml/text.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: leptris
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.9.
|
|
4
|
+
version: 1.9.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -63,15 +63,11 @@ executables: []
|
|
|
63
63
|
extensions: []
|
|
64
64
|
extra_rdoc_files: []
|
|
65
65
|
files:
|
|
66
|
-
- ".rspec"
|
|
67
|
-
- ".rubocop.yml"
|
|
68
66
|
- CHANGELOG.md
|
|
69
67
|
- CLAUDE.md
|
|
70
68
|
- LICENSE.md
|
|
71
69
|
- README.adoc
|
|
72
70
|
- Rakefile
|
|
73
|
-
- benchmark/README.md
|
|
74
|
-
- benchmark/leptris_vs_nokogiri.rb
|
|
75
71
|
- leptris.gemspec
|
|
76
72
|
- lib/leptris.rb
|
|
77
73
|
- lib/leptris/version.rb
|
data/.rspec
DELETED
data/.rubocop.yml
DELETED
data/benchmark/README.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# leptris vs the field
|
|
2
|
-
|
|
3
|
-
The authoritative cross-library numbers live in the README's
|
|
4
|
-
Performance section (measured with metanorma/serialbench).
|
|
5
|
-
|
|
6
|
-
This local benchmark compares against Nokogiri only:
|
|
7
|
-
|
|
8
|
-
LEPTRIS_LIB_PATH=/path/to/libleptris.dylib bundle exec ruby benchmark/leptris_vs_nokogiri.rb
|
|
9
|
-
|
|
10
|
-
For the full-field race (Ox, Nokogiri, Oga, REXML, Leptris), use
|
|
11
|
-
https://github.com/metanorma/serialbench — leptris's adapter ships
|
|
12
|
-
there under `lib/serialbench/serializers/xml/leptris_serializer.rb`.
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Ruby-level performance comparison: Leptris::XML (FFI → libleptris v0.12.0)
|
|
4
|
-
# vs Nokogiri (C extension → libxml2).
|
|
5
|
-
#
|
|
6
|
-
# Run with: bundle exec ruby -Ilib benchmark/leptris_vs_nokogiri.rb
|
|
7
|
-
|
|
8
|
-
require "benchmark"
|
|
9
|
-
require "leptris/xml"
|
|
10
|
-
require "nokogiri"
|
|
11
|
-
|
|
12
|
-
module Fixtures
|
|
13
|
-
SMALL = "<catalog>" +
|
|
14
|
-
(1..10).map { |i| "<book id='#{i}'><title>Book #{i}</title></book>" }.join +
|
|
15
|
-
"</catalog>"
|
|
16
|
-
|
|
17
|
-
MEDIUM = ("<catalog version='2.0'>" +
|
|
18
|
-
(1..100).map do |i|
|
|
19
|
-
"<book id='#{i}' lang='en'>" \
|
|
20
|
-
"<title>Book #{i}</title>" \
|
|
21
|
-
"<author id='a#{i}'>Author #{i}</author>" \
|
|
22
|
-
"<price currency='USD'>#{i}.99</price>" \
|
|
23
|
-
"</book>"
|
|
24
|
-
end.join +
|
|
25
|
-
"</catalog>").freeze
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def time_it(label, n, &block)
|
|
29
|
-
GC.start
|
|
30
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
-
n.times { yield }
|
|
32
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
33
|
-
us_per_iter = (elapsed / n) * 1_000_000
|
|
34
|
-
printf " %-40s %8.2f µs/iter (%d iters in %.3fs)\n", label, us_per_iter, n, elapsed
|
|
35
|
-
us_per_iter
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def ratio(label, leptris_us, nokogiri_us)
|
|
39
|
-
r = nokogiri_us / leptris_us
|
|
40
|
-
who = r > 1 ? "Leptris faster" : "Nokogiri faster"
|
|
41
|
-
printf " → %-30s Leptris/Nokogiri = %.2fx (%s)\n\n", label, r, who
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
N_PARSE_SMALL = 5_000
|
|
45
|
-
N_PARSE_MEDIUM = 1_000
|
|
46
|
-
N_QUERY = 10_000
|
|
47
|
-
N_TRAVERSE = 2_000
|
|
48
|
-
N_SERIALIZE = 1_000
|
|
49
|
-
|
|
50
|
-
puts "===== Parse — small (#{Fixtures::SMALL.bytesize} B) ====="
|
|
51
|
-
t = time_it("leptris parse small", N_PARSE_SMALL) { d = Leptris::XML::Document.parse(Fixtures::SMALL); d.free }
|
|
52
|
-
n = time_it("nokogiri parse small", N_PARSE_SMALL) { Nokogiri::XML(Fixtures::SMALL) }
|
|
53
|
-
ratio("parse small", t, n)
|
|
54
|
-
|
|
55
|
-
puts "===== Parse — medium (#{Fixtures::MEDIUM.bytesize} B) ====="
|
|
56
|
-
t = time_it("leptris parse medium", N_PARSE_MEDIUM) { d = Leptris::XML::Document.parse(Fixtures::MEDIUM); d.free }
|
|
57
|
-
n = time_it("nokogiri parse medium", N_PARSE_MEDIUM) { Nokogiri::XML(Fixtures::MEDIUM) }
|
|
58
|
-
ratio("parse medium", t, n)
|
|
59
|
-
|
|
60
|
-
# Pre-parse medium for query benchmarks
|
|
61
|
-
doc_t = Leptris::XML::Document.parse(Fixtures::MEDIUM)
|
|
62
|
-
doc_n = Nokogiri::XML(Fixtures::MEDIUM)
|
|
63
|
-
|
|
64
|
-
puts "===== XPath — count(//book) ====="
|
|
65
|
-
t = time_it("leptris xpath count()", N_QUERY) { doc_t.xpath("count(//book)") }
|
|
66
|
-
n = time_it("nokogiri xpath count()", N_QUERY) { doc_n.xpath("count(//book)") }
|
|
67
|
-
ratio("xpath count()", t, n)
|
|
68
|
-
|
|
69
|
-
puts "===== XPath — //book (nodeset of 100) ====="
|
|
70
|
-
t = time_it("leptris xpath //book", N_QUERY) { doc_t.xpath("//book") }
|
|
71
|
-
n = time_it("nokogiri xpath //book", N_QUERY) { doc_n.xpath("//book") }
|
|
72
|
-
ratio("xpath //book", t, n)
|
|
73
|
-
|
|
74
|
-
puts "===== XPath — predicate //book[@id='50'] ====="
|
|
75
|
-
t = time_it("leptris xpath predicate", N_QUERY) { doc_t.xpath("//book[@id='50']") }
|
|
76
|
-
n = time_it("nokogiri xpath predicate", N_QUERY) { doc_n.xpath("//book[@id='50']") }
|
|
77
|
-
ratio("xpath predicate", t, n)
|
|
78
|
-
|
|
79
|
-
puts "===== XPath — complex //book[price > 50] ====="
|
|
80
|
-
t = time_it("leptris xpath complex", N_QUERY) { doc_t.xpath("//book[price > 50]") }
|
|
81
|
-
n = time_it("nokogiri xpath complex", N_QUERY) { doc_n.xpath("//book[price > 50]") }
|
|
82
|
-
ratio("xpath complex", t, n)
|
|
83
|
-
|
|
84
|
-
puts "===== XPath — union //author | //title ====="
|
|
85
|
-
t = time_it("leptris xpath union", N_QUERY) { doc_t.xpath("//author | //title") }
|
|
86
|
-
n = time_it("nokogiri xpath union", N_QUERY) { doc_n.xpath("//author | //title") }
|
|
87
|
-
ratio("xpath union", t, n)
|
|
88
|
-
|
|
89
|
-
puts "===== Tree traversal (root.traverse) ====="
|
|
90
|
-
t = time_it("leptris traverse", N_TRAVERSE) { doc_t.root.traverse { |n| n.name } }
|
|
91
|
-
n = time_it("nokogiri traverse", N_TRAVERSE) { doc_n.root.traverse { |n| n.name } }
|
|
92
|
-
ratio("traverse", t, n)
|
|
93
|
-
|
|
94
|
-
puts "===== Serialize — Document#to_xml ====="
|
|
95
|
-
t = time_it("leptris serialize", N_SERIALIZE) { doc_t.to_xml }
|
|
96
|
-
n = time_it("nokogiri serialize", N_SERIALIZE) { doc_n.to_xml }
|
|
97
|
-
ratio("serialize", t, n)
|
|
98
|
-
|
|
99
|
-
doc_t.free
|
|
100
|
-
|
|
101
|
-
puts ""
|
|
102
|
-
puts "libleptris v0.12.0 — all upstream issues closed."
|
|
103
|
-
puts "All 176 Ruby specs passing, 0 pending."
|
|
104
|
-
|
|
105
|
-
|