leptris 1.9.12-aarch64-linux → 1.9.14-aarch64-linux

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: cf80f624ccd37fd9495e26c150943bc07923b82fd3e96eacb0d115d2a2651f5d
4
- data.tar.gz: f7974a661b342dacb962a6e92bc7c9634d3a606b0ab1c2300262c469420b4bd2
3
+ metadata.gz: c96f59841d0606a790ec372576cdb05f50bbc39c43cdf1188e7a4535068a7a8f
4
+ data.tar.gz: e3396a5ec39b5638f20a2254da0796f6330ca689f62e2fcad939850a42662f64
5
5
  SHA512:
6
- metadata.gz: b4524b049a05c7dffe328ccb16742dede857b442b8dc5443ceddb9aad27c9b71ea7ab02bcf7d0d6b6fc724828587b3557d11042b939c15c97727d7ed048384e2
7
- data.tar.gz: 74811080bd538e8b9023de303cbca5ade92c6005cb76aebd72e548c44b9504fae44dfa44055b7d34d2e157c5b779a330cc5da2c875fc485574728fcaaf237e26
6
+ metadata.gz: a6cc9bb4c0ba7b0c39ced816d8435370947d6e051df16d1d03a899c5fbbdd1a7ba1e0c7e0a05f7879874a4b20c5db7b2f7b639138eb0137bbb1276e1288b7b59
7
+ data.tar.gz: 12846d96eec9f9a3d681bdb1bd9a2e29807a21575adc209851bdb4b3067f8c5999732faa9a4bdfad0af1ac6e55cdac6f52c832d886623f137c8b0aa867bc5ba2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ 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.14] - 2026-08-27
9
+
10
+ ### Changed
11
+
12
+ - **Writable documents memoize** — the versioned read cache. A
13
+ per-document mutation version advances at every mutation gate
14
+ (ensure_writable!, root=, add_pi); each memoized field carries
15
+ its own version stamp and recomputes after any bump. Readonly
16
+ semantics unchanged (their version never advances — memos
17
+ forever valid). Measured on writable documents: namespace
18
+ inspection 0.392 s -> 0.013 s (**31x**, readonly parity),
19
+ children 0.154 s -> 0.014 s (**11x**). The read-heavy
20
+ DOM-editing workload (parse, query repeatedly, mutate
21
+ occasionally) now performs like the readonly one between
22
+ mutations. Staleness is test-pinned: a mutation-invalidation
23
+ matrix covers content, attribute, structural, namespace, root=,
24
+ and PI mutations against every memoized read. The shared
25
+ node-level stamp variant was tried and rejected in favor of
26
+ per-field stamps (one field's recompute must not resurrect
27
+ another's stale memo). ADR 0003 extended.
28
+ - NodeSet#[] drops the Ruby-side bounds FFI (the C accessor
29
+ already returns NULL out of range).
30
+
31
+ ## [1.9.13] - 2026-08-27
32
+
33
+ ### Changed
34
+
35
+ - **Cached-true readonly guard**: readonly is one-way, so a node
36
+ caches TRUE once observed (FALSE stays uncached — the document
37
+ may still flip). Per-read guards drop from a three-call document
38
+ round-trip to one ivar check. Harness attr loop 0.061 -> 0.048 s.
39
+ - **Lazy wrapper-cache allocation**: Documents no longer allocate
40
+ the wrapper-identity Hash up front; parse-heavy loops that free
41
+ before re-reading stop paying it (~9% on the tiny-doc
42
+ parse-query-serialize loop). Identity semantics unchanged.
43
+
44
+ ### Measured dead
45
+
46
+ - Further readonly-[] trimming: the isolated read is at the Ruby
47
+ call-chain floor (~227 ns across ~7 calls); inlining the
48
+ attributes build would duplicate memo logic for ~10%.
49
+
8
50
  ## [1.9.12] - 2026-08-27
9
51
 
10
52
  ### Changed
