leptris 1.9.11 → 1.9.13

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: 4e218d64aa7c30174d4fe7a22e47cc78c35f07cbfe3d0f22430f3ba411651143
4
- data.tar.gz: 030eed3a6b5a7387da757ae20406591490a1f55893425d63a443c77631903a6f
3
+ metadata.gz: 590285b33ed6aac05def5ff85268822fc6d5abf87cfd5b262d51b72868f70ac3
4
+ data.tar.gz: 8dc09c12f495c12177a5dd15ff8892340dc090d678eb342738571bfb0e142228
5
5
  SHA512:
6
- metadata.gz: 45c707da19fc4114c5f35641b799e104130a8d7c93d8fd365fe72cafec0b894f80e06dba7a73d0109b0fde77868d5db3836035411a3d9951d2f5c3db3c7c8a0f
7
- data.tar.gz: '06388d993c6331e3089ad5c9f87056d978096f8bbd56598a1e8abdd37db03575e7d79989f3ed9ef5ba836d58804daaa1039d1b22e2c30c6b1bc4fb59d88fbd85'
6
+ metadata.gz: 2781b4465ff07a85ea7ecc91824070da8412aa933bc96273a4b9159860c95a0e227e06630967685e66db9fd696a6e5648ec9474217ecae769b8163096adf18ed
7
+ data.tar.gz: 9d7173b435723be6f068700e27e05f796246143dcf22dd1319079c5770a71f3f933fdd7d517a71ee47cba58f1305ed2d2e270ffaee43c422c43d693dbb77f4f7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ 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.13] - 2026-08-27
9
+
10
+ ### Changed
11
+
12
+ - **Cached-true readonly guard**: readonly is one-way, so a node
13
+ caches TRUE once observed (FALSE stays uncached — the document
14
+ may still flip). Per-read guards drop from a three-call document
15
+ round-trip to one ivar check. Harness attr loop 0.061 -> 0.048 s.
16
+ - **Lazy wrapper-cache allocation**: Documents no longer allocate
17
+ the wrapper-identity Hash up front; parse-heavy loops that free
18
+ before re-reading stop paying it (~9% on the tiny-doc
19
+ parse-query-serialize loop). Identity semantics unchanged.
20
+
21
+ ### Measured dead
22
+
23
+ - Further readonly-[] trimming: the isolated read is at the Ruby
24
+ call-chain floor (~227 ns across ~7 calls); inlining the
25
+ attributes build would duplicate memo logic for ~10%.
26
+
27
+ ## [1.9.12] - 2026-08-27
28
+
29
+ ### Changed
30
+
31
+ - **NodeSet#each materializes on the first pass**: the lazy batch
32
+ fetch builds the array while yielding; every later each/[]/length
33
+ serves from it. Iterating twice without an explicit to_a paid the
34
+ batch twice — measured 0.220 s -> 0.127 s (42%) on the repeated-
35
+ iteration loop, now matching the materialized shape.
36
+ - **Leaner memo guard**: `readonly_cached?` checks ivar presence
37
+ alone — memo presence proves readonly, because every memo site
38
+ assigns only under readonly and readonly is one-way. Saves a
39
+ document round-trip on every memoized read; the harness's
40
+ readonly loops drop another 22-45% (attrs 0.078 -> 0.061 s,
41
+ namespaces 0.033 -> 0.018 s, content 0.066 -> 0.037 s).
42
+
43
+ ### Measured and rejected
44
+
45
+ - SAX bulk pointer read for attribute pairs: a bulk read needs a
46
+ - counting pass first, which adds calls to a one-pass walk — no
47
+ win, withdrawn on inspection.
48
+ - Unconditional writable-content memoization and version-stamp
49
+ memoization (see the round-X report): invalidation completeness
50
+ and a compare costing the read, respectively.
51
+
8
52
  ## [1.9.11] - 2026-08-26
9
53
 
10
54
  ### Changed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.11"
4
+ VERSION = "1.9.13"
5
5
  end
@@ -3,7 +3,7 @@
3
3
  require "ffi"
4
4
 
5
5
  class Leptris::XML::Document
6
- attr_reader :c_ptr, :wrapper_cache
6
+ attr_reader :c_ptr
7
7
 
8
8
  # @api private
9
9
  # Internal flag container shared between the Document instance and its
@@ -31,7 +31,13 @@ class Leptris::XML::Document
31
31
  # the weak map — any GC sweep evicted it and the second call built
32
32
  # a fresh object. A strong cache costs at most one wrapper per node
33
33
  # actually visited, held until the document dies.
34
- @wrapper_cache = {}
34
+ #
35
+ # Allocated lazily: parse-heavy loops stop paying one Hash per
36
+ # document for trees that are freed before any wrap.
37
+ end
38
+
39
+ def wrapper_cache
40
+ @wrapper_cache ||= {}
35
41
  end
36
42
 
37
43
  def self.parse(xml_or_io, options: nil, readonly: false, recover: false)
@@ -230,7 +236,7 @@ class Leptris::XML::Document
230
236
  @freed.state = :freed
