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,716 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ class Leptris::XML::Document
6
+
7
+ # @api private
8
+ # Internal flag container shared between the Document instance and its
9
+ # GC finalizer. Using a one-element Array because Procs close over
10
+ # variables by reference — mutating freed[0] is visible from both
11
+ # the explicit `free` path and the finalizer. This eliminates the
12
+ # double-free that FFI::AutoPointer's release proc caused when
13
+ # `Document#free` was called explicitly and then GC ran.
14
+ Freed = Struct.new(:state) # state: :alive | :freed
15
+
16
+ # Mutation version: advanced by every data mutation (via
17
+ # Node#ensure_writable!, root=, add_pi). Node memos stamp the
18
+ # version they were computed under and recompute after any bump —
19
+ # the invalidation that makes WRITABLE-document memoization sound.
20
+ # Readonly documents never advance it, so their memos are forever
21
+ # valid (ADR 0003 semantics, unchanged).
22
+ def version
23
+ @version
24
+ end
25
+
26
+ # Called by the mutation gates (Node#ensure_writable!, root=,
27
+ # add_pi) — every memo stamped with an older version discards.
28
+ def advance_version
29
+ @version += 1
30
+ end
31
+
32
+ def initialize(c_ptr = nil, freed = Freed.new(:alive))
33
+ @c_ptr = c_ptr
34
+ # Plain-Integer address twin of c_ptr: the native layer reads
35
+ # this ivar directly (no method dispatch), nil'ed exactly when
36
+ # c_ptr is.
37
+ @c_address = c_ptr&.address
38
+ @freed = freed
39
+ @readonly = false
40
+ @version = 0
41
+ # Per-document STRONG cache for Node wrappers, keyed on c_ptr
42
+ # address. Every wrapper is created through Node.wrap, which is the
43
+ # single construction path, so the same C node always yields the
44
+ # same Ruby object. Cleared when the Document is freed — no stale
45
+ # entries.
46
+ #
47
+ # Deliberately NOT ObjectSpace::WeakMap: a weak cache makes wrapper
48
+ # identity a GC race. `doc.root.equal?(doc.root)` failed on the
49
+ # Windows CI matrix (188 examples, the 4 identity specs) because
50
+ # between the two calls the first wrapper was referenced only by
51
+ # the weak map — any GC sweep evicted it and the second call built
52
+ # a fresh object. A strong cache costs at most one wrapper per node
53
+ # actually visited, held until the document dies.
54
+ #
55
+ # Allocated lazily: parse-heavy loops stop paying one Hash per
56
+ # document for trees that are freed before any wrap.
57
+ end
58
+
59
+ # TODO.perf/37: the Integer address is canonical; the Pointer
60
+ # materializes only when read (parse/create faces skip it).
61
+ # A freed document answers nil — never re-materialize a stale
62
+ # or absent address.
63
+ def c_ptr
64
+ @c_ptr ||= (@c_address && ::FFI::Pointer.new(@c_address))
65
+ end
66
+
67
+ # The Integer twin of #c_ptr.
68
+ def c_address
69
+ @c_address
70
+ end
71
+
72
+ def wrapper_cache
73
+ @wrapper_cache ||= {}
74
+ end
75
+
76
+ # #231: deterministic scope — yields the document, guarantees
77
+ # teardown at block exit (even on raise). Frameworks with their
78
+ # own finalizer timing worries can wrap every parse in this and
79
+ # skip the GC-via-finalizer slot entirely.
80
+ def self.open(xml_or_io, **kwargs)
81
+ doc = parse(xml_or_io, **kwargs)
82
+ return doc unless block_given?
83
+ begin
84
+ yield doc
85
+ ensure
86
+ doc.free
87
+ end
88
+ end
89
+
90
+ # #230: bulk hydration walks — one C pass per subtree, no per-node
91
+ # Ruby wrapper construction. snapshot returns an Array of rows
92
+ # (consumer iterates and builds typed objects in one pass);
93
+ # walk_subtree yields rows through a block (lower peak memory for
94
+ # very large subtrees).
95
+ def snapshot(node)
96
+ if defined?(Leptris::XML::NATIVE_FAST)
97
+ Leptris::XML::Native.snapshot_subtree(
98
+ self, node.respond_to?(:c_address) ? node.c_address : nil)
99
+ else
100
+ rows = []
101
+ walk_subtree(node) { |row| rows << row }
102
+ rows
103
+ end
104
+ end
105
+
106
+ def walk_subtree(node, &block)
107
+ return enum_for(:walk_subtree, node) unless block
108
+ if defined?(Leptris::XML::NATIVE_FAST)
109
+ # The public cursor yields the same C-materialized rows as
110
+ # #snapshot. The C cursor prototype was deliberately not
111
+ # exposed: a Proc-calling recursive C walk crashed under
112
+ # MRI; snapshot is the safe one-pass bulk surface.
113
+ snapshot(node).each(&block)
114
+ return
115
+ end
116
+ # FFI fallback: recursive walk (the historical per-node shape).
117
+ if node.is_a?(Leptris::XML::Node)
118
+ yield(element_row(node)) if node.element?
119
+ node.element_children.each { |c| walk_subtree(c, &block) }
120
+ end
121
+ end
122
+
123
+ def element_row(el)
124
+ { kind: "element", name: el.name, prefix: el.namespace&.prefix,
125
+ uri: el.namespace&.href, attrs: el.attributes,
126
+ text: el.element_children.find { |c| c.text? }&.content,
127
+ depth: 0 }
128
+ end
129
+
130
+ def self.parse(xml_or_io, options: nil, readonly: false, recover: false)
131
+ xml = xml_or_io.is_a?(String) ? xml_or_io : xml_or_io.read
132
+ if xml.empty?
133
+ raise Leptris::XML::ParseError, "empty input"
134
+ end
135
+ # The default path (no options, no recover) allocates nothing:
136
+ # ParseOptions.new here would only discover flags==0 (#187 —
137
+ # per-call overhead is most of a small-document parse).
138
+ if options.nil? && !recover
139
+ # TODO.perf/35: parse + wrapper + lifetime handle in one C
140
+ # dispatch; failure raises through the shared path below.
141
+ if defined?(Leptris::XML::NATIVE_FAST)
142
+ doc = Leptris::XML::Native.parse_binding_document(xml)
143
+ raise Leptris::XML::ParseError,
144
+ "leptris_parse_string failed: " +
145
+ Leptris::XML::FFI.leptris_last_error.to_s if doc.nil?
146
+ return doc.tap { |d| d.readonly! if readonly }
147
+ end
148
+ raw = Leptris::XML::FFI.leptris_parse_string(xml, xml.bytesize, nil)
149
+ if raw.null?
150
+ raise Leptris::XML::ParseError,
151
+ "leptris_parse_string failed: " +
152
+ Leptris::XML::FFI.leptris_last_error.to_s
153
+ end
154
+ return wrap(raw).tap { |doc| doc.readonly! if readonly }
155
+ end
156
+ if options.nil?
157
+ options = Leptris::XML::ParseOptions.new(recover: recover)
158
+ elsif recover && !options.recover?
159
+ options = options | Leptris::XML::ParseOptions.new(recover: true)
160
+ elsif !options.is_a?(Leptris::XML::ParseOptions)
161
+ raise ArgumentError, "options must be a Leptris::XML::ParseOptions"
162
+ end
163
+ # The status out-param is nullable; the thread-local last error
164
+ # carries failure detail, and skipping the per-parse MemoryPointer
165
+ # is measurable on small documents.
166
+ raw =
167
+ if options.struct_required?
168
+ # Recover is a struct field, not a parse flag — the options
169
+ # struct path (leptris_parse_string_ex) is the only carrier.
170
+ options_struct = options.to_c_struct
171
+ Leptris::XML::FFI.leptris_parse_string_ex(
172
+ xml, xml.bytesize, options_struct.pointer, nil)
173
+ elsif options.flags.zero?
174
+ Leptris::XML::FFI.leptris_parse_string(xml, xml.bytesize, nil)
175
+ else
176
+ Leptris::XML::FFI.leptris_parse_string_flags(
177
+ xml, xml.bytesize, options.flags, nil)
178
+ end
179
+ if raw.null?
180
+ if options.recover?
181
+ # Unreachable in practice: recover returns an empty document
182
+ # rather than NULL; kept so a contract change fails loudly.
183
+ raise Leptris::XML::Error,
184
+ "leptris_parse_string_ex returned NULL under recover"
185
+ end
186
+ raise Leptris::XML::ParseError,
187
+ "leptris_parse_string failed: " +
188
+ Leptris::XML::FFI.leptris_last_error.to_s
189
+ end
190
+ wrap(raw).tap { |doc| doc.readonly! if readonly }
191
+ end
192
+
193
+ def self.parse_file(path, readonly: false)
194
+ raw = Leptris::XML::FFI.leptris_parse_file(path, nil)
195
+ if raw.null?
196
+ raise Leptris::XML::ParseError,
197
+ "leptris_parse_file failed: " +
198
+ Leptris::XML::FFI.leptris_last_error.to_s
199
+ end
200
+ wrap(raw).tap { |doc| doc.readonly! if readonly }
201
+ end
202
+
203
+ # Create an empty document (no root element) backed by its own memory
204
+ # pool. Elements for the tree are created against it via
205
+ # #create_element and friends, then attached with #root=.
206
+ def self.create
207
+ # One C dispatch (TODO.perf/12): engine create + wrapper
208
+ # (ivar-seeded) + lifetime handle. The FFI+wrap shape stays
209
+ # for LEPTRIS_NO_NATIVE.
210
+ if defined?(Leptris::XML::NATIVE_FAST)
211
+ doc = Leptris::XML::Native.create_binding_document
212
+ raise Leptris::XML::Error,
213
+ "leptris_document_create failed" if doc.nil?
214
+ return doc
215
+ end
216
+ raw = Leptris::XML::FFI.leptris_document_create
217
+ raise Leptris::XML::Error,
218
+ "leptris_document_create failed" if raw.null?
219
+ wrap(raw)
220
+ end
221
+
222
+ # Deep copy of +node+ in a NEW document — the one authority
223
+ # behind Node#dup / Element#dup and the indent-unit path.
224
+ # Elements deep-copy through the C copier (every child kind and
225
+ # namespace survives, #696/#721/#812); every other node kind
226
+ # rebuilds by value through this document's factories — their
227
+ # entire state is the payload the factory takes (#161). The
228
+ # returned node is detached from the original and fully usable.
229
+ def self.copy_of(node)
230
+ new_doc = create
231
+ return rebuild_node(node, new_doc) unless node.element?
232
+ copy = Leptris::XML::FFI.leptris_element_copy(node.c_ptr, new_doc.c_ptr)
233
+ raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
234
+ new_doc.root = Leptris::XML::Node.wrap(copy, new_doc)
235
+ end
236
+
237
+ def self.rebuild_node(node, new_doc)
238
+ case node.type
239
+ when Leptris::XML::FFI::NODE_TEXT
240
+ new_doc.create_text_node(node.content)
241
+ when Leptris::XML::FFI::NODE_CDATA
242
+ new_doc.create_cdata(node.content)
243
+ when Leptris::XML::FFI::NODE_COMMENT
244
+ new_doc.create_comment(node.content)
245
+ when Leptris::XML::FFI::NODE_PI
246
+ new_doc.create_processing_instruction(node.name, node.content)
247
+ else
248
+ raise Leptris::XML::Error,
249
+ "dup is not supported for #{node.class} nodes"
250
+ end
251
+ end
252
+ private_class_method :rebuild_node
253
+
254
+ # Convert a raw LeptrisDocument pointer into a Ruby Document with safe
255
+ # GC lifetime management. The finalizer captures the raw address
256
+ # integer (not the Document or Pointer object — those would prevent
257
+ # GC) and shares a one-shot flag with the instance so explicit
258
+ # `#free` and the GC finalizer can never both call
259
+ # `leptris_document_free` on the same address.
260
+ def self.wrap(raw_address)
261
+ addr = raw_address.is_a?(::FFI::Pointer) ? raw_address.address : raw_address
262
+ ptr = ::FFI::Pointer.new(addr)
263
+ freed = Freed.new(:alive)
264
+ doc = new(ptr, freed)
265
+ if defined?(Leptris::XML::NATIVE_FAST)
266
+ # TypedData dfree owns the release at GC (TODO.perf/12): no
267
+ # Ruby finalizer, no FFI dispatch from finalizer context.
268
+ # Freed stays the shared free-state for #freed?/#free.
269
+ Leptris::XML::Native.doc_handle_attach(doc)
270
+ else
271
+ ObjectSpace.define_finalizer(doc, finalizer(addr, freed))
272
+ end
273
+ doc
274
+ end
275
+
276
+ def self.finalizer(address, freed)
277
+ proc do
278
+ next if freed.state == :freed
279
+ freed.state = :freed
280
+ Leptris::XML::FFI.leptris_document_free(::FFI::Pointer.new(address))
281
+ end
282
+ end
283
+ private_class_method :finalizer
284
+
285
+ def root
286
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
287
+ return nil if c_ptr.nil?
288
+ # Version-stamped memo (TODO.perf/13): root changes only
289
+ # through mutations that advance @version (root=, unlink) or
290
+ # #free (which nils c_ptr above) — the entry point of every
291
+ # pipeline stops paying FFI + wrap per call.
292
+ return @root if @root_version == @version
293
+ ptr = Leptris::XML::FFI.leptris_document_root(c_ptr)
294
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, self)
295
+ @root = result
296
+ @root_version = @version
297
+ result
298
+ end
299
+
300
+ # The document node — navigation head over the whole tree chain
301
+ # [prolog comments/PIs, root element, epilog comments/PIs] in
302
+ # document order (libleptris 1.9.7, upstream #580: the libxml2
303
+ # model; XPath /comment() and //processing-instruction() see the
304
+ # document-level nodes). A stable, document-owned singleton —
305
+ # Node.wrap's cache keeps the returned wrapper identical.
306
+ def node
307
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
308
+ @node ||= Leptris::XML::Node.wrap(
309
+ Leptris::XML::FFI.leptris_document_node(c_ptr), self)
310
+ end
311
+
312
+ # The document's children, via the document node: prolog
313
+ # comments/PIs, the root element, epilog comments/PIs, in
314
+ # document order (Nokogiri-parity shape).
315
+ #
316
+ # On programmatically built documents the C document node's chain
317
+ # misses a root attached via #root= until some other document
318
+ # mutation refreshes it (leptris-ruby#91 — libleptris's
319
+ # document_set_root does not register into the chain). The merge
320
+ # below splices the attached root in by document order whenever
321
+ # the chain lacks it, so parsed and built documents read the
322
+ # same. Prolog/epilog placement uses node_compare against the
323
+ # root.
324
+ def children
325
+ doc_node = node
326
+ return Leptris::XML::NodeSet.new(self, []) if doc_node.nil?
327
+ kids = doc_node.children.to_a
328
+ root_ptr = Leptris::XML::FFI.leptris_document_root(c_ptr)
329
+ return kids if root_ptr.null?
330
+ return kids if kids.any? { |child| child.c_ptr == root_ptr }
331
+ root = Leptris::XML::Node.wrap(root_ptr, self)
332
+ # A replaced root stays in the C chain until another document
333
+ # mutation refreshes it — the chain's element slots other than
334
+ # the current root are stale old roots. The new root inherits
335
+ # the first stale slot's position (the prolog/epilog split
336
+ # follows where the old root sat); with no stale element
337
+ # (programmatically built documents) placement falls back to
338
+ # document-order comparison.
339
+ stale = kids.select do |child|
340
+ child.element? && child.c_ptr != root_ptr
341
+ end
342
+ if stale.any?
343
+ kept = kids.reject { |child| stale.include?(child) }
344
+ slot = kids.index(stale.first)
345
+ kept.insert(slot, root)
346
+ Leptris::XML::NodeSet.new(self, kept)
347
+ else
348
+ prolog, epilog = kids.partition do |child|
349
+ Leptris::XML::FFI.leptris_node_compare(child.c_ptr, root_ptr).negative?
350
+ end
351
+ Leptris::XML::NodeSet.new(self, prolog + [root] + epilog)
352
+ end
353
+ end
354
+
355
+ # Attach +element+ as the document's root element. The element must
356
+ # have been created against this document and must not already have
357
+ # a parent. Any previous root is left detached (still owned by the
358
+ # document's pool until #free).
359
+ def root=(element)
360
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
361
+ # Same mutation gate as every other writer — the FFI path
362
+ # historically missed it (TODO.perf/33: the C face exposed
363
+ # the divergence; readonly is one-way, so one ivar read).
364
+ if @readonly
365
+ raise Leptris::XML::ReadOnlyError,
366
+ "document is readonly — root= attempted"
367
+ end
368
+ # A document root has no in-scope declarations of its own —
369
+ # lift everything the element's source scope carried (#178).
370
+ unless Leptris::XML::Element.skip_adoption_lift?(element)
371
+ Leptris::XML::Element.lift_namespaces_for_adoption(element, {})
372
+ end
373
+ # TODO.perf/33: gates + bump + engine set_root in one C
374
+ # dispatch (the FFI call plus the c_ptr materialization fed
375
+ # ~5% of a fresh-doc build).
376
+ if defined?(Leptris::XML::NATIVE_FAST)
377
+ Leptris::XML::Native.set_binding_root(self, element.c_address)
378
+ else
379
+ Leptris::XML::FFI.check_status(
380
+ Leptris::XML::FFI.leptris_document_set_root(c_ptr, element.c_ptr))
381
+ @version += 1
382
+ end
383
+ # Seed the root memo through wrap: a cross-document element
384
+ # must enter THIS document's identity cache with @document
385
+ # pointing here, not ride its source-document wrapper.
386
+ @root = Leptris::XML::Node.wrap(element.c_ptr, self)
387
+ @root_version = @version
388
+ Leptris::XML::Node.invalidate_cross_document!(element, self)
389
+ element
390
+ end
391
+
392
+ def create_element(name)
393
+ # TODO.perf/09: one C call creates + wraps when the ext is
394
+ # loaded — no FFI marshaling, no wrap_fresh frames.
395
+ if defined?(Leptris::XML::NATIVE_FAST)
396
+ node = Leptris::XML::Native.create_binding_element(self, name.to_s)
397
+ raise Leptris::XML::Error, "leptris_element_create failed" if node.nil?
398
+ return node
399
+ end
400
+ ptr = Leptris::XML::FFI.leptris_element_create(c_ptr, name)
401
+ raise Leptris::XML::Error, "leptris_element_create failed" if ptr.null?
402
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_ELEMENT)
403
+ end
404
+
405
+ def create_text_node(content)
406
+ if defined?(Leptris::XML::NATIVE_FAST)
407
+ node = Leptris::XML::Native.create_binding_text(self, content.to_s)
408
+ raise Leptris::XML::Error, "leptris_text_node_create failed" if node.nil?
409
+ return node
410
+ end
411
+ ptr = Leptris::XML::FFI.leptris_text_node_create(c_ptr, content.to_s)
412
+ raise Leptris::XML::Error, "leptris_text_node_create failed" if ptr.null?
413
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_TEXT)
414
+ end
415
+
416
+ def create_comment(content)
417
+ ptr = Leptris::XML::FFI.leptris_comment_node_create(c_ptr, content.to_s)
418
+ raise Leptris::XML::Error, "leptris_comment_node_create failed" if ptr.null?
419
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_COMMENT)
420
+ end
421
+
422
+ def create_cdata(content)
423
+ ptr = Leptris::XML::FFI.leptris_cdata_node_create(c_ptr, content.to_s)
424
+ raise Leptris::XML::Error, "leptris_cdata_node_create failed" if ptr.null?
425
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_CDATA)
426
+ end
427
+
428
+ def create_processing_instruction(target, data = "")
429
+ ptr = Leptris::XML::FFI.leptris_pi_node_create(c_ptr, target.to_s, data.to_s)
430
+ raise Leptris::XML::Error, "leptris_pi_node_create failed" if ptr.null?
431
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_PI)
432
+ end
433
+
434
+ def fragment(markup)
435
+ Leptris::XML::DocumentFragment.parse(markup, self)
436
+ end
437
+
438
+ def dup
439
+ raw = Leptris::XML::FFI.leptris_document_copy(c_ptr)
440
+ raise Leptris::XML::Error, "leptris_document_copy failed" if raw.null?
441
+ self.class.wrap(raw)
442
+ end
443
+ alias_method :clone, :dup
444
+
445
+ def doctype
446
+ ptr = Leptris::XML::FFI.leptris_document_internal_subset(c_ptr)
447
+ return nil if ptr.null?
448
+ Leptris::XML::DocType.new(ptr, self)
449
+ end
450
+ alias_method :internal_subset, :doctype
451
+
452
+ # indent_text selects the ext-serializer knob (libleptris 1.9.22):
453
+ # a STRING is the indent unit with Nokogiri's semantics — the unit
454
+ # replaces the default spaces, one copy per depth level, standard
455
+ # layout (requires indent > 0); true selects the display form
456
+ # (1.9.9 #129 — text and mixed content indent too; output is
457
+ # display-oriented and not round-trip-guaranteed).
458
+ def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false)
459
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
460
+ return "" if c_ptr.nil?
461
+ case indent_text
462
+ when String
463
+ return Leptris::XML::Serialization.to_xml_indent_unit(
464
+ c_ptr, indent_text, indent: indent, no_decl: no_decl,
465
+ encoding: encoding)
466
+ when true
467
+ return Leptris::XML::Serialization.to_xml_display(
468
+ c_ptr, indent: indent, no_decl: no_decl, encoding: encoding)
469
+ end
470
+ Leptris::XML::Serialization.to_xml(
471
+ Leptris::XML::Serialization::DOCUMENT_SERIALIZE_INTO, c_ptr,
472
+ indent: indent, no_decl: no_decl, encoding: encoding)
473
+ end
474
+ alias_method :to_s, :to_xml
475
+ alias_method :serialize, :to_xml
476
+
477
+ def save(path, **opts)
478
+ opts_struct, _encoding_anchor = Leptris::XML::Serialization.build_options(
479
+ indent: opts.fetch(:indent, 0),
480
+ no_decl: opts.fetch(:no_decl, false),
481
+ encoding: opts[:encoding])
482
+ status = Leptris::XML::FFI.leptris_document_save_file(
483
+ c_ptr, path, opts_struct.pointer)
484
+ Leptris::XML::FFI.check_status(status)
485
+ self
486
+ end
487
+
488
+ def canonicalize(version = Leptris::XML::FFI::C14N_1_0,
489
+ inclusive_namespaces = nil,
490
+ with_comments: false,
491
+ exclusive: false,
492
+ mode: nil)
493
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
494
+ return "" if c_ptr.nil?
495
+ resolved_mode = mode || (exclusive ? Leptris::XML::FFI::C14N_MODE_EXCLUSIVE
496
+ : Leptris::XML::FFI::C14N_MODE_CANONICAL)
497
+ Leptris::XML::Serialization.canonicalize(
498
+ Leptris::XML::FFI.method(:leptris_c14n_canonicalize_ex), c_ptr,
499
+ version: version, mode: resolved_mode,
500
+ inclusive_namespaces: inclusive_namespaces,
501
+ with_comments: with_comments)
502
+ end
503
+ alias_method :c14n, :canonicalize
504
+
505
+ def free
506
+ return if @freed.state == :freed
507
+ @freed.state = :freed
508
+ Leptris::XML::FFI.leptris_document_free(c_ptr) unless c_ptr.nil?
509
+ @c_ptr = nil
510
+ @c_address = nil
511
+ @wrapper_cache&.clear
512
+ # Detach the lifetime handle (TODO.perf/12): the memory is
513
+ # already released above — the GC-pass dfree must no-op.
514
+ Leptris::XML::Native.doc_handle_release(self) if defined?(Leptris::XML::NATIVE_FAST)
515
+ end
516
+
517
+ # Enable the first-party EXSLT-style extension pack on this
518
+ # document: str:/set:/math: prefixed functions (replace, tokenize,
519
+ # split, concat, padding; distinct, intersection, difference,
520
+ # leading, trailing; max, min, abs, sqrt, power) as native C
521
+ # handlers. Returns self for chaining.
522
+ def exslt
523
+ Leptris::XML::FFI.check_status(
524
+ Leptris::XML::FFI.leptris_exslt_enable(c_ptr))
525
+ self
526
+ end
527
+
528
+ # Document-level processing instructions (not tree nodes):
529
+ # an array of [target, data] pairs in document order.
530
+ def processing_instructions
531
+ return @processing_instructions if @pi_version == @version
532
+ count = Leptris::XML::FFI.leptris_document_pi_count(c_ptr)
533
+ result = count.times.map do |i|
534
+ [Leptris::XML::FFI.leptris_document_pi_target(c_ptr, i),
535
+ Leptris::XML::FFI.read_pi_data(
536
+ Leptris::XML::FFI.leptris_document_pi_data(c_ptr, i)).to_s]
537
+ end
538
+ @processing_instructions = result
539
+ @pi_version = @version
540
+ result
541
+ end
542
+
543
+ # Removes a document-level processing instruction — by target
544
+ # (String/Symbol) or by 0-based index among the document's PIs
545
+ # (Integer) (libleptris 1.9.9, upstream #612). Returns the removed
546
+ # PI — pool-owned and valid until #free — or nil when nothing
547
+ # matched.
548
+ def remove_pi(target_or_index)
549
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
550
+ if target_or_index.is_a?(Integer)
551
+ ptr = Leptris::XML::FFI.leptris_document_remove_pi(
552
+ c_ptr, nil, target_or_index)
553
+ else
554
+ ptr = Leptris::XML::FFI.leptris_document_remove_pi(
555
+ c_ptr, target_or_index.to_s, 0)
556
+ end
557
+ return nil if ptr.null?
558
+ @version += 1
559
+ Leptris::XML::Node.wrap(ptr, self)
560
+ end
561
+
562
+ # Append a document-level processing instruction. Returns the PI
563
+ # node (epilog anchor — after the root; identity via
564
+ # leptris_document_append_pi, libleptris 1.9.176 / #212).
565
+ def add_pi(target, data = "")
566
+ witness = Leptris::XML::FFI.leptris_document_append_pi(
567
+ c_ptr, target.to_s, data.to_s)
568
+ raise Leptris::XML::Error, "leptris_document_append_pi failed" if witness.null?
569
+ @version += 1
570
+ Leptris::XML::Node.wrap(witness, self)
571
+ end
572
+
573
+ # Remove a document-level PI or comment from the document
574
+ # children chain (libleptris 1.9.176 / #212). The node stays
575
+ # document-owned. Raises when the node is not in the chain.
576
+ def remove_child(node)
577
+ Leptris::XML::FFI.check_status(
578
+ Leptris::XML::FFI.leptris_document_remove_child(c_ptr, node.c_ptr))
579
+ @version += 1
580
+ self
581
+ end
582
+
583
+ # Create an &name; entity reference node (libleptris 1.9.176 /
584
+ # #212): serializes back verbatim once attached.
585
+ def create_entity_reference(name)
586
+ ptr = Leptris::XML::FFI.leptris_entity_ref_node_create(
587
+ c_ptr, name.to_s)
588
+ raise Leptris::XML::Error, "leptris_entity_ref_node_create failed" if ptr.null?
589
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_ENTITY_REF)
590
+ end
591
+
592
+ # The XML declaration (libleptris 1.9.176 / #212). `#version`
593
+ # stays the mutation counter — the declaration reads are
594
+ # xml_-prefixed.
595
+ def xml_version
596
+ Leptris::XML::FFI.leptris_document_version(c_ptr)
597
+ end
598
+
599
+ def xml_standalone
600
+ v = Leptris::XML::FFI.leptris_document_standalone(c_ptr)
601
+ v < 0 ? nil : (v == 1)
602
+ end
603
+
604
+ def xml_encoding
605
+ Leptris::XML::FFI.leptris_document_encoding(c_ptr)
606
+ end
607
+
608
+ def xml_version=(value)
609
+ Leptris::XML::FFI.check_status(
610
+ Leptris::XML::FFI.leptris_document_set_version(c_ptr, value.to_s))
611
+ value
612
+ end
613
+
614
+ def xml_encoding=(value)
615
+ Leptris::XML::FFI.check_status(
616
+ Leptris::XML::FFI.leptris_document_set_encoding(c_ptr, value.to_s))
617
+ value
618
+ end
619
+
620
+ def xml_standalone=(value)
621
+ Leptris::XML::FFI.check_status(
622
+ Leptris::XML::FFI.leptris_document_set_standalone(c_ptr,
623
+ value.nil? ? -1 : (value ? 1 : 0)))
624
+ value
625
+ end
626
+
627
+ # Programmatic DOCTYPE (libleptris 1.9.176 / #212): create and
628
+ # set the document's DOCTYPE (root name + optional PUBLIC/SYSTEM
629
+ # ids); serializes in document position. Returns the DocType.
630
+ def set_doctype(name, public_id: nil, system_id: nil)
631
+ dt = Leptris::XML::FFI.leptris_document_set_doctype(
632
+ c_ptr, name.to_s, public_id, system_id)
633
+ raise Leptris::XML::Error, "leptris_document_set_doctype failed" if dt.null?
634
+ @version += 1
635
+ Leptris::XML::DocType.new(dt, self)
636
+ end
637
+
638
+ # Append a document-level comment (epilog position, after the
639
+ # root element) — the add_pi twin (libleptris 1.9.160, #1032;
640
+ # parsed document-level comments round-tripped since 1.9.3/#578,
641
+ # this is the writer). Returns self.
642
+ def add_comment(content)
643
+ witness = Leptris::XML::FFI.leptris_document_add_comment(
644
+ c_ptr, content.to_s)
645
+ raise Leptris::XML::Error,
646
+ "leptris_document_add_comment failed" if witness.null?
647
+ @version += 1
648
+ self
649
+ end
650
+
651
+ # Marks the document read-only: tree mutations raise
652
+ # Leptris::XML::ReadOnlyError, and read paths memoize aggressively
653
+ # (names, content, children, attributes) since they can never go
654
+ # stale. The C document is also frozen (advisory upstream). One-way.
655
+ def readonly!
656
+ Leptris::XML::FFI.leptris_document_freeze(c_ptr)
657
+ @readonly = true
658
+ self
659
+ end
660
+
661
+ def readonly?
662
+ @readonly == true
663
+ end
664
+
665
+ # True once #free has run (or the GC finalizer fired) — borrowed
666
+ # handles check this before dereferencing their c_ptr.
667
+ def freed?
668
+ @freed.state == :freed || c_ptr.nil?
669
+ end
670
+
671
+ # Document-level comments — parsed <!-- ... --> outside the
672
+ # root element, prolog then epilog, in document order (the
673
+ # companion reader to #processing_instructions; libleptris
674
+ # 1.9.3, upstream #578). Version-memoized like the PI list.
675
+ def comments
676
+ return @comments if @comments_version == @version
677
+ count = Leptris::XML::FFI.leptris_document_comment_count(c_ptr)
678
+ result = Array.new(count) do |i|
679
+ Leptris::XML::FFI.leptris_document_comment_content(c_ptr, i)
680
+ end
681
+ @comments = result
682
+ @comments_version = @version
683
+ result
684
+ end
685
+
686
+ # The thread-global last-failure [line, column] (1-based), or nil
687
+ # when no error is recorded — the position companion to
688
+ # Document#last_error; populated by recover parses.
689
+ def last_error_position
690
+ line = ::FFI::MemoryPointer.new(:int)
691
+ column = ::FFI::MemoryPointer.new(:int)
692
+ begin
693
+ Leptris::XML::FFI.leptris_last_error_position(line, column)
694
+ line.read_int.zero? && column.read_int.zero? ? nil :
695
+ [line.read_int, column.read_int]
696
+ ensure
697
+ line.free
698
+ column.free
699
+ end
700
+ end
701
+
702
+ # The most recent error recorded against this document, or nil.
703
+ def last_error
704
+ msg = Leptris::XML::FFI.leptris_document_last_error(c_ptr)
705
+ msg.nil? || msg.empty? ? nil : msg
706
+ end
707
+
708
+ def name; "document"; end
709
+ def document; self; end
710
+ def encoding
711
+ return nil if c_ptr.nil?
712
+ Leptris::XML::FFI.leptris_document_encoding(c_ptr)
713
+ end
714
+
715
+ include Leptris::XML::Searchable
716
+ end