@@ -1,4 +1,20 @@
1
- # ADR 0003: readonly memoization stays hand-rolled
1
+ # ADR 0003: memoization stays hand-rolled; writable docs memoize via version stamps
2
+
3
+ ## 2026-08-27 extension (leptris-ruby 1.9.14)
4
+
5
+ Writable documents now memoize too: Document carries a mutation
6
+ version advanced at every mutation gate (Node#ensure_writable!,
7
+ root=, add_pi), and each memoized field stores its OWN version
8
+ stamp (`return @x if memo_hit?(@x_version)`). A shared node-level
9
+ stamp was tried and rejected — one field's recompute resurrects
10
+ another field's stale memo. Readonly semantics are unchanged
11
+ (their version never advances). Round X's "version-stamp rejected:
12
+ the compare costs the read" was corrected: the compare is cheap
13
+ against multi-FFI reads.
14
+
15
+ The hand-rolled per-site pattern (below) still stands; only the
16
+ guard gained a version argument.
17
+
2
18
 
3
19
  ## Context
4
20
  Every readonly-mode read repeats the same three lines:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.12"
4
+ VERSION = "1.9.14"
5
5
  end
@@ -4,11 +4,14 @@ class Leptris::XML::CDATA < Leptris::XML::Text
4
4
  def name; "#cdata-section"; end
5
5
 
6
6
  def content
7
- return @content if readonly_cached?(:@content)
7
+ return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr).tap do |text|
10
- @content = text if @document&.readonly?
9
+ result = Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr)
10
+ if @document
11
+ @content = result
12
+ @content_version = @document.version
11
13
  end
14
+ result
12
15
  end
13
16
 
14
17
  def content=(new_content)
@@ -4,11 +4,14 @@ class Leptris::XML::Comment < Leptris::XML::Node
4
4
  def name; "comment"; end
5
5
 
6
6
  def content
7
- return @content if readonly_cached?(:@content)
7
+ return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr).tap do |text|
10
- @content = text if @document&.readonly?
9
+ result = Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr)
10
+ if @document
11
+ @content = result
12
+ @content_version = @document.version
11
13
  end
14
+ result
12
15
  end
13
16
 
14
17
  def content=(new_content)
@@ -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
@@ -14,10 +14,27 @@ class Leptris::XML::Document
14
14
  # `Document#free` was called explicitly and then GC ran.
15
15
  Freed = Struct.new(:state) # state: :alive | :freed
16
16
 
17
+ # Mutation version: advanced by every data mutation (via
18
+ # Node#ensure_writable!, root=, add_pi). Node memos stamp the
19
+ # version they were computed under and recompute after any bump —
20
+ # the invalidation that makes WRITABLE-document memoization sound.
21
+ # Readonly documents never advance it, so their memos are forever
22
+ # valid (ADR 0003 semantics, unchanged).
23
+ def version
24
+ @version
25
+ end
26
+
27
+ # Called by the mutation gates (Node#ensure_writable!, root=,
28
+ # add_pi) — every memo stamped with an older version discards.
29
+ def advance_version
30
+ @version += 1
31
+ end
32
+
17
33
  def initialize(c_ptr = nil, freed = Freed.new(:alive))
18
34
  @c_ptr = c_ptr
19
35
  @freed = freed
20
36
  @readonly = false
37
+ @version = 0
21
38
  # Per-document STRONG cache for Node wrappers, keyed on c_ptr
22
39
  # address. Every wrapper is created through Node.wrap, which is the
23
40
  # single construction path, so the same C node always yields the
@@ -31,7 +48,13 @@ class Leptris::XML::Document
31
48
  # the weak map — any GC sweep evicted it and the second call built
32
49
  # a fresh object. A strong cache costs at most one wrapper per node
33
50
  # actually visited, held until the document dies.
34
- @wrapper_cache = {}
51
+ #
52
+ # Allocated lazily: parse-heavy loops stop paying one Hash per
53
+ # document for trees that are freed before any wrap.
54
+ end
55
+
56
+ def wrapper_cache
57
+ @wrapper_cache ||= {}
35
58
  end
36
59
 
37
60
  def self.parse(xml_or_io, options: nil, readonly: false, recover: false)
@@ -136,6 +159,7 @@ class Leptris::XML::Document
136
159
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
137
160
  Leptris::XML::FFI.check_status(
138
161
  Leptris::XML::FFI.leptris_document_set_root(@c_ptr, element.c_ptr))
162
+ @version += 1
139
163
  element
140
164
  end
141
165
 
@@ -230,7 +254,7 @@ class Leptris::XML::Document
230
254
  @freed.state = :freed
231
255
  Leptris::XML::FFI.leptris_document_free(@c_ptr) unless @c_ptr.nil?
232
256
  @c_ptr = nil
233
- @wrapper_cache.clear
257
+ @wrapper_cache&.clear
234
258
  end
235
259
 
