leptris 1.9.31 → 1.9.33

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: 61af1b12021e7a1cf22f04004ea36d27df6f740ff135d039854c51497abf87f3
4
- data.tar.gz: 0a842331a02a988b8bfce2920f8878b87c5ff5fdbd4c471f2f04694f4a1b3465
3
+ metadata.gz: '01791cd8ecc5e2c7b472ac0333ae89ab338d6fa8872a55f724cafe708f866f2b'
4
+ data.tar.gz: 00d0115424789446417bce8b22d35a8b1f23883c17dd135f4a4e8d0d8ba19aa1
5
5
  SHA512:
6
- metadata.gz: e832da72a4e2fe0ddede057023c1e981705a41f196d85e7d7e5d61051d524bddfc2305065fef940ef60af48b87e978bc46be7e61030d598de83fb916db8bba3e
7
- data.tar.gz: 7a266c15d5983229591bf414ad0dde95cc93018ba01388bf721f98977c026c164797e1dc805fc9a46625a6b2037c7709a65cb0e0640ca5ed6c46f40614dd37c0
6
+ metadata.gz: a8d17f3f61f0317d9165a57e6ecf134cb22751c23f5fd39a669e9df7f6fa414e780a52ab55f7bd549c1ea10ae8cfa6d0fad6fe44f9b092ac581974d391cfa521
7
+ data.tar.gz: 2842e82675cba9aad3bc4bb859e1a85a78043b3b81b4eebf2d7c0afab4df3abacb01ddc56144d0bec96bd3473d5ad31bff9dc4beef31c2639282c9f7c55b84f1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ 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.33] - 2026-08-28
9
+
10
+ ### Changed
11
+
12
+ - **Cached namespace binding sets**: every namespaced query
13
+ previously built its binding set from scratch (flatten the hash,
14
+ allocate the CStringArray wire format, `ns_set_new_from_pairs`,
15
+ eval, free) — ~6.7 µs of construction, 2.6× the same query
16
+ without namespaces on a small document. The XPath VM reads the
17
+ set as a const map during evaluation (vm.c) and never mutates
18
+ it, so sets are now cached per distinct prefix/URI vocabulary and
19
+ shared across queries and threads. Measured: namespaced
20
+ `//x:rect` 10.9 -> **6.9 µs per query (-38%)**; `at_xpath` -26%.
21
+ Single-pair key fast path; failed builds raise before caching;
22
+ symbol- and string-keyed hashes share one entry. The remaining
23
+ gap vs no-namespace queries is the VM's prefix resolution.
24
+
25
+ ## [1.9.32] - 2026-08-28
26
+
27
+ ### Fixed
28
+
29
+ - **traverse is subtree-bounded (leptris-ruby#89)**: the C walker
30
+ was never bounded — after visiting the receiver it pushed the
31
+ receiver's next sibling and continued to the end of the document
32
+ chain, so `element.traverse` swept following siblings and their
33
+ subtrees, and `root.traverse` swept the epilog. In post-order the
34
+ receiver is the LAST node of its own subtree, so the callback now
35
+ returns non-zero at self — the C loop honors it and the walk
36
+ stops exactly at the boundary.
37
+ - **traverse re-raises callback exceptions (leptris-ruby#90)**: a
38
+ rescue inside the FFI callback stashes the exception and returns
39
+ non-zero (aborting the walk); the stashed exception is re-raised
40
+ after `leptris_node_traverse` returns. Previously the dispatch
41
+ silently swallowed Ruby exceptions and the walk continued with
42
+ partially processed data.
43
+ - **built documents list the attached root (leptris-ruby#91)**:
44
+ libleptris's `document_set_root` does not register the root into
45
+ the document node's child chain, so `Document#children` missed it
46
+ until another document mutation refreshed the chain. `#children`
47
+ now splices the attached root in: a replaced root's stale chain
48
+ entry is dropped and the new root inherits its position (the
49
+ prolog/epilog split follows the old slot); on rootless chains
50
+ placement falls back to document-order comparison. Parsed and
51
+ built documents read the same.
52
+ - **document-level PI writes name the contract (leptris-ruby#92)**:
53
+ parse-created document-level PIs carry no document linkage in
54
+ libleptris, so `target=`/`data=` fail INVALID_ARG and `unlink`
55
+ NOT_FOUND while tree-level and `Document#add_pi` PIs mutate fine.
56
+ Those failures now raise a descriptive error naming the contract
57
+ (mutable alternatives included) instead of a bare "Invalid
58
+ argument". Full write-through and a remove API need C surface
59
+ (filed upstream); the binding cannot supply them.
60
+
8
61
  ## [1.9.31] - 2026-08-28
9
62
 
10
63
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.31"
4
+ VERSION = "1.9.33"
5
5
  end
@@ -166,9 +166,44 @@ class Leptris::XML::Document
166
166
  # The document's children, via the document node: prolog
167
167
  # comments/PIs, the root element, epilog comments/PIs, in
168
168
  # document order (Nokogiri-parity shape).
169
+ #
170
+ # On programmatically built documents the C document node's chain
171
+ # misses a root attached via #root= until some other document
172
+ # mutation refreshes it (leptris-ruby#91 — libleptris's
173
+ # document_set_root does not register into the chain). The merge
174
+ # below splices the attached root in by document order whenever
175
+ # the chain lacks it, so parsed and built documents read the
176
+ # same. Prolog/epilog placement uses node_compare against the
177
+ # root.
169
178
  def children
170
179
  doc_node = node
171
- doc_node ? doc_node.children : Leptris::XML::NodeSet.new(self, [])
180
+ return Leptris::XML::NodeSet.new(self, []) if doc_node.nil?
181
+ kids = doc_node.children.to_a
182
+ root_ptr = Leptris::XML::FFI.leptris_document_root(@c_ptr)
183
+ return kids if root_ptr.null?
184
+ return kids if kids.any? { |child| child.c_ptr == root_ptr }
185
+ root = Leptris::XML::Node.wrap(root_ptr, self)
186
+ # A replaced root stays in the C chain until another document
187
+ # mutation refreshes it — the chain's element slots other than
188
+ # the current root are stale old roots. The new root inherits
189
+ # the first stale slot's position (the prolog/epilog split
190
+ # follows where the old root sat); with no stale element
191
+ # (programmatically built documents) placement falls back to
192
+ # document-order comparison.
193
+ stale = kids.select do |child|
194
+ child.element? && child.c_ptr != root_ptr
195
+ end
196
+ if stale.any?
197
+ kept = kids.reject { |child| stale.include?(child) }
198
+ slot = kids.index(stale.first)
199
+ kept.insert(slot, root)
200
+ Leptris::XML::NodeSet.new(self, kept)
201
+ else
202
+ prolog, epilog = kids.partition do |child|
203
+ Leptris::XML::FFI.leptris_node_compare(child.c_ptr, root_ptr).negative?
204
+ end
205
+ Leptris::XML::NodeSet.new(self, prolog + [root] + epilog)
206
+ end
172
207
  end
173
208
 
174
209
  # Attach +element+ as the document's root element. The element must
@@ -800,21 +800,37 @@ module Leptris
800
800
 
801
801
  # Namespace-binding lifecycle, written once: flatten the
802
802
  # prefix/URI hash into the alternating CStringArray wire
803
- # format, build the caller-owned set, yield it, free it under
804
- # all outcomes. Every eval variant uses this; none of them
805
- # knows how a set is born or dies.
803
+ # format and build the C set. Sets are cached per distinct
804
+ # vocabulary: the XPath VM reads the set as a const map
805
+ # during evaluation (vm.c) and never mutates it, so one set
806
+ # serves the process across queries and threads — building
807
+ # the wire format plus a C set per query cost ~6.7 µs, 2.6×
808
+ # a no-namespace query on a small document. Cached sets are
809
+ # never freed (bounded by the process's distinct namespace
810
+ # vocabularies — the same trade the CSS translation cache
811
+ # makes). Failed builds raise before caching.
812
+ NS_SET_CACHE = {}
813
+
806
814
  def self.with_ns_set(hash)
807
- flat = hash.flat_map { |prefix, uri| [prefix.to_s, uri.to_s] }
808
- buffer, _anchors = Leptris::XML::CStringArray.to_c(flat)
809
- set = leptris_xpath_ns_set_new_from_pairs(buffer, flat.length / 2)
810
- if set.null?
811
- raise Leptris::XML::Error,
812
- "leptris_xpath_ns_set_new_from_pairs failed"
813
- end
814
- begin
815
- yield set
816
- ensure
817
- leptris_xpath_ns_set_free(set)
815
+ yield ns_set_for(hash)
816
+ end
817
+
818
+ def self.ns_set_for(hash)
819
+ pairs = hash.map { |prefix, uri| [prefix.to_s, uri.to_s] }
820
+ key = if pairs.size == 1
821
+ "#{pairs[0][0]}\x00#{pairs[0][1]}"
822
+ else
823
+ pairs.sort.join("\x00")
824
+ end
825
+ NS_SET_CACHE.fetch(key) do
826
+ flat = pairs.flatten
827
+ buffer, _anchors = Leptris::XML::CStringArray.to_c(flat)
828
+ set = leptris_xpath_ns_set_new_from_pairs(buffer, flat.length / 2)
829
+ if set.null?
830
+ raise Leptris::XML::Error,
831
+ "leptris_xpath_ns_set_new_from_pairs failed"
832
+ end
833
+ NS_SET_CACHE[key] = set
818
834
  end
819
835
  end
820
836
 
@@ -263,27 +263,56 @@ class Leptris::XML::Node
263
263
 
264
264
  def unlink
265
265
  ensure_writable!
266
- Leptris::XML::FFI.check_status(
267
- Leptris::XML::FFI.leptris_node_unlink(@c_ptr))
266
+ rc = Leptris::XML::FFI.leptris_node_unlink(@c_ptr)
267
+ if rc == Leptris::XML::FFI::LEPTRIS_ERROR_NOT_FOUND && processing_instruction?
268
+ raise Leptris::XML::Error,
269
+ Leptris::XML::ProcessingInstruction::READ_ONLY_NATIVE
270
+ end
271
+ Leptris::XML::FFI.check_status(rc)
268
272
  @parent = nil
269
273
  self
270
274
  end
271
275
  alias_method :remove, :unlink
272
276
 
273
- # Walks the subtree in post-order DFS (matches Nokogiri's semantics).
277
+ # Walks the subtree in post-order DFS (matches Nokogiri's
278
+ # semantics): the receiver, its descendants, nothing else.
274
279
  #
275
280
  # One FFI call dispatches the whole walk; the C engine invokes the
276
281
  # callback once per visited node (the only per-node cost is the
277
282
  # C-to-Ruby callback dispatch, not FFI round-trips).
283
+ #
284
+ # Subtree-bounded by abort-at-self (leptris-ruby#89): the C
285
+ # walker was never bounded — after visiting the receiver it
286
+ # pushes the receiver's NEXT SIBLING and continues to the end of
287
+ # the document chain. In post-order the receiver is the LAST
288
+ # node of its own subtree, so returning non-zero at self stops
289
+ # the walk exactly at the boundary (the C loop honors a non-zero
290
+ # callback return). The self comparison is by address; the
291
+ # receiver's handle is stable for the walk's duration.
292
+ #
293
+ # Exceptions raised by the block are re-raised after the walk
294
+ # (leptris-ruby#90): a rescue inside the callback stashes the
295
+ # exception and returns non-zero, aborting the C walk — without
296
+ # it the FFI dispatch silently swallowed the exception and the
297
+ # walk continued with partially processed data.
278
298
  def traverse
279
299
  return enum_for(:traverse) unless block_given?
280
300
  ensure_alive!
301
+ error = nil
302
+ self_address = @c_ptr.address
281
303
  callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
282
- yield Leptris::XML::Node.wrap(node_ptr, @document)
283
- 0
304
+ begin
305
+ yield Leptris::XML::Node.wrap(node_ptr, @document)
306
+ node_ptr.address == self_address ? 1 : 0
307
+ rescue Exception => e # rubocop:disable Lint/RescueException
308
+ error = e
309
+ 1
310
+ end
284
311
  end
285
312
  Leptris::XML::FFI.leptris_node_traverse(
286
313
  @c_ptr, Leptris::XML::FFI::TRAVERSE_POST_ORDER, callback, nil)
314
+ raise error if error
315
+ self
287
316
  end
288
317
 
289
318
  def path
@@ -25,17 +25,34 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
25
25
  result
26
26
  end
27
27
 
28
+ # Parse-created document-level PIs carry no document linkage in
29
+ # libleptris (leptris-ruby#92): the C setters reject them with
30
+ # INVALID_ARG while tree-level and Document#add_pi PIs mutate
31
+ # fine. Map that failure to the contract instead of a bare
32
+ # "Invalid argument".
33
+ READ_ONLY_NATIVE =
34
+ "this PI is a parse-created document-level PI — its node has " \
35
+ "no document linkage in libleptris, so target/data cannot be " \
36
+ "written and it cannot be unlinked (leptris-ruby#92); " \
37
+ "tree-level and Document#add_pi PIs are mutable"
38
+
28
39
  def target=(new_target)
29
40
  ensure_writable!
30
- Leptris::XML::FFI.check_status(
31
- Leptris::XML::FFI.leptris_pi_node_set_target(@c_ptr, new_target.to_s))
41
+ rc = Leptris::XML::FFI.leptris_pi_node_set_target(
42
+ @c_ptr, new_target.to_s)
43
+ raise Leptris::XML::Error, READ_ONLY_NATIVE if
44
+ rc == Leptris::XML::FFI::LEPTRIS_ERROR_INVALID_ARG
45
+ Leptris::XML::FFI.check_status(rc)
32
46
  new_target
33
47
  end
34
48
 
35
49
  def data=(new_data)
36
50
  ensure_writable!
37
- Leptris::XML::FFI.check_status(
38
- Leptris::XML::FFI.leptris_pi_node_set_data(@c_ptr, new_data.to_s))
51
+ rc = Leptris::XML::FFI.leptris_pi_node_set_data(
52
+ @c_ptr, new_data.to_s)
53
+ raise Leptris::XML::Error, READ_ONLY_NATIVE if
54
+ rc == Leptris::XML::FFI::LEPTRIS_ERROR_INVALID_ARG
55
+ Leptris::XML::FFI.check_status(rc)
39
56
  new_data
40
57
  end
41
58
  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.31
4
+ version: 1.9.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.