leptris 1.9.1 → 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: ed2f1d5755923f389734df36cc9c40c513535e58e7abe5a40aa87dd93f858788
4
- data.tar.gz: eb4e9c9c3b12a7584572746053c9aaf318ee5481fa002909d8502d5565bcf543
3
+ metadata.gz: c3e961e97c5ce359508474f70efda320521debdd4eb5e851241a82a49ffe7512
4
+ data.tar.gz: 8fd855b22026aacac080e734658e115826b45e59dcbfee6bb01a0e2b024a8cd1
5
5
  SHA512:
6
- metadata.gz: 35a1a34584014746679c39098f60c6009871007e3e5016562b669437e6f00d84412c7a26f286034f628421f72846c810b1b3568faacbbae86fa3e6c47b6db20f
7
- data.tar.gz: 5e4785ee975db46c2f7798a4effd7c3d2be729e94ded98db78e3cdd830891b0704170e104d9b2287ff1e4e8a98833917a70a7ec4d5591615931020cf67f18e54
6
+ metadata.gz: b1e58c09914cc12d9541410e1e5e46ff1c7135646bed22a46c6f796e41444ebdb0d039d1eabc1a5dc6790c6bcf2c3e1e31ae8fa6d8a33e25005138c5c0889fef
7
+ data.tar.gz: 42acbecab9c56654468540fa1cf7cda8dd666023a0d40b8f298ef2e18691d50e1e577393a0fb8bee36db27b743cdddf349db745e3b9c1bbb817d0a8681687a78
data/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ 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
+
39
+ ## [1.9.2] - 2026-08-25
40
+
41
+ ### Changed
42
+
43
+ Architecture deepening — all C ABI and buffer knowledge now lives
44
+ behind the FFI seam (`lib/leptris/xml/ffi.rb`); no public API change.
45
+
46
+ - `PullEventStruct` (FFI::Struct) replaces the pull parser's
47
+ hand-rolled ABI offsets (`get_int(0)` / `get_pointer(8)` /
48
+ `get_pointer(16)`); struct-layout changes become a one-line
49
+ layout edit instead of silent offset drift.
50
+ - `FFI.with_ns_set(hash)` concentrates the namespace-binding
51
+ lifecycle (flatten → CStringArray wire format → build → yield →
52
+ free) that ad-hoc and compiled XPath each hand-wrote.
53
+ - `FFI.serialize_into_string` / `FFI.fetch_children` /
54
+ `FFI.fetch_result_nodes` / `FFI.parse_fragment_with_status` own
55
+ the size-query/allocate/fill/read buffer protocols; Serialization,
56
+ NodeSet and DocumentFragment now contain no `MemoryPointer` code.
57
+ - `Node#children` and `DocumentFragment#children` fetch all child
58
+ handles in one batched call (the libleptris 1.7.0 surface, now
59
+ attached) instead of the first_child + N next_sibling walk —
60
+ N+1 FFI round trips collapse to 2 dispatches plus N wraps.
61
+
8
62
  ## [1.9.1] - 2026-08-24
9
63
 
10
64
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.1"
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.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ffi"
4
-
5
3
  # Wraps the synthetic "#document-fragment" element returned by
6
4
  # leptris_parse_fragment. Children of this fragment are the parsed nodes;
7
5
  # the fragment itself isn't part of any document tree but borrows its
@@ -15,23 +13,18 @@ class Leptris::XML::DocumentFragment
15
13
  end
16
14
 
17
15
  def self.parse(xml, document)
18
- status_ptr = ::FFI::MemoryPointer.new(:int)
19
- raw = Leptris::XML::FFI.leptris_parse_fragment(
20
- xml.to_s, xml.to_s.bytesize, document.c_ptr, status_ptr)
16
+ raw, status = Leptris::XML::FFI.parse_fragment_with_status(
17
+ xml.to_s, document.c_ptr)
21
18
  if raw.null?
22
19
  raise Leptris::XML::ParseError,
23
- "leptris_parse_fragment failed (status=#{status_ptr.read_int})"
20
+ "leptris_parse_fragment failed (status=#{status})"
24
21
  end