236
260
  # Enable the first-party EXSLT-style extension pack on this
@@ -247,15 +271,14 @@ class Leptris::XML::Document
247
271
  # Document-level processing instructions (not tree nodes):
248
272
  # an array of [target, data] pairs in document order.
249
273
  def processing_instructions
250
- if readonly? && instance_variable_defined?(:@processing_instructions)
251
- return @processing_instructions
252
- end
274
+ return @processing_instructions if @pi_version == @version
253
275
  count = Leptris::XML::FFI.leptris_document_pi_count(@c_ptr)
254
276
  result = count.times.map do |i|
255
277
  [Leptris::XML::FFI.leptris_document_pi_target(@c_ptr, i),
256
278
  Leptris::XML::FFI.leptris_document_pi_data(@c_ptr, i)]
257
279
  end
258
- @processing_instructions = result if readonly?
280
+ @processing_instructions = result
281
+ @pi_version = @version
259
282
  result
260
283
  end
261
284
 
@@ -264,6 +287,7 @@ class Leptris::XML::Document
264
287
  witness = Leptris::XML::FFI.leptris_document_add_pi(
265
288
  @c_ptr, target.to_s, data.to_s)
266
289
  raise Leptris::XML::Error, "leptris_document_add_pi failed" if witness.null?
290
+ @version += 1
267
291
  self
268
292
  end
269
293
 
@@ -17,11 +17,14 @@ class Leptris::XML::Element < Leptris::XML::Node
17
17
  alias_method :node_name=, :name=
18
18
 
19
19
  def content
20
- return @content if readonly_cached?(:@content)
20
+ return @content if memo_hit?(@content_version)
21
21
  ensure_alive!
22
- Leptris::XML::FFI.leptris_element_text(@c_ptr).tap do |text|
23
- @content = text if @document&.readonly?
22
+ result = Leptris::XML::FFI.leptris_element_text(@c_ptr)
23
+ if @document
24
+ @content = result
25
+ @content_version = @document.version
24
26
  end
27
+ result
25
28
  end
26
29
 
27
30
  def content=(new_content)
@@ -36,7 +39,7 @@ class Leptris::XML::Element < Leptris::XML::Node
36
39
  # materializes on demand — repeated reads become hash lookups,
37
40
  # with no dispatch and no lifetime guard (a hash read cannot
38
41
  # use-after-free).
39
- if @document&.readonly?
42
+ if readonly_document?
40
43
  cached = attributes[key.to_s]
41
44
  return cached.nil? ? nil : cached.value
42
45
  end
@@ -104,31 +107,43 @@ class Leptris::XML::Element < Leptris::XML::Node
104
107
  end
105
108
 
106
109
  def keys
107
- return @keys if readonly_cached?(:@keys)
110
+ return @keys if memo_hit?(@keys_version)
108
111
  result = each_attribute.to_a.map(&:name)
109
- @keys = result if @document&.readonly?
112
+ if @document
113
+ @keys = result
114
+ @keys_version = @document.version
115
+ end
110
116
  result
111
117
  end
112
118
 
113
119
  def values
114
- return @values if readonly_cached?(:@values)
120
+ return @values if memo_hit?(@values_version)
115
121
  result = each_attribute.to_a.map(&:value)
116
- @values = result if @document&.readonly?
122
+ if @document
123
+ @values = result
124
+ @values_version = @document.version
125
+ end
117
126
  result
118
127
  end
119
128
 
120
129
  def attributes
121
- return @attributes if readonly_cached?(:@attributes)
130
+ return @attributes if memo_hit?(@attributes_version)
122
131
  result = {}
123
132
  each_attribute { |attr| result[attr.name] = attr }
124
- @attributes = result if @document&.readonly?
133
+ if @document
134
+ @attributes = result
135
+ @attributes_version = @document.version
136
+ end
125
137
  result
126
138
  end
127
139
 
128
140
  def attribute_nodes
129
- return @attribute_nodes if readonly_cached?(:@attribute_nodes)
141
+ return @attribute_nodes if memo_hit?(@attribute_nodes_version)
130
142
  result = each_attribute.to_a
131
- @attribute_nodes = result if @document&.readonly?
143
+ if @document
144
+ @attribute_nodes = result
145
+ @attribute_nodes_version = @document.version
146
+ end
132
147
  result
133
148
  end
134
149
 
@@ -247,7 +262,7 @@ class Leptris::XML::Element < Leptris::XML::Node
247
262
  alias_method :<<, :add_child