231
237
  Leptris::XML::FFI.leptris_document_free(@c_ptr) unless @c_ptr.nil?
232
238
  @c_ptr = nil
233
- @wrapper_cache.clear
239
+ @wrapper_cache&.clear
234
240
  end
235
241
 
236
242
  # Enable the first-party EXSLT-style extension pack on this
@@ -36,7 +36,7 @@ class Leptris::XML::Element < Leptris::XML::Node
36
36
  # materializes on demand — repeated reads become hash lookups,
37
37
  # with no dispatch and no lifetime guard (a hash read cannot
38
38
  # use-after-free).
39
- if @document&.readonly?
39
+ if readonly_document?
40
40
  cached = attributes[key.to_s]
41
41
  return cached.nil? ? nil : cached.value
42
42
  end
@@ -104,12 +104,22 @@ class Leptris::XML::Node
104
104
  # UseAfterFreeError when it was freed.
105
105
  def ensure_writable!
106
106
  ensure_alive!
107
- if @document&.readonly?
107
+ if readonly_document?
108
108
  raise Leptris::XML::ReadOnlyError,
109
109
  "document is readonly — mutation attempted on #{inspect}"
110
110
  end
111
111
  end
112
112
 
113
+ # Readonly is one-way, so caching TRUE is sound: once observed,
114
+ # the document is readonly forever. FALSE stays uncached (the
115
+ # document may still flip). Saves the document round-trip on
116
+ # per-read guards.
117
+ def readonly_document?
118
+ return true if instance_variable_defined?(:@readonly_document)
119
+ return false unless @document&.readonly?
120
+ @readonly_document = true
121
+ end
122
+
113
123
  def line
114
124
  ensure_alive!
115
125
  Leptris::XML::FFI.leptris_node_line(@c_ptr)
@@ -264,8 +274,11 @@ class Leptris::XML::Node
264
274
 
265
275
  protected
266
276
 
277
+ # Memo presence alone proves readonly: every memo site assigns
278
+ # only under readonly, and readonly is one-way — so the guard
279
+ # saves the document round-trip on every memoized read.
267
280
  def readonly_cached?(ivar)
268
- @document&.readonly? && instance_variable_defined?(ivar)
281
+ instance_variable_defined?(ivar)
269
282
  end
270
283
 
271
284
  def as_element_or_self
@@ -67,43 +67,46 @@ class Leptris::XML::NodeSet
67
67
  end
68
68
  end
69
69
 
70
+ # Iteration materializes: the first pass batch-fetches into @array
71
+ # (yielding as it wraps), and every subsequent reader — each, [],
72
+ # length — serves from the array. Repeated iteration without an
73
+ # explicit to_a pays the batch exactly once.
70
74
  def each
71
75
  return enum_for(:each) unless block_given?
72
- return @array.each { |n| yield n } if @array
73
- if @result_ptr
74
- # Batch-fetch all node pointers through the FFI seam
75
- # (leptris_xpath_result_get_nodes_ex copies every node kind,
76
- # not just elements) and wrap each. Wrappers are cached
77
- # per-Document via Node.wrap, so re-iteration of the same
78
- # NodeSet hits it.
76
+ unless @array
77
+ return self unless @result_ptr
79
78
  n = length
80
79
  if n > 0
80
+ # Batch-fetch all node pointers through the FFI seam
81
+ # (leptris_xpath_result_get_nodes_ex copies every node kind,
82
+ # not just elements); the batch accessor under-copies
83
+ # mixed-kind results (upstream leptris#477), so per-index
84
+ # fetch covers the remainder.
81
85
  pointers = Leptris::XML::FFI.fetch_result_nodes(@result_ptr, n)
86
+ nodes = []
82
87
  pointers.each do |ptr|
83
88
  next if ptr.null?
84
- yield Leptris::XML::Node.wrap(ptr, @document)
89
+ nodes << Leptris::XML::Node.wrap(ptr, @document)
85
90
  end
86
- # The batch accessor under-copies mixed-kind results (upstream
87
- # leptris#477); fall back to per-index fetch for the remainder.
88
91
  (pointers.length...n).each do |i|
89
92
  ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
90
93
  next if ptr.null?
91
- yield Leptris::XML::Node.wrap(ptr, @document)
94
+ nodes << Leptris::XML::Node.wrap(ptr, @document)
92
95
  end
96
+ @array = nodes
97
+ else
98
+ @array = []
93
99
  end
94
- else
95
- @array.each { |n| yield n }
96
100
  end
101
+ @array.each { |n| yield n }
97
102
  self
98
103
  end
99
104
 
100
105
  def to_a
101
106
  return @array if @array
102
107
  return [] if @result_ptr.nil?
103
- out = []
104
- each { |n| out << n }
105
- @array = out # memoize so subsequent to_a / each avoids re-fetch
106
- out
108
+ each { |n| } # materializes @array as a side effect
109
+ @array
107
110
  end
108
111
  alias_method :to_ary, :to_a
109
112
 
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.11
4
+ version: 1.9.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.