leptris 1.9.197.2-arm-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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2646 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. metadata +214 -0
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::Node
4
+ # TODO.perf/19: @c_address is the canonical truth; the Pointer
5
+ # materializes only when actually read (most wrappers' Pointers
6
+ # never are — hot reads are memoized or address-based).
7
+ def c_ptr
8
+ @c_ptr ||= ::FFI::Pointer.new(@c_address)
9
+ end
10
+
11
+ # The Integer twin of #c_ptr — reading it never materializes a
12
+ # Pointer (the native faces take addresses).
13
+ def c_address
14
+ @c_address
15
+ end
16
+
17
+ # Iterparse-yielded elements are owned by an IterationScope (the
18
+ # internal lifetime/memoization authority) — the public #document
19
+ # answers nil for them, per the documented contract.
20
+ # Precomputed at construction (TODO.perf/24): scope ownership
21
+ # is fixed for a node's lifetime — nil for iterparse yields.
22
+ def document
23
+ @pub_document
24
+ end
25
+
26
+ def initialize(c_ptr, document, parent: nil, node_type: nil)
27
+ @c_ptr = c_ptr
28
+ @c_address = c_ptr&.address
29
+ @document = document
30
+ @parent = parent
31
+ # Structural-memo stamp (TODO.perf/13): a constructor-seeded
32
+ # @parent is true AS OF the document's current version; any
33
+ # mutation advances the version and forces re-derivation. A
34
+ # NIL constructor parent means UNKNOWN, not "no parent" — the
35
+ # engine may attach during creation (create_child), so an
36
+ # unstamped memo must derive rather than trust nil.
37
+ @parent_version = parent ? document&.version : nil
38
+ # Computed once: structural stamps apply to document-owned
39
+ # nodes only (scope-owned iterparse elements move without
40
+ # their scope's version advancing — see #parent). An ivar
41
+ # keeps the memo-hit path free of method dispatch.
42
+ @structure_memoizable =
43
+ !document.nil? && !document.is_a?(Leptris::XML::IterationScope)
44
+ @pub_document = @structure_memoizable ? document : nil
45
+ # TODO.perf/25: pure address-based native reads touch no
46
+ # cache — safe for scope-owned (iterparse) elements too.
47
+ @addr_reads_fast = !!defined?(Leptris::XML::NATIVE_FAST)
48
+ # NATIVE_FAST availability is fixed at load time before any
49
+ # node exists, so the conjunction with document-ownership is
50
+ # construct-time constant — the hot gates read one ivar.
51
+ @native_fast = @structure_memoizable &&
52
+ defined?(Leptris::XML::NATIVE_FAST) ? true : false
53
+ # wrap() already calls leptris_node_get_type for dispatch; reusing
54
+ # the result makes every predicate and #type call FFI-free.
55
+ @node_type = node_type
56
+ end
57
+
58
+ # True when this node's owning context is an IterationScope
59
+ # (iterparse-yielded) rather than a Document.
60
+ def scope_owned?
61
+ @document.is_a?(Leptris::XML::IterationScope)
62
+ end
63
+ private :scope_owned?
64
+
65
+ # node_type: callers holding a batch-fetched kind (the XPath
66
+ # result-set batch fills out_kinds) pass it so the wrap skips the
67
+ # get_type dispatch; nil (the default) dispatches as before.
68
+ # Factory variant: the pointer was JUST created by the engine,
69
+ # so the cache lookup is a guaranteed miss — skip straight to
70
+ # construction and store (identity still survives attach).
71
+ def self.wrap_fresh(c_ptr, document, node_type)
72
+ node = construct(c_ptr, document, nil, node_type, nil)
73
+ if document
74
+ cache = document.wrapper_cache
75
+ cache[c_ptr.address] = node
76
+ end
77
+ node
78
+ end
79
+
80
+ def self.wrap(c_ptr, document, parent: nil, node_type: nil, result_value: nil)
81
+ # Per-document weak-ref cache. Returns the existing wrapper when the
82
+ # same c_ptr is wrapped twice (common in children/sibling walks,
83
+ # repeated xpath queries, traverse-then-access patterns). The cache
84
+ # dies with the document so no stale entries. The miss path
85
+ # resolves the cache and address once — a cold walk wraps every
86
+ # node exactly once and pays both only on the store.
87
+ if document
88
+ cache = document.wrapper_cache
89
+ address = c_ptr.address
90
+ if (cached = cache[address])
91
+ # The shared cache also holds NativeNodes (opt-in layer).
92
+ # Only return a hit when it is a binding Node subclass —
93
+ # otherwise fall through and construct the binding wrapper
94
+ # (both views of the same C node can coexist; the native
95
+ # entry stays under its own key-path via NativeNode.from).
96
+ return cached if cached.is_a?(Leptris::XML::Node)
97
+ end
98
+ end
99
+
100
+ node_type ||= Leptris::XML::FFI.leptris_node_get_type(c_ptr)
101
+ node = construct(c_ptr, document, parent, node_type, result_value)
102
+
103
+ cache[address] = node if document
104
+ node
105
+ end
106
+
107
+ # The single type-dispatch construction authority shared by wrap
108
+ # (cache-checked) and wrap_fresh (factory path).
109
+ def self.construct(c_ptr, document, parent, node_type, result_value)
110
+ case node_type
111
+ when Leptris::XML::FFI::NODE_ELEMENT
112
+ Leptris::XML::Element.new(c_ptr, document, parent: parent, node_type: node_type)
113
+ when Leptris::XML::FFI::NODE_TEXT
114
+ Leptris::XML::Text.new(c_ptr, document, parent: parent, node_type: node_type)
115
+ when Leptris::XML::FFI::NODE_SYNTHETIC_TEXT
116
+ # Sequence/map/array items: value captured by the NodeSet at
117
+ # materialization (only the result handle can read them).
118
+ Leptris::XML::ResultText.new(
119
+ c_ptr, document, result_value, parent: parent, node_type: node_type)
120
+ when Leptris::XML::FFI::NODE_ATTRIBUTE
121
+ # ruby#153: synthetic attribute result nodes carry their
122
+ # name/value in the result handle — the NodeSet captures
123
+ # them at materialization (result_value is a {name:, value:}
124
+ # hash; the single-node seam captures the same way).
125
+ rv = result_value || {}
126
+ Leptris::XML::ResultAttr.new(
127
+ c_ptr, document, rv[:name], rv[:value],
128
+ parent: parent, node_type: node_type)
129
+ when Leptris::XML::FFI::NODE_COMMENT
130
+ Leptris::XML::Comment.new(c_ptr, document, parent: parent, node_type: node_type)
131
+ when Leptris::XML::FFI::NODE_CDATA
132
+ Leptris::XML::CDATA.new(c_ptr, document, parent: parent, node_type: node_type)
133
+ when Leptris::XML::FFI::NODE_PI
134
+ Leptris::XML::ProcessingInstruction.new(c_ptr, document, parent: parent, node_type: node_type)
135
+ when Leptris::XML::FFI::NODE_ENTITY_REF
136
+ Leptris::XML::EntityReference.new(c_ptr, document, parent: parent, node_type: node_type)
137
+ else
138
+ new(c_ptr, document, parent: parent, node_type: node_type)
139
+ end
140
+ end
141
+ private_class_method :construct
142
+
143
+ def name
144
+ raise NotImplementedError, "#{self.class}#name not implemented"
145
+ end
146
+
147
+ def content
148
+ raise NotImplementedError, "#{self.class}#content not implemented"
149
+ end
150
+
151
+ # Dispatching defs, not alias_method: an alias snapshots this base
152
+ # #content (the raise), so subclass overrides would never be seen
153
+ # through the alias. A plain method resolves #content per-call.
154
+ def text
155
+ content
156
+ end
157
+
158
+ def inner_text
159
+ content
160
+ end
161
+
162
+ def type
163
+ return @node_type if @node_type
164
+ ensure_alive!
165
+ @node_type = Leptris::XML::FFI.leptris_node_get_type(c_ptr)
166
+ end
167
+ alias_method :node_type, :type
168
+
169
+ def element?; type == Leptris::XML::FFI::NODE_ELEMENT; end
170
+
171
+ # The ext bulk path applies to document-owned trees only:
172
+ # scope-owned (iterparse) elements ride the IterationScope seam.
173
+ # Precomputed at construction (TODO.perf/17) — one ivar read.
174
+ def native_fast_children?
175
+ @native_fast
176
+ end
177
+
178
+ # TODO.perf/25: the bulk children faces accept the
179
+ # IterationScope itself (its @wrapper_cache resets per yield —
180
+ # recycled addresses cannot collide — and its @version advances
181
+ # per yield and on mutation), so streaming children ride the
182
+ # one-pass materialization too. The scope-aware stamps live in
183
+ # the C face.
184
+ def native_bulk_children?
185
+ defined?(Leptris::XML::NATIVE_FAST)
186
+ end
187
+
188
+ # Content-defined 64-bit Merkle digest of this subtree
189
+ # (libleptris 1.9.99, #869): element name/prefix/resolved
190
+ # namespace URI, attributes sorted and first-wins-deduplicated,
191
+ # children hashed in document order — no addresses participate,
192
+ # so equal trees hash equal across processes. Equality implies
193
+ # subtree equivalence; inequality implies nothing (descend).
194
+ # +drop_ws+ skips whitespace-only text nodes.
195
+ def digest(drop_ws: false)
196
+ ensure_alive!
197
+ Leptris::XML::FFI.leptris_node_digest(
198
+ c_ptr, drop_ws ? 1 : 0)
199
+ end
200
+ def text?; type == Leptris::XML::FFI::NODE_TEXT; end
201
+ def comment?; type == Leptris::XML::FFI::NODE_COMMENT; end
202
+ def cdata?; type == Leptris::XML::FFI::NODE_CDATA; end
203
+ def processing_instruction?
204
+ type == Leptris::XML::FFI::NODE_PI
205
+ end
206
+ alias_method :pi?, :processing_instruction?
207
+
208
+ # Version-stamped structural memo (TODO.perf/13): derive once,
209
+ # re-derive after any mutation that advances the owning
210
+ # document's version. Fixes the stale seeded @parent after a
211
+ # move (FFI children walks seed it; the move never cleared it).
212
+ # Scope-owned (iterparse) elements never memoize: a scope element
213
+ # adopted into a document moves without its scope's version
214
+ # advancing, so a stamp would lie.
215
+ def parent
216
+ if @structure_memoizable && @parent_version == @document.version
217
+ return @parent
218
+ end
219
+ ensure_alive!
220
+ ptr = Leptris::XML::FFI.leptris_node_parent(c_ptr)
221
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document)
222
+ if @document
223
+ @parent = result
224
+ @parent_version = @document.version
225
+ end
226
+ result
227
+ end
228
+
229
+ # Derive the parent WITHOUT stamping the structural memo. The
230
+ # adoption lift reads the child's source scope BETWEEN the
231
+ # mutation gate's version bump and the engine move — a stamped
232
+ # #parent there would record post-bump versions carrying
233
+ # pre-move truth. Ancestor walks that run inside mutations use
234
+ # this; #namespaces itself is memoized, so stable trees pay the
235
+ # unstamped derivation at most once per version.
236
+ def unstamped_parent
237
+ ensure_alive!
238
+ ptr = Leptris::XML::FFI.leptris_node_parent(c_ptr)
239
+ ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document)
240
+ end
241
+
242
+ # Mutation sites move nodes ACROSS documents: the moved node's
243
+ # stamps reference the source document's version, which the move
244
+ # does not advance. Clear them and advance the source version
245
+ # (its other nodes hold parent/sibling stamps the move invalidates).
246
+ def invalidate_structural_stamps!
247
+ @parent_version = nil
248
+ @next_sibling_version = nil
249
+ @previous_sibling_version = nil
250
+ end
251
+
252
+ # Mutation-site helper: when +node+ moves into +target_document+
253
+ # from a different one, the move invalidates the node's own
254
+ # structural stamps (stamped against the SOURCE version) and the
255
+ # source document's sibling/parent stamps. Scope-owned nodes
256
+ # answer nil from #document and never memoize — nothing to do.
257
+ def self.invalidate_cross_document!(node, target_document)
258
+ source = node.document
259
+ return if source.nil? || source.equal?(target_document)
260
+ node.invalidate_structural_stamps!
261
+ source.advance_version
262
+ end
263
+
264
+ # Borrowed-handle lifetime: every c_ptr dereference is valid only
265
+ # while the owning document lives. Parentless nodes (iterparse
266
+ # yields) cannot validate and are skipped. The guard runs before
267
+ # every uncached FFI dispatch, so it uses the cheapest sufficient
268
+ # check: #free nils the document's c_ptr, and the GC-finalizer
269
+ # path cannot fire while any handle (which strongly references
270
+ # the document) exists. Document#freed? remains the accurate
271
+ # public predicate.
272
+ def ensure_alive!
273
+ if @document && @document.c_ptr.nil?
274
+ raise Leptris::XML::UseAfterFreeError,
275
+ "owning document has been freed — handle used on #{inspect}"
276
+ end
277
+ end
278
+
279
+ # Raises ReadOnlyError when the owning document was marked readonly,
280
+ # UseAfterFreeError when it was freed. Every node-level mutation
281
+ # passes through this gate, so it is where the document's mutation
282
+ # version advances — the invalidation behind writable-document
283
+ # memoization. Bumping before the C call is conservative: a failed
284
+ # mutation merely discards memos.
285
+ def ensure_writable!
286
+ ensure_alive!
287
+ if readonly_document?
288
+ raise Leptris::XML::ReadOnlyError,
289
+ "document is readonly — mutation attempted on #{inspect}"
290
+ end
291
+ @document.advance_version
292
+ nil
293
+ end
294
+
295
+ # Readonly is one-way, so caching TRUE is sound: once observed,
296
+ # the document is readonly forever. FALSE stays uncached (the
297
+ # document may still flip). Saves the document round-trip on
298
+ # per-read guards.
299
+ def readonly_document?
300
+ return true if instance_variable_defined?(:@readonly_document)
301
+ return false unless @document&.readonly?
302
+ @readonly_document = true
303
+ end
304
+
305
+ # A node's source position never changes — first read wins.
306
+ def line
307
+ return @line if defined?(@line)
308
+ ensure_alive!
309
+ @line = Leptris::XML::FFI.leptris_node_line(c_ptr)
310
+ end
311
+
312
+ # Full parser-recorded position (libleptris 1.9.180, #1124):
313
+ # {line:, col_start:, col_end:} in Jing's diagnostic
314
+ # convention; zeros for programmatically-created nodes. First
315
+ # read wins (positions never change).
316
+ def source_position
317
+ return @source_position if defined?(@source_position)
318
+ ensure_alive!
319
+ out = Leptris::XML::FFI::SourcePosition.new
320
+ Leptris::XML::FFI.leptris_node_source_position(c_ptr, out)
321
+ @source_position = { line: out[:line], col_start: out[:col_start],
322
+ col_end: out[:col_end] }
323
+ end
324
+
325
+ # Byte offset of the node's markup in its parse source (the '<'
326
+ # of the tag; libleptris 1.9.162, #1039 — the position descriptor
327
+ # CALLBACK rows echo). 0 when unknown: mutation-created nodes, or
328
+ # documents >= 2 GiB.
329
+ def byte_offset
330
+ return @byte_offset if defined?(@byte_offset)
331
+ ensure_alive!
332
+ @byte_offset = Leptris::XML::FFI.leptris_node_byte_offset(c_ptr)
333
+ end
334
+
335
+ def <=>(other)
336
+ return nil unless other.is_a?(Leptris::XML::Node)
337
+ return nil unless @document == other.document
338
+ ensure_alive!
339
+ Leptris::XML::FFI.leptris_node_compare(c_ptr, other.c_ptr)
340
+ end
341
+
342
+ def child
343
+ ensure_alive!
344
+ ptr = Leptris::XML::FFI.leptris_node_first_child(c_ptr)
345
+ return nil if ptr.null?
346
+ Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
347
+ end
348
+
349
+ def children
350
+ # Immutable in readonly mode: the batch fetch plus wrapper
351
+ # construction is paid once. The kinds ride the batch
352
+ # (leptris_node_children_ex), so no per-child get_type.
353
+ return @children if memo_hit?(@children_version)
354
+ ensure_alive!
355
+ if native_bulk_children?
356
+ # TODO.perf/01 tail: one C pass constructs every binding
357
+ # wrapper (class dispatch + ivars + identity cache) — the
358
+ # per-child Ruby wrap frames disappear.
359
+ nodes = Leptris::XML::Native.bulk_children(@document, @c_address)
360
+ result = Leptris::XML::NodeSet.new(@document, nodes)
361
+ if @document
362
+ @children = result
363
+ @children_version = @document.version
364
+ end
365
+ return result
366
+ end
367
+ parent = as_element_or_self
368
+ pointers, kinds = Leptris::XML::FFI.fetch_children(c_ptr)
369
+ nodes = Array.new(pointers.size) do |i|
370
+ Leptris::XML::Node.wrap(pointers[i], @document,
371
+ parent: parent, node_type: kinds[i])
372
+ end
373
+ result = Leptris::XML::NodeSet.new(@document, nodes)
374
+ if @document
375
+ @children = result
376
+ @children_version = @document.version
377
+ end
378
+ result
379
+ end
380
+
381
+ def next_sibling
382
+ if @structure_memoizable && @next_sibling_version == @document.version
383
+ return @next_sibling
384
+ end
385
+ ensure_alive!
386
+ ptr = Leptris::XML::FFI.leptris_node_next_sibling(c_ptr)
387
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
388
+ if @document
389
+ @next_sibling = result
390
+ @next_sibling_version = @document.version
391
+ end
392
+ result
393
+ end
394
+ alias_method :next, :next_sibling
395
+
396
+ def previous_sibling
397
+ if @structure_memoizable && @previous_sibling_version == @document.version
398
+ return @previous_sibling
399
+ end
400
+ ensure_alive!
401
+ ptr = Leptris::XML::FFI.leptris_node_previous_sibling(c_ptr)
402
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
403
+ if @document
404
+ @previous_sibling = result
405
+ @previous_sibling_version = @document.version
406
+ end
407
+ result
408
+ end
409
+ alias_method :previous, :previous_sibling
410
+
411
+ def first_element_child
412
+ return @first_element_child if memo_hit?(@first_element_child_version)
413
+ ensure_alive!
414
+ # TODO.perf/30: one C walk (the FFI scan paid two calls per
415
+ # skipped non-element sibling).
416
+ if @addr_reads_fast
417
+ result = Leptris::XML::Native.first_element_child(
418
+ @document, @c_address)
419
+ if @document
420
+ @first_element_child = result
421
+ @first_element_child_version = @document.version
422
+ end
423
+ return result
424
+ end
425
+ # Raw pointer scan: non-element siblings are typed with one C
426
+ # call each — never wrapped, never cached — and the found
427
+ # element carries the ELEMENT hint into the wrap.
428
+ ptr = Leptris::XML::FFI.leptris_node_first_child(c_ptr)
429
+ result = nil
430
+ until ptr.nil? || ptr.null?
431
+ if Leptris::XML::FFI.leptris_node_get_type(ptr) ==
432
+ Leptris::XML::FFI::NODE_ELEMENT
433
+ result = Leptris::XML::Node.wrap(
434
+ ptr, @document, parent: as_element_or_self,
435
+ node_type: Leptris::XML::FFI::NODE_ELEMENT)
436
+ break
437
+ end
438
+ ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
439
+ end
440
+ if @document
441
+ @first_element_child = result
442
+ @first_element_child_version = @document.version
443
+ end
444
+ result
445
+ end
446
+
447
+ def last_element_child
448
+ # TODO.perf/30: one C walk — the batch fetch materialized
449
+ # every child pointer to keep one.
450
+ if @addr_reads_fast
451
+ return Leptris::XML::Native.last_element_child(
452
+ @document, @c_address)
453
+ end
454
+ if is_a?(Leptris::XML::Element)
455
+ kids = Leptris::XML::FFI.fetch_element_children(c_ptr)
456
+ return nil if kids.empty?
457
+ return Leptris::XML::Node.wrap(
458
+ kids.last, @document, parent: self,
459
+ node_type: Leptris::XML::FFI::NODE_ELEMENT)
460
+ end
461
+ children.reverse_each.find(&:element?)
462
+ end
463
+
464
+ def element_children
465
+ return @element_children if memo_hit?(@element_children_version)
466
+ ensure_alive!
467
+ # Element receivers ride the element-only batch: text/comment
468
+ # children are never wrapped (nor their get_type paid — the
469
+ # ELEMENT hint rides along). Other nodes keep the filter.
470
+ result =
471
+ if native_bulk_children?
472
+ Leptris::XML::Native.bulk_element_children(@document, @c_address)
473
+ elsif is_a?(Leptris::XML::Element)
474
+ parent = as_element_or_self
475
+ Leptris::XML::FFI.fetch_element_children(c_ptr).map do |ptr|
476
+ Leptris::XML::Node.wrap(ptr, @document, parent: parent,
477
+ node_type: Leptris::XML::FFI::NODE_ELEMENT)
478
+ end
479
+ else
480
+ children.select(&:element?)
481
+ end
482
+ if @document
483
+ @element_children = result
484
+ @element_children_version = @document.version
485
+ end
486
+ result
487
+ end
488
+ alias_method :elements, :element_children
489
+
490
+ def next_element
491
+ sibling = next_sibling
492
+ sibling = sibling.next_sibling until sibling.nil? || sibling.element?
493
+ sibling
494
+ end
495
+
496
+ def previous_element
497
+ sibling = previous_sibling
498
+ sibling = sibling.previous_sibling until sibling.nil? || sibling.element?
499
+ sibling
500
+ end
501
+
502
+ def unlink
503
+ if @native_fast
504
+ Leptris::XML::Native.unlink_binding_node(@document, @c_address)
505
+ @parent = nil
506
+ return self
507
+ end
508
+ ensure_writable!
509
+ Leptris::XML::FFI.check_status(
510
+ Leptris::XML::FFI.leptris_node_unlink(c_ptr))
511
+ @parent = nil
512
+ self
513
+ end
514
+ alias_method :remove, :unlink
515
+
516
+ # Visits the subtree with ONE C call (leptris_node_visit,
517
+ # libleptris 1.9.20 — upstream #645a): elements yield twice —
518
+ # (node, true, depth) before their children, (node, false, depth)
519
+ # after the subtree completes — every other kind once with
520
+ # entering=true; depth counts element levels from the receiver.
521
+ # No NodeSet, pointer array, or children memo per level: the
522
+ # leanest full-subtree iteration the binding offers (a document
523
+ # receiver walks the document child chain). The walk is read-only
524
+ # — mutate only between visits.
525
+ #
526
+ # root.visit { |node, entering, depth| ... }
527
+ def visit(&block)
528
+ return enum_for(:visit) unless block
529
+ ensure_alive!
530
+ # TODO.perf/27: the ext's callback rb_yields directly — no
531
+ # FFI::Function closure per call.
532
+ if @addr_reads_fast
533
+ Leptris::XML::Native.visit_binding(@document, @c_address,
534
+ &block)
535
+ return self
536
+ end
537
+ document = @document
538
+ visitor = ::FFI::Function.new(
539
+ :void, [:pointer, :pointer, :int, :int], blocking: true) do |_, node_ptr, entering, depth|
540
+ block.call(
541
+ Leptris::XML::Node.wrap(node_ptr, document),
542
+ entering == 1, depth)
543
+ end
544
+ Leptris::XML::FFI.leptris_node_visit(c_ptr, visitor, nil)
545
+ self
546
+ end
547
+
548
+ # Walks the subtree in post-order DFS (matches Nokogiri's
549
+ # semantics): the receiver, its descendants, nothing else.
550
+ #
551
+ # One FFI call dispatches the whole walk; the C engine invokes the
552
+ # callback once per visited node (the only per-node cost is the
553
+ # C-to-Ruby callback dispatch, not FFI round-trips).
554
+ #
555
+ # Subtree-bounded by abort-at-self (leptris-ruby#89): the C
556
+ # walker was never bounded — after visiting the receiver it
557
+ # pushes the receiver's NEXT SIBLING and continues to the end of
558
+ # the document chain. In post-order the receiver is the LAST
559
+ # node of its own subtree, so returning non-zero at self stops
560
+ # the walk exactly at the boundary (the C loop honors a non-zero
561
+ # callback return). The self comparison is by address; the
562
+ # receiver's handle is stable for the walk's duration.
563
+ #
564
+ # Exceptions raised by the block are re-raised after the walk
565
+ # (leptris-ruby#90): a rescue inside the callback stashes the
566
+ # exception and returns non-zero, aborting the C walk — without
567
+ # it the FFI dispatch silently swallowed the exception and the
568
+ # walk continued with partially processed data.
569
+ def traverse(&block)
570
+ return enum_for(:traverse) unless block
571
+ ensure_alive!
572
+ # TODO.perf/27: post-order + abort-at-self + stash-abort-raise
573
+ # all preserved in the C callback (walk state on its stack).
574
+ # The block forwards explicitly — rb_yield needs it on the C
575
+ # entry's own frame.
576
+ if @addr_reads_fast
577
+ Leptris::XML::Native.traverse_binding(@document, @c_address,
578
+ &block)
579
+ return self
580
+ end
581
+ error = nil
582
+ self_address = @c_address
583
+ callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
584
+ begin
585
+ yield Leptris::XML::Node.wrap(node_ptr, @document)
586
+ node_ptr.address == self_address ? 1 : 0
587
+ rescue Exception => e # rubocop:disable Lint/RescueException
588
+ error = e
589
+ 1
590
+ end
591
+ end
592
+ Leptris::XML::FFI.leptris_node_traverse(
593
+ c_ptr, Leptris::XML::FFI::TRAVERSE_POST_ORDER, callback, nil)
594
+ raise error if error
595
+ self
596
+ end
597
+
598
+ def path
599
+ return @path if memo_hit?(@path_version)
600
+ ensure_alive!
601
+ result = if @addr_reads_fast
602
+ Leptris::XML::Native.fast_path(@c_address)
603
+ else
604
+ str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(c_ptr)
605
+ str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
606
+ end
607
+ if @document
608
+ @path = result
609
+ @path_version = @document.version
610
+ end
611
+ result
612
+ end
613
+
614
+ def css_path
615
+ return @css_path if memo_hit?(@css_path_version)
616
+ result =
617
+ if path.nil?
618
+ nil
619
+ else
620
+ path.split("/").filter_map do |part|
621
+ next nil if part.empty?
622
+ part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
623
+ end.join(" > ")
624
+ end
625
+ if @document
626
+ @css_path = result
627
+ @css_path_version = @document.version
628
+ end
629
+ result
630
+ end
631
+
632
+ # Deep copy in a NEW document via Document.copy_of (the single
633
+ # copy seam — comment/PI children and namespaces both survive,
634
+ # #696/#721/#812; the copier is pool-threaded, ~2.3x Nokogiri;
635
+ # non-element kinds rebuild by value — #161).
636
+ def dup
637
+ ensure_alive!
638
+ # TODO.perf/30: elements copy in one C dispatch (engine
639
+ # create + handle + element_copy + rooted wrap). The
640
+ # namespace lift stays a Ruby decision — the copy_of seam's
641
+ # semantics (#696/#721/#812) run below when needed.
642
+ if element? && @addr_reads_fast
643
+ doc = Leptris::XML::Native.copy_binding_element(
644
+ @document, @c_address)
645
+ unless doc.nil?
646
+ # copy_of's contract returns the copied ROOT (the last
647
+ # expression of the root= assignment), not the document.
648
+ copied = doc.root
649
+ unless Leptris::XML::Element.skip_adoption_lift?(copied)
650
+ Leptris::XML::Element.lift_namespaces_for_adoption(copied, {})
651
+ end
652
+ return copied
653
+ end
654
+ end
655
+ Leptris::XML::Document.copy_of(self)
656
+ end
657
+ alias_method :clone, :dup
658
+
659
+ def ==(other)
660
+ return false unless other.is_a?(Leptris::XML::Node)
661
+ c_ptr == other.c_ptr
662
+ end
663
+
664
+ def inspect
665
+ "#<#{self.class.name} ptr=#{c_ptr}>"
666
+ end
667
+
668
+ protected
669
+
670
+ # A memo is valid while the document's mutation version has not
671
+ # advanced since that memo was stored. Each memoized field carries
672
+ # its OWN stamp — a shared node-level stamp would let one field's
673
+ # recompute resurrect another field's stale memo. Readonly
674
+ # documents never advance the version, so their memos are forever
675
+ # valid (ADR 0003 semantics); writable documents gain memos
676
+ # between mutations.
677
+ def memo_hit?(stamp)
678
+ @document && stamp == @document.version
679
+ end
680
+
681
+ def as_element_or_self
682
+ is_a?(Leptris::XML::Element) ? self : nil
683
+ end
684
+
685
+ include Leptris::XML::Searchable
686
+ end