248
263
 
249
264
  def namespace
250
- return @namespace if readonly_cached?(:@namespace)
265
+ return @namespace if memo_hit?(@namespace_version)
251
266
  ensure_alive!
252
267
  uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
253
268
  result =
@@ -261,12 +276,15 @@ class Leptris::XML::Element < Leptris::XML::Node
261
276
  prefix = nil if prefix.nil? || prefix.empty?
262
277
  Leptris::XML::Namespace.new(self, uri, prefix: prefix)
263
278
  end
264
- @namespace = result if @document&.readonly?
279
+ if @document
280
+ @namespace = result
281
+ @namespace_version = @document.version
282
+ end
265
283
  result
266
284
  end
267
285
 
268
286
  def namespace_definitions
269
- return @namespace_definitions if readonly_cached?(:@namespace_definitions)
287
+ return @namespace_definitions if memo_hit?(@namespace_definitions_version)
270
288
  ensure_alive!
271
289
  count = Leptris::XML::FFI.leptris_element_namespace_count(@c_ptr)
272
290
  result = count.times.map do |i|
@@ -274,12 +292,15 @@ class Leptris::XML::Element < Leptris::XML::Node
274
292
  uri = Leptris::XML::FFI.leptris_element_namespace_decl_uri(@c_ptr, i)
275
293
  Leptris::XML::Namespace.new(self, uri, prefix: prefix)
276
294
  end
277
- @namespace_definitions = result if @document&.readonly?
295
+ if @document
296
+ @namespace_definitions = result
297
+ @namespace_definitions_version = @document.version
298
+ end
278
299
  result
279
300
  end
280
301
 
281
302
  def namespaces
282
- return @namespaces if readonly_cached?(:@namespaces)
303
+ return @namespaces if memo_hit?(@namespaces_version)
283
304
  scopes = {}
284
305
  node = self
285
306
  while node.is_a?(Leptris::XML::Element)
@@ -289,7 +310,10 @@ class Leptris::XML::Element < Leptris::XML::Node
289
310
  end
290
311
  node = node.parent
291
312
  end
292
- @namespaces = scopes if @document&.readonly?
313
+ if @document
314
+ @namespaces = scopes
315
+ @namespaces_version = @document.version
316
+ end
293
317
  scopes
294
318
  end
295
319
 
@@ -101,13 +101,29 @@ class Leptris::XML::Node
101
101
  end
102
102
 
103
103
  # Raises ReadOnlyError when the owning document was marked readonly,
104
- # UseAfterFreeError when it was freed.
104
+ # UseAfterFreeError when it was freed. Every node-level mutation
105
+ # passes through this gate, so it is where the document's mutation
106
+ # version advances — the invalidation behind writable-document
107
+ # memoization. Bumping before the C call is conservative: a failed
108
+ # mutation merely discards memos.
105
109
  def ensure_writable!
106
110
  ensure_alive!
107
- if @document&.readonly?
111
+ if readonly_document?
108
112
  raise Leptris::XML::ReadOnlyError,
109
113
  "document is readonly — mutation attempted on #{inspect}"
110
114
  end
115
+ @document.advance_version
116
+ nil
117
+ end
118
+
119
+ # Readonly is one-way, so caching TRUE is sound: once observed,
120
+ # the document is readonly forever. FALSE stays uncached (the
121
+ # document may still flip). Saves the document round-trip on
122
+ # per-read guards.
123
+ def readonly_document?
124
+ return true if instance_variable_defined?(:@readonly_document)
125
+ return false unless @document&.readonly?
126
+ @readonly_document = true
111
127
  end
112
128
 
113
129
  def line
@@ -132,14 +148,17 @@ class Leptris::XML::Node
132
148
  def children
133
149
  # Immutable in readonly mode: the batch fetch plus wrapper
134
150
  # construction is paid once.
135
- return @children if readonly_cached?(:@children)
151
+ return @children if memo_hit?(@children_version)
136
152
  ensure_alive!
137
153
  parent = as_element_or_self
138
154
  nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
139
155
  Leptris::XML::Node.wrap(ptr, @document, parent: parent)
140
156
  end
141
157
  result = Leptris::XML::NodeSet.new(@document, nodes)
142
- @children = result if @document&.readonly?
158
+ if @document
159
+ @children = result
160
+ @children_version = @document.version
161
+ end
143
162
  result
144
163
  end
145
164
 
