leptris 1.9.13 → 1.9.15

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: 590285b33ed6aac05def5ff85268822fc6d5abf87cfd5b262d51b72868f70ac3
4
- data.tar.gz: 8dc09c12f495c12177a5dd15ff8892340dc090d678eb342738571bfb0e142228
3
+ metadata.gz: c1b73d7abca12056f163aed086825140416384075955e593f11191156db475fc
4
+ data.tar.gz: ead9075339cfd0736ca79cbede18612702f4632c95d9d90a512f67b3e4c32ec7
5
5
  SHA512:
6
- metadata.gz: 2781b4465ff07a85ea7ecc91824070da8412aa933bc96273a4b9159860c95a0e227e06630967685e66db9fd696a6e5648ec9474217ecae769b8163096adf18ed
7
- data.tar.gz: 9d7173b435723be6f068700e27e05f796246143dcf22dd1319079c5770a71f3f933fdd7d517a71ee47cba58f1305ed2d2e270ffaee43c422c43d693dbb77f4f7
6
+ metadata.gz: 22ec81a95a094913096d9c0749c42a64274f7af9225c4128b51529c8160f6480d3f23476b548f7c766202be9744dee9a830eaa715dbeea77a42e4d374f61b6e5
7
+ data.tar.gz: 5e5408837f3ca2b4b54e4069a36798000b67d87fee9ed7021927b4e6c8b95d6a04a9f9d770fcd786763569d700182f87fc5b7df54c14462391c0420f69d108ed
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.15] - 2026-08-27
9
+
10
+ ### Changed
11
+
12
+ - **`Element#[]` serves bare names from the versioned attributes
13
+ hash on every document kind** (writable included): materializes
14
+ on demand, invalidates through the mutation gate. Qualified names
15
+ (with a colon) still route to the engine — they resolve through
16
+ in-scope declarations where the written prefix never matters,
17
+ which a written-name-keyed hash cannot answer. Writable
18
+ attribute loops reach readonly parity: 0.196 s -> 0.101 s (2x);
19
+ the readonly special-case branch is deleted. The qualified/bare
20
+ split is spec-pinned (cross-prefix match, undeclared-prefix nil).
21
+ - **`first_element_child` memoizes** with version invalidation
22
+ (structural mutations bump): 0.058 s -> 0.015 s (4x) on the
23
+ repeat loop.
24
+
25
+ ## [1.9.14] - 2026-08-27
26
+
27
+ ### Changed
28
+
29
+ - **Writable documents memoize** — the versioned read cache. A
30
+ per-document mutation version advances at every mutation gate
31
+ (ensure_writable!, root=, add_pi); each memoized field carries
32
+ its own version stamp and recomputes after any bump. Readonly
33
+ semantics unchanged (their version never advances — memos
34
+ forever valid). Measured on writable documents: namespace
35
+ inspection 0.392 s -> 0.013 s (**31x**, readonly parity),
36
+ children 0.154 s -> 0.014 s (**11x**). The read-heavy
37
+ DOM-editing workload (parse, query repeatedly, mutate
38
+ occasionally) now performs like the readonly one between
39
+ mutations. Staleness is test-pinned: a mutation-invalidation
40
+ matrix covers content, attribute, structural, namespace, root=,
41
+ and PI mutations against every memoized read. The shared
42
+ node-level stamp variant was tried and rejected in favor of
43
+ per-field stamps (one field's recompute must not resurrect
44
+ another's stale memo). ADR 0003 extended.
45
+ - NodeSet#[] drops the Ruby-side bounds FFI (the C accessor
46
+ already returns NULL out of range).
47
+
8
48
  ## [1.9.13] - 2026-08-27
9
49
 
10
50
  ### 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.13"
4
+ VERSION = "1.9.15"
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)
@@ -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
@@ -142,6 +159,7 @@ class Leptris::XML::Document
142
159
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
143
160
  Leptris::XML::FFI.check_status(
144
161
  Leptris::XML::FFI.leptris_document_set_root(@c_ptr, element.c_ptr))
162
+ @version += 1
145
163
  element
146
164
  end
147
165
 
@@ -253,15 +271,14 @@ class Leptris::XML::Document
253
271
  # Document-level processing instructions (not tree nodes):
254
272
  # an array of [target, data] pairs in document order.
255
273
  def processing_instructions
256
- if readonly? && instance_variable_defined?(:@processing_instructions)
257
- return @processing_instructions
258
- end
274
+ return @processing_instructions if @pi_version == @version
259
275
  count = Leptris::XML::FFI.leptris_document_pi_count(@c_ptr)
260
276
  result = count.times.map do |i|
261
277
  [Leptris::XML::FFI.leptris_document_pi_target(@c_ptr, i),
262
278
  Leptris::XML::FFI.leptris_document_pi_data(@c_ptr, i)]
263
279
  end
264
- @processing_instructions = result if readonly?
280
+ @processing_instructions = result
281
+ @pi_version = @version
265
282
  result
266
283
  end
267
284
 
@@ -270,6 +287,7 @@ class Leptris::XML::Document
270
287
  witness = Leptris::XML::FFI.leptris_document_add_pi(
271
288
  @c_ptr, target.to_s, data.to_s)
272
289
  raise Leptris::XML::Error, "leptris_document_add_pi failed" if witness.null?
290
+ @version += 1
273
291
  self
274
292
  end
275
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)
@@ -32,16 +35,20 @@ class Leptris::XML::Element < Leptris::XML::Node
32
35
  end
33
36
 
34
37
  def [](key)
35
- # Readonly: the attributes hash cannot go stale (ADR 0003) and
36
- # materializes on demand — repeated reads become hash lookups,
37
- # with no dispatch and no lifetime guard (a hash read cannot
38
- # use-after-free).
39
- if readonly_document?
40
- cached = attributes[key.to_s]
38
+ # BARE names serve from the versioned attributes hash (written
39
+ # name == the only legal key for a no-namespace attribute);
40
+ # materializes on demand, invalidates through the mutation gate
41
+ # (ADR 0003's 1.9.14 extension); a hash read cannot
42
+ # use-after-free. QUALIFIED names (with a colon) go to the
43
+ # engine: they resolve through in-scope declarations, where the
44
+ # written prefix never matters — the hash cannot answer them.
45
+ name = key.to_s
46
+ if @document && !name.include?(":")
47
+ cached = attributes[name]
41
48
  return cached.nil? ? nil : cached.value
42
49
  end
43
50
  ensure_alive!
44
- Leptris::XML::FFI.leptris_element_attribute(@c_ptr, key.to_s)
51
+ Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
45
52
  end
46
53
  alias_method :attr, :[]
47
54
  alias_method :get_attribute, :[]
@@ -104,31 +111,43 @@ class Leptris::XML::Element < Leptris::XML::Node
104
111
  end
105
112
 
106
113
  def keys
107
- return @keys if readonly_cached?(:@keys)
114
+ return @keys if memo_hit?(@keys_version)
108
115
  result = each_attribute.to_a.map(&:name)
109
- @keys = result if @document&.readonly?
116
+ if @document
117
+ @keys = result
118
+ @keys_version = @document.version
119
+ end
110
120
  result
111
121
  end
112
122
 
113
123
  def values
114
- return @values if readonly_cached?(:@values)
124
+ return @values if memo_hit?(@values_version)
115
125
  result = each_attribute.to_a.map(&:value)
116
- @values = result if @document&.readonly?
126
+ if @document
127
+ @values = result
128
+ @values_version = @document.version
129
+ end
117
130
  result
118
131
  end
119
132
 
120
133
  def attributes
121
- return @attributes if readonly_cached?(:@attributes)
134
+ return @attributes if memo_hit?(@attributes_version)
122
135
  result = {}
123
136
  each_attribute { |attr| result[attr.name] = attr }
124
- @attributes = result if @document&.readonly?
137
+ if @document
138
+ @attributes = result
139
+ @attributes_version = @document.version
140
+ end
125
141
  result
126
142
  end
127
143
 
128
144
  def attribute_nodes
129
- return @attribute_nodes if readonly_cached?(:@attribute_nodes)
145
+ return @attribute_nodes if memo_hit?(@attribute_nodes_version)
130
146
  result = each_attribute.to_a
131
- @attribute_nodes = result if @document&.readonly?
147
+ if @document
148
+ @attribute_nodes = result
149
+ @attribute_nodes_version = @document.version
150
+ end
132
151
  result
133
152
  end
134
153
 
@@ -247,7 +266,7 @@ class Leptris::XML::Element < Leptris::XML::Node
247
266
  alias_method :<<, :add_child
248
267
 
249
268
  def namespace
250
- return @namespace if readonly_cached?(:@namespace)
269
+ return @namespace if memo_hit?(@namespace_version)
251
270
  ensure_alive!
252
271
  uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
253
272
  result =
@@ -261,12 +280,15 @@ class Leptris::XML::Element < Leptris::XML::Node
261
280
  prefix = nil if prefix.nil? || prefix.empty?
262
281
  Leptris::XML::Namespace.new(self, uri, prefix: prefix)
263
282
  end
264
- @namespace = result if @document&.readonly?
283
+ if @document
284
+ @namespace = result
285
+ @namespace_version = @document.version
286
+ end
265
287
  result
266
288
  end
267
289
 
268
290
  def namespace_definitions
269
- return @namespace_definitions if readonly_cached?(:@namespace_definitions)
291
+ return @namespace_definitions if memo_hit?(@namespace_definitions_version)
270
292
  ensure_alive!
271
293
  count = Leptris::XML::FFI.leptris_element_namespace_count(@c_ptr)
272
294
  result = count.times.map do |i|
@@ -274,12 +296,15 @@ class Leptris::XML::Element < Leptris::XML::Node
274
296
  uri = Leptris::XML::FFI.leptris_element_namespace_decl_uri(@c_ptr, i)