25
22
  new(document, raw)
26
23
  end
27
24
 
28
25
  def children
29
- count = Leptris::XML::FFI.leptris_element_child_count(@c_ptr)
30
- nodes = []
31
- ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
32
- until ptr.nil? || ptr.null?
33
- nodes << Leptris::XML::Node.wrap(ptr, @document)
34
- ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
26
+ nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
27
+ Leptris::XML::Node.wrap(ptr, @document)
35
28
  end
36
29
  Leptris::XML::NodeSet.new(@document, nodes)
37
30
  end
@@ -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
 
@@ -80,6 +80,17 @@ module Leptris
80
80
  :recover, :int
81
81
  end
82
82
 
83
+ # struct LeptrisPullEvent (leptris/sax.h): FFI::Struct derives
84
+ # the field offsets from the layout, so ABI changes are a
85
+ # one-line edit here instead of silent offset drift in callers.
86
+ class PullEventStruct < ::FFI::Struct
87
+ layout \
88
+ :type, :int,
89
+ :name, :pointer,
90
+ :text, :pointer,
91
+ :text_len, :size_t
92
+ end
93
+
83
94
  attach_function :leptris_version, [], :string
84
95
  attach_function :leptris_version_components, [:pointer, :pointer, :pointer], :void
85
96
 
@@ -158,6 +169,10 @@ module Leptris
158
169
  [:leptris_node_ref], :leptris_node_ref
159
170
  attach_function :leptris_node_child_count,
160
171
  [:leptris_node_ref], :size_t
172
+ # libleptris 1.7.0: all-kind child batch — out_nodes=NULL is a
173
+ # count-only query returning the TOTAL across every child kind.
174
+ attach_function :leptris_node_children,
175
+ [:leptris_node_ref, :pointer, :size_t], :size_t
161
176
  attach_function :leptris_node_as_element,
162
177
  [:leptris_node_ref], :leptris_element
163
178
  attach_function :leptris_element_as_node,
@@ -600,11 +615,45 @@ module Leptris
600
615
  LEPTRIS_PARSE_DEFAULT = 0
601
616
  LEPTRIS_PARSE_DROP_WS_TEXT = 1
602
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
+
603
651
  # Reads an libleptris-owned char* result and frees it as one unit,
604
652
  # so a call site can neither leak nor double-free.
605
653
  def self.read_owned_string(ptr)
606
654
  return "" if ptr.nil? || ptr.null?
607
655
  ptr.read_string.tap { leptris_free_string(ptr) }
656
+ .force_encoding(Encoding::UTF_8)
608
657
  end
609
658
 
610
659
  # Single status seam: turns a C status code into a Ruby error,
@@ -623,6 +672,90 @@ module Leptris
623
672
  detail = leptris_last_error.to_s
624
673
  detail.empty? ? base : "#{base} (#{detail})"
625
674
  end
675
+
676
+ # Namespace-binding lifecycle, written once: flatten the
677
+ # prefix/URI hash into the alternating CStringArray wire
678
+ # format, build the caller-owned set, yield it, free it under
679
+ # all outcomes. Every eval variant uses this; none of them
680
+ # knows how a set is born or dies.
681
+ def self.with_ns_set(hash)
682
+ flat = hash.flat_map { |prefix, uri| [prefix.to_s, uri.to_s] }
683
+ buffer, _anchors = Leptris::XML::CStringArray.to_c(flat)
684
+ set = leptris_xpath_ns_set_new_from_pairs(buffer, flat.length / 2)
685
+ if set.null?
686
+ raise Leptris::XML::Error,
687
+ "leptris_xpath_ns_set_new_from_pairs failed"
688
+ end
689
+ begin
690
+ yield set
691
+ ensure
692
+ leptris_xpath_ns_set_free(set)
693
+ end
694
+ end
695
+
696
+ # Caller-buffer serialization cycle: size query (buf=NULL),
697
+ # allocate exactly, fill, read. Since libleptris 1.9.0 the
698
+ # pair reuses one serialization through the per-document
699
+ # cache. +ffi_function+ is leptris_document_serialize_into or
700
+ # leptris_element_serialize_into. The buffer holds the
701
+ # serialization + NUL and XML output is NUL-free, so the
702
+ # bounded read is exact.
703
+ def self.serialize_into_string(ffi_function, c_ptr, options)
704
+ need = ffi_function.call(c_ptr, nil, 0, nil, options)
705
+ return "" if need.zero?
706
+ buffer = ::FFI::MemoryPointer.new(:char, need)
707
+ begin
708
+ ffi_function.call(c_ptr, buffer, need, nil, options)
709
+ buffer.read_string
710
+ ensure
711
+ buffer.free
712
+ end
713
+ end
714
+
715
+ # All-kind child handle batch (libleptris 1.7.0): count-only
716
+ # query sizes the buffer, one fetch copies every child kind,
717
+ # one bulk read materializes the pointers.
718
+ def self.fetch_children(c_ptr)
719
+ count = leptris_node_children(c_ptr, nil, 0)
720
+ return [] if count.zero?
721
+ with_pointer_buffer(count) do |buffer|
722
+ copied = leptris_node_children(c_ptr, buffer, count)
723
+ buffer.get_array_of_pointer(0, copied)
724
+ end
725
+ end
726
+
727
+ # XPath result-set batch (leptris_xpath_result_get_nodes_ex):
728
+ # copies all node kinds into a caller buffer and hands back
729
+ # the pointer array.
730
+ def self.fetch_result_nodes(result_ptr, count)
731
+ return [] if count.zero?
732
+ with_pointer_buffer(count) do |buffer|
733
+ copied = leptris_xpath_result_get_nodes_ex(
734
+ result_ptr, buffer, nil, count)
735
+ buffer.get_array_of_pointer(0, copied)
736
+ end
737
+ end
738
+
739
+ # Fragment parse with the status out-param read: returns
740
+ # [document-or-fragment pointer, status].
741
+ def self.parse_fragment_with_status(xml, document_ptr)
742
+ status = ::FFI::MemoryPointer.new(:int)
743
+ raw = leptris_parse_fragment(
744
+ xml, xml.bytesize, document_ptr, status)
745
+ [raw, status.read_int]
746
+ end
747
+
748
+ # The one buffer-allocation point for handle-array fetches:
749
+ # everything MemoryPointer-related above this line is either
750
+ # an attached C call or another seam helper.
751
+ def self.with_pointer_buffer(count)
752
+ buffer = ::FFI::MemoryPointer.new(:pointer, count)
753
+ begin
754
+ yield buffer
755
+ ensure
756
+ buffer.free
757
+ end
758
+ end
626
759
  end
627
760
  end
628
761
  end
@@ -107,14 +107,12 @@ class Leptris::XML::Node
107
107
  end
108
108
 
109
109
  def children
110
- # Immutable in readonly mode: the walk (first_child + one
111
- # next_sibling per child) plus wrapper construction is paid once.
110
+ # Immutable in readonly mode: the batch fetch plus wrapper
111
+ # construction is paid once.
112
112
  return @children if readonly_cached?(:@children)
113
- nodes = []
114
- ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
115
- until ptr.nil? || ptr.null?
116
- nodes << Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
117
- ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
113
+ parent = as_element_or_self
114
+ nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
115
+ Leptris::XML::Node.wrap(ptr, @document, parent: parent)
118
116
  end
119
117
  result = Leptris::XML::NodeSet.new(@document, nodes)
120
118
  @children = result if @document&.readonly?
@@ -150,7 +148,10 @@ class Leptris::XML::Node
150
148
  end
151
149
 
152
150
  def element_children
153
- 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
154
155
  end
155
156
  alias_method :elements, :element_children
156
157
 
@@ -197,17 +198,26 @@ class Leptris::XML::Node
197
198
  end
198
199
 
199
200
  def path
201
+ return @path if readonly_cached?(:@path)
200
202
  str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
201
- return nil if str_ptr.null?
202
- 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
203
206
  end
204
207
 
205
208
  def css_path
206
- return nil if path.nil?
207
- path.split("/").filter_map do |part|
208
- next nil if part.empty?
209
- part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
210
- 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
211
221
  end