@@ -175,9 +194,12 @@ class Leptris::XML::Node
175
194
  end
176
195
 
177
196
  def element_children
178
- return @element_children if readonly_cached?(:@element_children)
197
+ return @element_children if memo_hit?(@element_children_version)
179
198
  result = children.select(&:element?)
180
- @element_children = result if @document&.readonly?
199
+ if @document
200
+ @element_children = result
201
+ @element_children_version = @document.version
202
+ end
181
203
  result
182
204
  end
183
205
  alias_method :elements, :element_children
@@ -220,16 +242,19 @@ class Leptris::XML::Node
220
242
  end
221
243
 
222
244
  def path
223
- return @path if readonly_cached?(:@path)
245
+ return @path if memo_hit?(@path_version)
224
246
  ensure_alive!
225
247
  str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
226
248
  result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
227
- @path = result if @document&.readonly?
249
+ if @document
250
+ @path = result
251
+ @path_version = @document.version
252
+ end
228
253
  result
229
254
  end
230
255
 
231
256
  def css_path
232
- return @css_path if readonly_cached?(:@css_path)
257
+ return @css_path if memo_hit?(@css_path_version)
233
258
  result =
234
259
  if path.nil?
235
260
  nil
@@ -239,7 +264,10 @@ class Leptris::XML::Node
239
264
  part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
240
265
  end.join(" > ")
241
266
  end
242
- @css_path = result if @document&.readonly?
267
+ if @document
268
+ @css_path = result
269
+ @css_path_version = @document.version
270
+ end
243
271
  result
244
272
  end
245
273
 
@@ -264,11 +292,15 @@ class Leptris::XML::Node
264
292
 
265
293
  protected
266
294
 
267
- # Memo presence alone proves readonly: every memo site assigns
268
- # only under readonly, and readonly is one-way — so the guard
269
- # saves the document round-trip on every memoized read.
270
- def readonly_cached?(ivar)
271
- instance_variable_defined?(ivar)
295
+ # A memo is valid while the document's mutation version has not
296
+ # advanced since that memo was stored. Each memoized field carries
297
+ # its OWN stamp — a shared node-level stamp would let one field's
298
+ # recompute resurrect another field's stale memo. Readonly
299
+ # documents never advance the version, so their memos are forever
300
+ # valid (ADR 0003 semantics); writable documents gain memos
301
+ # between mutations.
302
+ def memo_hit?(stamp)
303
+ @document && stamp == @document.version
272
304
  end
273
305
 
274
306
  def as_element_or_self
@@ -60,7 +60,6 @@ class Leptris::XML::NodeSet
60
60
  # inconsistently with an eager set.
61
61
  return to_a[idx] if idx.negative?
62
62
  if @result_ptr
63
- return nil if idx < 0 || idx >= length
64
63
  ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, idx)
65
64
  return nil if ptr.null?
66
65
  Leptris::XML::Node.wrap(ptr, @document)
@@ -2,20 +2,26 @@
2
2
 
3
3
  class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
4
4
  def name
5
- return @name if readonly_cached?(:@name)
5
+ return @name if memo_hit?(@name_version)
6
6
  ensure_alive!
7
- Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr).tap do |target|
8
- @name = target if @document&.readonly?
7
+ result = Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr)
8
+ if @document
9
+ @name = result
10
+ @name_version = @document.version
9
11
  end
12
+ result
10
13
  end
11
14
  alias_method :target, :name
12
15
 
13
16
  def content
14
- return @content if readonly_cached?(:@content)
17
+ return @content if memo_hit?(@content_version)
15
18
  ensure_alive!
16
- Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s.tap do |data|
17
- @content = data if @document&.readonly?
19
+ result = Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s
20
+ if @document
21
+ @content = result
22
+ @content_version = @document.version
18
23
  end
24
+ result
19
25
  end
20
26
 
21
27
  def target=(new_target)
@@ -4,11 +4,14 @@ class Leptris::XML::Text < Leptris::XML::Node
4
4
  def name; "text"; end
5
5
 
6
6
  def content
7
- return @content if readonly_cached?(:@content)
7
+ return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr).tap do |text|
10
- @content = text if @document&.readonly?
9
+ result = Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr)
10
+ if @document
11
+ @content = result
12
+ @content_version = @document.version
11
13
  end
14
+ result
12
15
  end
13
16
 
14
17
  def content=(new_content)
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.12
4
+ version: 1.9.14
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi