leptris 1.9.2 → 1.9.3
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 +31 -0
- data/lib/leptris/version.rb +1 -1
- data/lib/leptris/xml/attr.rb +10 -8
- data/lib/leptris/xml/c_string_array.rb +1 -1
- data/lib/leptris/xml/cdata.rb +4 -1
- data/lib/leptris/xml/comment.rb +4 -1
- data/lib/leptris/xml/css_to_xpath.rb +12 -1
- data/lib/leptris/xml/document.rb +6 -1
- data/lib/leptris/xml/element.rb +32 -11
- data/lib/leptris/xml/ffi.rb +34 -0
- data/lib/leptris/xml/node.rb +20 -8
- data/lib/leptris/xml/processing_instruction.rb +8 -2
- data/lib/leptris/xml/pull.rb +1 -1
- data/lib/leptris/xml/text.rb +4 -1
- 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: c3e961e97c5ce359508474f70efda320521debdd4eb5e851241a82a49ffe7512
|
|
4
|
+
data.tar.gz: 8fd855b22026aacac080e734658e115826b45e59dcbfee6bb01a0e2b024a8cd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1e58c09914cc12d9541410e1e5e46ff1c7135646bed22a46c6f796e41444ebdb0d039d1eabc1a5dc6790c6bcf2c3e1e31ae8fa6d8a33e25005138c5c0889fef
|
|
7
|
+
data.tar.gz: 42acbecab9c56654468540fa1cf7cda8dd666023a0d40b8f298ef2e18691d50e1e577393a0fb8bee36db27b743cdddf349db745e3b9c1bbb817d0a8681687a78
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.3] - 2026-08-25
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **UTF-8 at the FFI seam**: every string crossing the C boundary
|
|
13
|
+
(names, content, attribute values, paths, pull attributes, PI
|
|
14
|
+
data, error strings) now arrives as UTF-8 per the headers'
|
|
15
|
+
contract, instead of FFI's default ASCII-8BIT. Downstream string
|
|
16
|
+
comparisons, hash keys, and regexes stop paying compatibility
|
|
17
|
+
checks — and non-ASCII content can no longer raise
|
|
18
|
+
Encoding::CompatibilityError in consumer code.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **Readonly read-path cache completed**: `namespace`,
|
|
23
|
+
`namespace_definitions`, `namespaces`, `keys`, `values`,
|
|
24
|
+
`attribute_nodes`, `element_children`, `path`, `css_path`,
|
|
25
|
+
Text/Comment/CDATA/PI `content` (and PI `name`), and
|
|
26
|
+
`Document#processing_instructions` now memoize under readonly —
|
|
27
|
+
the same cannot-go-stale invariant the first four readers already
|
|
28
|
+
had. Measured steady-state wins (readonly documents, 400-iteration
|
|
29
|
+
loops): namespace inspection 13.5× faster, attribute listing 3.2×,
|
|
30
|
+
text content 2×.
|
|
31
|
+
- **CSS translation cache**: `CssToXPath.convert` memoizes per
|
|
32
|
+
selector string (failures raise before caching); repeated
|
|
33
|
+
`css`/`at_css` vocabularies skip the regex cascade (~28% on the
|
|
34
|
+
selector loop benchmark).
|
|
35
|
+
- `Attr#to_xml` escapes the five XML entities in one gsub pass
|
|
36
|
+
instead of five chained passes; `Attr#prefix` slices instead of
|
|
37
|
+
split-and-discard.
|
|
38
|
+
|
|
8
39
|
## [1.9.2] - 2026-08-25
|
|
9
40
|
|
|
10
41
|
### Changed
|
data/lib/leptris/version.rb
CHANGED
data/lib/leptris/xml/attr.rb
CHANGED
|
@@ -28,7 +28,8 @@ class Leptris::XML::Attr
|
|
|
28
28
|
# immutable — the same semantics as leptris_attribute_prefix, so
|
|
29
29
|
# no declaration lookup is involved.
|
|
30
30
|
def prefix
|
|
31
|
-
|
|
31
|
+
i = @name.index(":")
|
|
32
|
+
i ? @name[0, i] : nil
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# The attribute's namespace URI, resolved through the OWNING
|
|
@@ -52,14 +53,15 @@ class Leptris::XML::Attr
|
|
|
52
53
|
def to_str; @value; end
|
|
53
54
|
|
|
54
55
|
# Serialized attribute form: name="value" with the five XML special
|
|
55
|
-
# characters escaped in the value.
|
|
56
|
+
# characters escaped in the value — single pass, no chained gsubs.
|
|
57
|
+
ESCAPE_TABLE = {
|
|
58
|
+
"&" => "&", "<" => "<", ">" => ">",
|
|
59
|
+
'"' => """, "'" => "'",
|
|
60
|
+
}.freeze
|
|
61
|
+
private_constant :ESCAPE_TABLE
|
|
62
|
+
|
|
56
63
|
def to_xml
|
|
57
|
-
|
|
58
|
-
.gsub("<", "<")
|
|
59
|
-
.gsub(">", ">")
|
|
60
|
-
.gsub('"', """)
|
|
61
|
-
.gsub("'", "'")
|
|
62
|
-
"#{@name}=\"#{escaped}\""
|
|
64
|
+
"#{@name}=\"#{@value.to_s.gsub(/[&<>"']/, ESCAPE_TABLE)}\""
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def ==(other)
|
data/lib/leptris/xml/cdata.rb
CHANGED
|
@@ -4,7 +4,10 @@ class Leptris::XML::CDATA < Leptris::XML::Text
|
|
|
4
4
|
def name; "#cdata-section"; end
|
|
5
5
|
|
|
6
6
|
def content
|
|
7
|
-
|
|
7
|
+
return @content if readonly_cached?(:@content)
|
|
8
|
+
Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr).tap do |text|
|
|
9
|
+
@content = text if @document&.readonly?
|
|
10
|
+
end
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def content=(new_content)
|
data/lib/leptris/xml/comment.rb
CHANGED
|
@@ -4,7 +4,10 @@ class Leptris::XML::Comment < Leptris::XML::Node
|
|
|
4
4
|
def name; "comment"; end
|
|
5
5
|
|
|
6
6
|
def content
|
|
7
|
-
|
|
7
|
+
return @content if readonly_cached?(:@content)
|
|
8
|
+
Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr).tap do |text|
|
|
9
|
+
@content = text if @document&.readonly?
|
|
10
|
+
end
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def content=(new_content)
|
|
@@ -35,8 +35,19 @@ module Leptris
|
|
|
35
35
|
|
|
36
36
|
module_function
|
|
37
37
|
|
|
38
|
+
# Translation is a pure function of the rule string, and real
|
|
39
|
+
# workloads repeat a small selector vocabulary in loops —
|
|
40
|
+
# memoize. Failed translations raise before caching.
|
|
41
|
+
CACHE = {}
|
|
42
|
+
private_constant :CACHE
|
|
43
|
+
|
|
38
44
|
def convert(rule)
|
|
39
|
-
rule.to_s
|
|
45
|
+
key = rule.to_s
|
|
46
|
+
CACHE.fetch(key) { CACHE[key] = convert_rule(key) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def convert_rule(rule)
|
|
50
|
+
rule.split(COMMA_SPLIT).map { |r| convert_one(r.strip) }.join(" | ")
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
def convert_one(rule)
|
data/lib/leptris/xml/document.rb
CHANGED
|
@@ -247,11 +247,16 @@ class Leptris::XML::Document
|
|
|
247
247
|
# Document-level processing instructions (not tree nodes):
|
|
248
248
|
# an array of [target, data] pairs in document order.
|
|
249
249
|
def processing_instructions
|
|
250
|
+
if readonly? && instance_variable_defined?(:@processing_instructions)
|
|
251
|
+
return @processing_instructions
|
|
252
|
+
end
|
|
250
253
|
count = Leptris::XML::FFI.leptris_document_pi_count(@c_ptr)
|
|
251
|
-
count.times.map do |i|
|
|
254
|
+
result = count.times.map do |i|
|
|
252
255
|
[Leptris::XML::FFI.leptris_document_pi_target(@c_ptr, i),
|
|
253
256
|
Leptris::XML::FFI.leptris_document_pi_data(@c_ptr, i)]
|
|
254
257
|
end
|
|
258
|
+
@processing_instructions = result if readonly?
|
|
259
|
+
result
|
|
255
260
|
end
|
|
256
261
|
|
|
257
262
|
# Append a document-level processing instruction. Returns self.
|
data/lib/leptris/xml/element.rb
CHANGED
|
@@ -88,11 +88,17 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def keys
|
|
91
|
-
|
|
91
|
+
return @keys if readonly_cached?(:@keys)
|
|
92
|
+
result = each_attribute.to_a.map(&:name)
|
|
93
|
+
@keys = result if @document&.readonly?
|
|
94
|
+
result
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def values
|
|
95
|
-
|
|
98
|
+
return @values if readonly_cached?(:@values)
|
|
99
|
+
result = each_attribute.to_a.map(&:value)
|
|
100
|
+
@values = result if @document&.readonly?
|
|
101
|
+
result
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
def attributes
|
|
@@ -104,7 +110,10 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
def attribute_nodes
|
|
107
|
-
|
|
113
|
+
return @attribute_nodes if readonly_cached?(:@attribute_nodes)
|
|
114
|
+
result = each_attribute.to_a
|
|
115
|
+
@attribute_nodes = result if @document&.readonly?
|
|
116
|
+
result
|
|
108
117
|
end
|
|
109
118
|
|
|
110
119
|
# The element's own namespace prefix (e.g. "foo" for <foo:child/>),
|
|
@@ -221,26 +230,37 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
221
230
|
alias_method :<<, :add_child
|
|
222
231
|
|
|
223
232
|
def namespace
|
|
233
|
+
return @namespace if readonly_cached?(:@namespace)
|
|
224
234
|
uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
235
|
+
result =
|
|
236
|
+
if uri.nil? || uri.empty?
|
|
237
|
+
nil
|
|
238
|
+
else
|
|
239
|
+
# The resolved namespace is reached via this element's own
|
|
240
|
+
# prefix, so carry it through: consumers that distinguish
|
|
241
|
+
# {"p" => "urn:p"} from the default {"nil => "urn:p"} need it.
|
|
242
|
+
prefix = Leptris::XML::FFI.leptris_element_prefix(@c_ptr)
|
|
243
|
+
prefix = nil if prefix.nil? || prefix.empty?
|
|
244
|
+
Leptris::XML::Namespace.new(self, uri, prefix: prefix)
|
|
245
|
+
end
|
|
246
|
+
@namespace = result if @document&.readonly?
|
|
247
|
+
result
|
|
232
248
|
end
|
|
233
249
|
|
|
234
250
|
def namespace_definitions
|
|
251
|
+
return @namespace_definitions if readonly_cached?(:@namespace_definitions)
|
|
235
252
|
count = Leptris::XML::FFI.leptris_element_namespace_count(@c_ptr)
|
|
236
|
-
count.times.map do |i|
|
|
253
|
+
result = count.times.map do |i|
|
|
237
254
|
prefix = Leptris::XML::FFI.leptris_element_namespace_decl_prefix(@c_ptr, i)
|
|
238
255
|
uri = Leptris::XML::FFI.leptris_element_namespace_decl_uri(@c_ptr, i)
|
|
239
256
|
Leptris::XML::Namespace.new(self, uri, prefix: prefix)
|
|
240
257
|
end
|
|
258
|
+
@namespace_definitions = result if @document&.readonly?
|
|
259
|
+
result
|
|
241
260
|
end
|
|
242
261
|
|
|
243
262
|
def namespaces
|
|
263
|
+
return @namespaces if readonly_cached?(:@namespaces)
|
|
244
264
|
scopes = {}
|
|
245
265
|
node = self
|
|
246
266
|
while node.is_a?(Leptris::XML::Element)
|
|
@@ -250,6 +270,7 @@ class Leptris::XML::Element < Leptris::XML::Node
|
|
|
250
270
|
end
|
|
251
271
|
node = node.parent
|
|
252
272
|
end
|
|
273
|
+
@namespaces = scopes if @document&.readonly?
|
|
253
274
|
scopes
|
|
254
275
|
end
|
|
255
276
|
|
data/lib/leptris/xml/ffi.rb
CHANGED
|
@@ -615,11 +615,45 @@ module Leptris
|
|
|
615
615
|
LEPTRIS_PARSE_DEFAULT = 0
|
|
616
616
|
LEPTRIS_PARSE_DROP_WS_TEXT = 1
|
|
617
617
|
|
|
618
|
+
# The headers' contract is UTF-8 for every C string, but FFI's
|
|
619
|
+
# read_string hands back ASCII-8BIT — the platform default
|
|
620
|
+
# leaks through the seam. Every string-returning attached
|
|
621
|
+
# function this binding CALLS is wrapped here so its result
|
|
622
|
+
# arrives as UTF-8; mirror-only attachments stay raw (add the
|
|
623
|
+
# name here when a call site starts using its string).
|
|
624
|
+
UTF8_RETURNS = %i[
|
|
625
|
+
leptris_version
|
|
626
|
+
leptris_status_string leptris_last_error
|
|
627
|
+
leptris_document_last_error leptris_document_encoding
|
|
628
|
+
leptris_document_pi_target leptris_document_pi_data
|
|
629
|
+
leptris_element_name leptris_element_text leptris_element_prefix
|
|
630
|
+
leptris_element_namespace
|
|
631
|
+
leptris_element_attribute leptris_element_attribute_ns
|
|
632
|
+
leptris_attribute_get_name leptris_attribute_get_value
|
|
633
|
+
leptris_attribute_namespace_uri
|
|
634
|
+
leptris_element_namespace_decl_prefix
|
|
635
|
+
leptris_element_namespace_decl_uri
|
|
636
|
+
leptris_text_node_get_content leptris_comment_node_get_content
|
|
637
|
+
leptris_cdata_node_get_content
|
|
638
|
+
leptris_pi_node_get_target leptris_pi_node_get_data
|
|
639
|
+
leptris_pull_attr_name leptris_pull_attr_value
|
|
640
|
+
].freeze
|
|
641
|
+
private_constant :UTF8_RETURNS
|
|
642
|
+
|
|
643
|
+
UTF8_RETURNS.each do |name|
|
|
644
|
+
raw = method(name)
|
|
645
|
+
define_singleton_method(name) do |*args|
|
|
646
|
+
str = raw.call(*args)
|
|
647
|
+
str.nil? ? nil : str.force_encoding(Encoding::UTF_8)
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
618
651
|
# Reads an libleptris-owned char* result and frees it as one unit,
|
|
619
652
|
# so a call site can neither leak nor double-free.
|
|
620
653
|
def self.read_owned_string(ptr)
|
|
621
654
|
return "" if ptr.nil? || ptr.null?
|
|
622
655
|
ptr.read_string.tap { leptris_free_string(ptr) }
|
|
656
|
+
.force_encoding(Encoding::UTF_8)
|
|
623
657
|
end
|
|
624
658
|
|
|
625
659
|
# Single status seam: turns a C status code into a Ruby error,
|
data/lib/leptris/xml/node.rb
CHANGED
|
@@ -148,7 +148,10 @@ class Leptris::XML::Node
|
|
|
148
148
|
end
|
|
149
149
|
|
|
150
150
|
def element_children
|
|
151
|
-
|
|
151
|
+
return @element_children if readonly_cached?(:@element_children)
|
|
152
|
+
result = children.select(&:element?)
|
|
153
|
+
@element_children = result if @document&.readonly?
|
|
154
|
+
result
|
|
152
155
|
end
|
|
153
156
|
alias_method :elements, :element_children
|
|
154
157
|
|
|
@@ -195,17 +198,26 @@ class Leptris::XML::Node
|
|
|
195
198
|
end
|
|
196
199
|
|
|
197
200
|
def path
|
|
201
|
+
return @path if readonly_cached?(:@path)
|
|
198
202
|
str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
|
|
204
|
+
@path = result if @document&.readonly?
|
|
205
|
+
result
|
|
201
206
|
end
|
|
202
207
|
|
|
203
208
|
def css_path
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
return @css_path if readonly_cached?(:@css_path)
|
|
210
|
+
result =
|
|
211
|
+
if path.nil?
|
|
212
|
+
nil
|
|
213
|
+
else
|
|
214
|
+
path.split("/").filter_map do |part|
|
|
215
|
+
next nil if part.empty?
|
|
216
|
+
part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
|
|
217
|
+
end.join(" > ")
|
|
218
|
+
end
|
|
219
|
+
@css_path = result if @document&.readonly?
|
|
220
|
+
result
|
|
209
221
|
end
|
|
210
222
|
|
|
211
223
|
def dup
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
|
|
4
4
|
def name
|
|
5
|
-
|
|
5
|
+
return @name if readonly_cached?(:@name)
|
|
6
|
+
Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr).tap do |target|
|
|
7
|
+
@name = target if @document&.readonly?
|
|
8
|
+
end
|
|
6
9
|
end
|
|
7
10
|
alias_method :target, :name
|
|
8
11
|
|
|
9
12
|
def content
|
|
10
|
-
|
|
13
|
+
return @content if readonly_cached?(:@content)
|
|
14
|
+
Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s.tap do |data|
|
|
15
|
+
@content = data if @document&.readonly?
|
|
16
|
+
end
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def target=(new_target)
|
data/lib/leptris/xml/pull.rb
CHANGED
data/lib/leptris/xml/text.rb
CHANGED
|
@@ -4,7 +4,10 @@ class Leptris::XML::Text < Leptris::XML::Node
|
|
|
4
4
|
def name; "text"; end
|
|
5
5
|
|
|
6
6
|
def content
|
|
7
|
-
|
|
7
|
+
return @content if readonly_cached?(:@content)
|
|
8
|
+
Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr).tap do |text|
|
|
9
|
+
@content = text if @document&.readonly?
|
|
10
|
+
end
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def content=(new_content)
|