212
222
 
213
223
  def dup
@@ -63,30 +63,24 @@ class Leptris::XML::NodeSet
63
63
  def each
64
64
  return enum_for(:each) unless block_given?
65
65
  if @result_ptr
66
- # Batch-fetch all node pointers in one FFI call
67
- # (leptris_xpath_result_get_nodes_ex copies every node kind, not
68
- # just elements) and wrap each. Wrappers are cached per-Document
69
- # via Node.wrap, so re-iteration of the same NodeSet hits it.
66
+ # Batch-fetch all node pointers through the FFI seam
67
+ # (leptris_xpath_result_get_nodes_ex copies every node kind,
68
+ # not just elements) and wrap each. Wrappers are cached
69
+ # per-Document via Node.wrap, so re-iteration of the same
70
+ # NodeSet hits it.
70
71
  n = length
71
72
  if n > 0
72
- buf = ::FFI::MemoryPointer.new(:pointer, n)
73
- begin
74
- copied = Leptris::XML::FFI.leptris_xpath_result_get_nodes_ex(
75
- @result_ptr, buf, nil, n)
76
- copied.times do |i|
77
- ptr = buf.get_pointer(i * ::FFI.type_size(:pointer))
78
- next if ptr.null?
79
- yield Leptris::XML::Node.wrap(ptr, @document)
80
- end
81
- # The batch accessor under-copies mixed-kind results (upstream
82
- # leptris#477); fall back to per-index fetch for the remainder.
83
- (copied...n).each do |i|
84
- ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
85
- next if ptr.null?
86
- yield Leptris::XML::Node.wrap(ptr, @document)
87
- end
88
- ensure
89
- buf.free
73
+ pointers = Leptris::XML::FFI.fetch_result_nodes(@result_ptr, n)
74
+ pointers.each do |ptr|
75
+ next if ptr.null?
76
+ yield Leptris::XML::Node.wrap(ptr, @document)
77
+ end
78
+ # The batch accessor under-copies mixed-kind results (upstream
79
+ # leptris#477); fall back to per-index fetch for the remainder.
80
+ (pointers.length...n).each do |i|
81
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
82
+ next if ptr.null?
83
+ yield Leptris::XML::Node.wrap(ptr, @document)
90
84
  end
91
85
  end
92
86
  else
@@ -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)
@@ -67,23 +67,18 @@ module Leptris::XML::Pull
67
67
  def next_event
68
68
  raw = Leptris::XML::FFI.leptris_pull_next(@handle)
69
69
  return nil if raw.null?
70
- type_code = raw.get_int(0)
71
- type = TYPES[type_code]
72
- name = read_string_at(raw, 8)
73
- text_ptr = raw.get_pointer(16)
74
- text = text_ptr.null? ? nil : text_ptr.read_string
70
+ event = Leptris::XML::FFI::PullEventStruct.new(raw)
71
+ type = TYPES[event[:type]]
72
+ name = read_owned(event[:name])
73
+ text = read_owned(event[:text])
75
74
  attrs = type == :start_element ? capture_attrs : nil
76
75
  Event.new(type: type, name: name, text: text, attrs: attrs)
77
76
  end
78
77
 
79
78
  private
80
79
 
81
- # struct LeptrisPullEvent { int type; const char* name; const
82
- # char* text; size_t text_len; } — pointer-sized fields follow the
83
- # 4-byte enum on 64-bit ABIs (name at 8, text at 16).
84
- def read_string_at(raw, offset)
85
- ptr = raw.get_pointer(offset)
86
- ptr.null? ? nil : ptr.read_string
80
+ def read_owned(ptr)
81
+ ptr.null? ? nil : ptr.read_string.force_encoding(Encoding::UTF_8)
87
82
  end
88
83
 
89
84
  def capture_attrs
@@ -55,20 +55,11 @@ module Leptris::XML::Searchable
55
55
 
56
56
  protected
57
57
 