275
297
  Leptris::XML::Namespace.new(self, uri, prefix: prefix)
276
298
  end
277
- @namespace_definitions = result if @document&.readonly?
299
+ if @document
300
+ @namespace_definitions = result
301
+ @namespace_definitions_version = @document.version
302
+ end
278
303
  result
279
304
  end
280
305
 
281
306
  def namespaces
282
- return @namespaces if readonly_cached?(:@namespaces)
307
+ return @namespaces if memo_hit?(@namespaces_version)
283
308
  scopes = {}
284
309
  node = self
285
310
  while node.is_a?(Leptris::XML::Element)
@@ -289,7 +314,10 @@ class Leptris::XML::Element < Leptris::XML::Node
289
314
  end
290
315
  node = node.parent
291
316
  end
292
- @namespaces = scopes if @document&.readonly?
317
+ if @document
318
+ @namespaces = scopes
319
+ @namespaces_version = @document.version
320
+ end
293
321
  scopes
294
322
  end
295
323
 
@@ -101,13 +101,19 @@ 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
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
111
117
  end
112
118
 
113
119
  # Readonly is one-way, so caching TRUE is sound: once observed,
@@ -142,14 +148,17 @@ class Leptris::XML::Node
142
148
  def children
143
149
  # Immutable in readonly mode: the batch fetch plus wrapper
144
150
  # construction is paid once.
145
- return @children if readonly_cached?(:@children)
151
+ return @children if memo_hit?(@children_version)
146
152
  ensure_alive!
147
153
  parent = as_element_or_self
148
154
  nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
149
155
  Leptris::XML::Node.wrap(ptr, @document, parent: parent)
150
156
  end
151
157
  result = Leptris::XML::NodeSet.new(@document, nodes)
152
- @children = result if @document&.readonly?
158
+ if @document
159
+ @children = result
160
+ @children_version = @document.version
161
+ end
153
162
  result
154
163
  end
155
164
 
@@ -170,14 +179,23 @@ class Leptris::XML::Node
170
179
  alias_method :previous, :previous_sibling
171
180
 
172
181
  def first_element_child
182
+ return @first_element_child if memo_hit?(@first_element_child_version)
173
183
  ensure_alive!
174
184
  ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
185
+ result = nil
175
186
  until ptr.nil? || ptr.null?
176
187
  node = Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
177
- return node if node.element?
188
+ if node.element?
189
+ result = node
190
+ break
191
+ end
178
192
  ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
179
193
  end
180
- nil
194
+ if @document
195
+ @first_element_child = result
196
+ @first_element_child_version = @document.version
197
+ end
198
+ result
181
199
  end
182
200
 
183
201
  def last_element_child
@@ -185,9 +203,12 @@ class Leptris::XML::Node
185
203
  end
186
204
 
187
205
  def element_children
188
- return @element_children if readonly_cached?(:@element_children)
206
+ return @element_children if memo_hit?(@element_children_version)
189
207
  result = children.select(&:element?)
190
- @element_children = result if @document&.readonly?
208
+ if @document
209
+ @element_children = result
210
+ @element_children_version = @document.version
211
+ end
191
212
  result
192
213
  end
193
214
  alias_method :elements, :element_children
@@ -230,16 +251,19 @@ class Leptris::XML::Node
230
251
  end
231
252
 
232
253
  def path
233
- return @path if readonly_cached?(:@path)
254
+ return @path if memo_hit?(@path_version)
234
255
  ensure_alive!
235
256
  str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
236
257
  result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
237
- @path = result if @document&.readonly?
258
+ if @document
259
+ @path = result
260
+ @path_version = @document.version
261
+ end
238
262
  result
239
263
  end
240
264
 
241
265
  def css_path
242
- return @css_path if readonly_cached?(:@css_path)
266
+ return @css_path if memo_hit?(@css_path_version)
243
267
  result =
244
268
  if path.nil?
245
269
  nil
@@ -249,7 +273,10 @@ class Leptris::XML::Node
249
273
  part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
250
274
  end.join(" > ")
251
275
  end
252
- @css_path = result if @document&.readonly?
276
+ if @document
277
+ @css_path = result
278
+ @css_path_version = @document.version
279
+ end
253
280
  result
254
281
  end
255
282
 
@@ -274,11 +301,15 @@ class Leptris::XML::Node
274
301
 
275
302
  protected
276
303
 
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.
280
- def readonly_cached?(ivar)
281
- instance_variable_defined?(ivar)
304
+ # A memo is valid while the document's mutation version has not
305
+ # advanced since that memo was stored. Each memoized field carries
306
+ # its OWN stamp — a shared node-level stamp would let one field's
307
+ # recompute resurrect another field's stale memo. Readonly
308
+ # documents never advance the version, so their memos are forever
309
+ # valid (ADR 0003 semantics); writable documents gain memos
310
+ # between mutations.
311
+ def memo_hit?(stamp)
312
+ @document && stamp == @document.version
282
313
  end
283
314
 
284
315
  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.13
4
+ version: 1.9.15
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-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