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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3e961e97c5ce359508474f70efda320521debdd4eb5e851241a82a49ffe7512
4
- data.tar.gz: 8fd855b22026aacac080e734658e115826b45e59dcbfee6bb01a0e2b024a8cd1
3
+ metadata.gz: 996051e28883434c5ac9cf2041278a37a0ebc3ad628685838e6b743cc87e0b9d
4
+ data.tar.gz: 3909cce576ad3ce14fa65c4058997476ddd814ea9f819e00e0b4bbb969bd09cf
5
5
  SHA512:
6
- metadata.gz: b1e58c09914cc12d9541410e1e5e46ff1c7135646bed22a46c6f796e41444ebdb0d039d1eabc1a5dc6790c6bcf2c3e1e31ae8fa6d8a33e25005138c5c0889fef
7
- data.tar.gz: 42acbecab9c56654468540fa1cf7cda8dd666023a0d40b8f298ef2e18691d50e1e577393a0fb8bee36db27b743cdddf349db745e3b9c1bbb817d0a8681687a78
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 appveyor Gemfile])
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.3"
4
+ VERSION = "1.9.5"
5
5
  end
@@ -5,6 +5,7 @@ class Leptris::XML::CDATA < Leptris::XML::Text
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
@@ -5,6 +5,7 @@ class Leptris::XML::Comment < Leptris::XML::Node
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
@@ -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)
@@ -2,7 +2,9 @@
2
2
 
3
3
  class Leptris::XML::Element < Leptris::XML::Node
4
4
  def name
5
- @name ||= Leptris::XML::FFI.leptris_element_name(@c_ptr)
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))
@@ -62,7 +62,9 @@ class Leptris::XML::Node
62
62
  end
63
63
 
64
64
  def type
65
- @node_type ||= Leptris::XML::FFI.leptris_node_get_type(@c_ptr)
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
- # Raises ReadOnlyError when the owning document was marked readonly.
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)
@@ -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 = Leptris::XML::NodeSet.new(@document)
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
- sub = Leptris::XML::NodeSet.send(:from_result, @document, result_ptr)
131
- accumulated = merge_node_sets(accumulated, sub)
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
- attr_accessor :document, :encoding
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 = build_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 = build_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. The struct (and its callbacks) are
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("UTF-8"))
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.leptris_status_string(Leptris::XML::FFI::LEPTRIS_ERROR_XPATH)
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
 
@@ -5,6 +5,7 @@ class Leptris::XML::Text < Leptris::XML::Node
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
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.3
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
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
-
4
- Style/StringLiterals:
5
- EnforcedStyle: double_quotes
6
-
7
- Style/StringLiteralsInInterpolation:
8
- EnforcedStyle: double_quotes
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
-