58
- # Builds a caller-owned namespace binding set in one FFI call
59
- # (ns_set_new_from_pairs takes a flat alternating prefix/URI array,
60
- # the same wire format CStringArray owns), evaluates via
61
- # leptris_xpath_eval_ns, and frees the set regardless of outcome.
58
+ # Evaluates against a caller-owned namespace binding set; the
59
+ # build/teardown lifecycle lives at the FFI seam (FFI.with_ns_set).
62
60
  def xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
63
- flat = ns.flat_map { |prefix, uri| [prefix.to_s, uri.to_s] }
64
- buffer, _anchors = Leptris::XML::CStringArray.to_c(flat)
65
- set = Leptris::XML::FFI.leptris_xpath_ns_set_new_from_pairs(
66
- buffer, flat.length / 2)
67
- raise Leptris::XML::Error, "leptris_xpath_ns_set_new_from_pairs failed" if set.null?
68
- begin
61
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
69
62
  Leptris::XML::FFI.leptris_xpath_eval_ns(doc_ptr, context_ptr, expr, set)
70
- ensure
71
- Leptris::XML::FFI.leptris_xpath_ns_set_free(set)
72
63
  end
73
64
  end
74
65
 
@@ -18,12 +18,9 @@ module Leptris::XML::Serialization
18
18
  end
19
19
  private_constant :DEFAULT_OPTIONS
20
20
 
21
- # +ffi_function+ is a bound _serialize_into function taking
22
- # (c_ptr, buf, capacity, out_len, opts): size query with buf=NULL,
23
- # then one fill into a caller buffer. Since libleptris 1.9.0 the
24
- # pair reuses a single serialization through a per-document cache
25
- # (leptris#541) — no C-side result-string allocation, and repeated
26
- # serializations of an unmutated document skip the serialize pass.
21
+ # +ffi_function+ is a bound _serialize_into function. The buffer
22
+ # cycle lives at the FFI seam (FFI.serialize_into_string); this
23
+ # module owns only options selection and construction.
27
24
  def self.to_xml(ffi_function, c_ptr, indent: 0, no_decl: false, encoding: nil)
28
25
  opts =
29
26
  if indent.to_i.zero? && !no_decl && encoding.nil?
@@ -33,18 +30,7 @@ module Leptris::XML::Serialization
33
30
  indent: indent, no_decl: no_decl, encoding: encoding)
34
31
  opts
35
32
  end
36
- need = ffi_function.call(c_ptr, nil, 0, nil, opts.pointer)
37
- return "" if need.zero?
38
- buf = ::FFI::MemoryPointer.new(:char, need)
39
- begin
40
- ffi_function.call(c_ptr, buf, need, nil, opts.pointer)
41
- # The buffer holds exactly the serialization + NUL; XML output
42
- # is NUL-free, so a bounded read_string is exact. (Avoids the
43
- # out_len param — no portable size_t read exists on Pointer.)
44
- buf.read_string
45
- ensure
46
- buf.free
47
- end
33
+ Leptris::XML::FFI.serialize_into_string(ffi_function, c_ptr, opts.pointer)
48
34
  end
49
35
 
50
36
  # +ffi_function+ is a bound FFI function taking
@@ -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)
@@ -40,16 +40,9 @@ class Leptris::XML::XPath
40
40
  document = context.document
41
41
  result_ptr =
42
42
  if ns && !ns.empty?
43
- flat = ns.flat_map { |prefix, uri| [prefix.to_s, uri.to_s] }
44
- buffer, _anchors = Leptris::XML::CStringArray.to_c(flat)
45
- set = Leptris::XML::FFI.leptris_xpath_ns_set_new_from_pairs(
46
- buffer, flat.length / 2)
47
- raise Leptris::XML::Error, "leptris_xpath_ns_set_new_from_pairs failed" if set.null?
48
- begin
43
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
49
44
  Leptris::XML::FFI.leptris_xpath_compiled_eval_ns(
50
45
  @handle, document.c_ptr, context_ptr(context), set)
51
- ensure
52
- Leptris::XML::FFI.leptris_xpath_ns_set_free(set)
53
46
  end
54
47
  else
55
48
  Leptris::XML::FFI.leptris_xpath_compiled_eval(
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi