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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d6cc4a584231a903d60b34a8b3e438c729d804743f66ada5b9944dac2c2662e
4
- data.tar.gz: 2cfcc126bf8e78363d6614e06ddfed0d72bd653eac0087f80781e237a7c1f85a
3
+ metadata.gz: c3e961e97c5ce359508474f70efda320521debdd4eb5e851241a82a49ffe7512
4
+ data.tar.gz: 8fd855b22026aacac080e734658e115826b45e59dcbfee6bb01a0e2b024a8cd1
5
5
  SHA512:
6
- metadata.gz: 6fe8c57e921c84a0c31552c255151e1f0a756236282b7d8ac184a7f54da5dcac465747d33acc77fbd38d47506fcfc7d89961711c7ae3daf45aa08cbc9ef337fe
7
- data.tar.gz: ea5561b2e483f0dc17c9942d60763565873f3d983b2f188e71153afd60e83909ce53a40a61d3c73e15be13592e0c00f2752aaad9cca8365e3e68d5b2bd5455a8
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.2"
4
+ VERSION = "1.9.3"
5
5
  end
@@ -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
- @name.include?(":") ? @name.split(":", 2).first : nil
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
+ "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
59
+ '"' => "&quot;", "'" => "&apos;",
60
+ }.freeze
61
+ private_constant :ESCAPE_TABLE
62
+
56
63
  def to_xml
57
- escaped = @value.to_s.gsub("&", "&amp;")
58
- .gsub("<", "&lt;")
59
- .gsub(">", "&gt;")
60
- .gsub('"', "&quot;")
61
- .gsub("'", "&apos;")
62
- "#{@name}=\"#{escaped}\""
64
+ "#{@name}=\"#{@value.to_s.gsub(/[&<>"']/, ESCAPE_TABLE)}\""
63
65
  end
64
66
 
65
67
  def ==(other)
@@ -29,7 +29,7 @@ module Leptris::XML::CStringArray
29
29
  result = []
30
30
  offset = 0
31
31
  while (entry = ptr.get_pointer(offset)) && !entry.null?
32
- result << entry.read_string
32
+ result << entry.read_string.force_encoding(Encoding::UTF_8)
33
33
  offset += ptr_size
34
34
  end
35
35
  result
@@ -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
- Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr)
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)
@@ -4,7 +4,10 @@ class Leptris::XML::Comment < Leptris::XML::Node
4
4
  def name; "comment"; end
5
5
 
6
6
  def content
7
- Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr)
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.split(COMMA_SPLIT).map { |r| convert_one(r.strip) }.join(" | ")
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)
@@ -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.
@@ -88,11 +88,17 @@ class Leptris::XML::Element < Leptris::XML::Node
88
88
  end
89
89
 
90
90
  def keys
91
- each_attribute.to_a.map(&:name)
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
- each_attribute.to_a.map(&:value)
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
- each_attribute.to_a
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
- return nil if uri.nil? || uri.empty?
226
- # The resolved namespace is reached via this element's own prefix,
227
- # so carry it through: consumers that distinguish
228
- # {"p" => "urn:p"} from the default {"nil => "urn:p"} need it.
229
- prefix = Leptris::XML::FFI.leptris_element_prefix(@c_ptr)
230
- prefix = nil if prefix.nil? || prefix.empty?
231
- Leptris::XML::Namespace.new(self, uri, prefix: prefix)
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
 
@@ -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,
@@ -148,7 +148,10 @@ class Leptris::XML::Node
148
148
  end
149
149
 
150
150
  def element_children
151
- children.select(&:element?)
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
- return nil if str_ptr.null?
200
- Leptris::XML::FFI.read_owned_string(str_ptr)
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 nil if path.nil?
205
- path.split("/").filter_map do |part|
206
- next nil if part.empty?
207
- part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
208
- end.join(" > ")
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
- Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr)
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
- Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s
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)
@@ -78,7 +78,7 @@ module Leptris::XML::Pull
78
78
  private
79
79
 
80
80
  def read_owned(ptr)
81
- ptr.null? ? nil : ptr.read_string
81
+ ptr.null? ? nil : ptr.read_string.force_encoding(Encoding::UTF_8)
82
82
  end
83
83
 
84
84
  def capture_attrs
@@ -4,7 +4,10 @@ class Leptris::XML::Text < Leptris::XML::Node
4
4
  def name; "text"; end
5
5
 
6
6
  def content
7
- Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr)
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)
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.2
4
+ version: 1.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.