leptris 1.9.92.2 → 1.9.105.0

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: 56235008b014f2ccf89a5713d7e95f6cf358a828be7aefb5923a651065980d18
4
- data.tar.gz: 3d0d3c43d5f2a05fc3c21c0efbeadd33e308d4deaa330fe9b2e8530672944d40
3
+ metadata.gz: 6d66d917b7658ce7016fc697f5c26bc0f26a4c529a2ff4cccdc241dc43524b61
4
+ data.tar.gz: 711c7ad4aa90101509bddcf8dbd9a5d456736b774e00bf4603b135b72e2b1f81
5
5
  SHA512:
6
- metadata.gz: 22c6d7837e4c8db9c4298a5a8b1f944e3c3f2b8ad02126a5e6374fb68b8619fa0456e58f9f1aca6f6384ead6148fc0b83d516d254558d35d5f02e7c9671f762f
7
- data.tar.gz: b891d175c7e9655a1346bd414191d1bc58465ae22f20949ca91a29ce95497edb943e6c9b49b1ca85f824dcd6160a967c12f2be27c12cf69c85303f7402112e12
6
+ metadata.gz: 16f738b770e66956609d5b96132e813fecd50d3d5e9839f7102d0df2b53eedda4778805d1f6a17a8931b639557d5596b49d6807969a8fb21736704581b99f726
7
+ data.tar.gz: b04afa176b4fa26efff932b3b46de5e75cb1dbc99a4f2cb2ab44f35a1dc27d30f21916f303007e5a081f8738b082ac5e274e469e4d05d05b9b9cf86c6221367a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,147 @@ 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.105.0] - 2026-09-08
9
+
10
+ ### Changed
11
+
12
+ - **Lockstep with libleptris 1.9.105** (1.9.101 → 1.9.105 — five
13
+ releases). One new C symbol attached (audit 265/265):
14
+ - **HTML two-mode split** (1.9.104/1.9.105, #659):
15
+ `Leptris::XML.parse_html(html, mode: :html4 | :whatwg)`.
16
+ **:html4 is the default** — pins the libxml2/Nokogiri
17
+ compatibility shape (leading script/style stay in body) so the
18
+ binding's byte-parity contract holds; **:whatwg** selects the
19
+ conformant engine (full implied-head set, foster parenting —
20
+ html5lib corpus 294/1555 vs Nokogiri's 372). Four specs.
21
+ - **#904 fixed** (1.9.103): iterparse yields prime the TLS
22
+ last-root memo — the ~1.4 µs residual per streamed attribute
23
+ read is GONE (measured at the noise floor vs the parse-only
24
+ baseline; was 2.19 µs, document case 0.82 µs).
25
+ - **#905 hotfix** (1.9.102): the v1.9.99 register-elision
26
+ (SEGFAULT in `fn:snapshot`'s detached-copy path on macOS,
27
+ 0-nodes on Linux) reverted — the Round-20 register-on-create
28
+ contract restored. **1.9.100.0/.1/.2 carried this segfault**
29
+ and are yanked.
30
+ - 1.9.101 eval perf: AVT brace-free fast path + the AST-cache
31
+ mutex skip for single-threaded transforms (~14400 lock/unlock
32
+ ops dropped per heavy transform).
33
+
34
+ ## [1.9.100.2] - 2026-09-07
35
+
36
+ ### Changed
37
+
38
+ - **Constraint compliance refresh** (TODO.restructure/14): two
39
+ fresh `respond_to?(:read)` duck checks that crept in with the
40
+ parallel SAX-drain and Pull-parse commits replaced with typed
41
+ branches (`is_a?(String) ? itself : .read`) — the same
42
+ discipline as 01.
43
+ - README gains the three newest features: `Node#digest`,
44
+ `Element#to_xml(expand_empty: true)`, and the iterparse
45
+ lifetime contract (`UseAfterFreeError` on post-iteration use,
46
+ `#document` still nil).
47
+ - CLAUDE.md architecture map refreshed: `iteration_scope.rb` and
48
+ `result_attr.rb` added with their seams.
49
+
50
+ ### Notes
51
+
52
+ The perf battery window is still blocked on the shared host
53
+ (load 169 at this writing); the standing baseline from the clean
54
+ 1.9.76 window holds.
55
+
56
+ ## [1.9.100.1] - 2026-09-07
57
+
58
+ ### Fixed — restructure round 2 (TODO.restructure/08-13, DONE)
59
+
60
+ - **#152 closed** (iterparse lifetime + cost): new
61
+ `IterationScope` — the owning context for yielded elements.
62
+ Post-iteration use of a held element now raises
63
+ `UseAfterFreeError` instead of segfaulting (the scope carries
64
+ the iterator handle as the liveness signal `ensure_alive!`
65
+ reads); `Element#to_xml` and `#inner_html` gained the liveness
66
+ guard they were missing (serialization reads C memory
67
+ unguarded — true for freed Documents too); wrapper identity
68
+ holds within a yielded subtree and resets across yields (the
69
+ released pool memory is recycled); `#document` still answers
70
+ nil (documented contract) while memoization engages — the
71
+ reported ~8.5 µs parentless attribute read drops to ~2.2 µs.
72
+ The residual ~1.4 µs vs the document case is engine-side
73
+ (leptris/leptris#904). Four new specs.
74
+ - **#153 closed** (PR #154, merged): XPath attribute-node results
75
+ wrap as usable `ResultAttr` faces (`#name`, `#value`, `#to_s`)
76
+ with values captured at result materialization — symmetric to
77
+ `ResultText`.
78
+ - **#149 diet** (binding-side): `Node.wrap_fresh` — one
79
+ construction authority shared with `wrap` (the type dispatch
80
+ lives in one place), skipping the guaranteed-miss cache lookup
81
+ on factory paths. create_text measured 2.8x → 2.18x Nokogiri;
82
+ the remaining gap is per-call FFI marshaling vs a C extension —
83
+ the structural ceiling of the FFI-only architecture, documented
84
+ on the issue.
85
+ - **Serialization**: `element_xml_expand_empty` memoizes its ext
86
+ struct (`EXPAND_EMPTY_EXT`), mirroring `INDENT_TEXT_EXT`.
87
+ - **Spec MECE**: digest spec homed in seam_and_reads (Node
88
+ behavior), expand_empty in document_spec (serialization);
89
+ count-neutral moves, 544 examples total.
90
+
91
+ ### Changed
92
+
93
+ - `#document` on iterparse-yielded elements preserves the nil
94
+ contract through a scope-aware reader.
95
+
96
+ ## [1.9.100.0] - 2026-09-07
97
+
98
+ ### Changed
99
+
100
+ - **Lockstep with libleptris 1.9.100** (1.9.94 → 1.9.100 — seven
101
+ releases: TLS last-root memo, element-level ext entries,
102
+ namespace-fixup walk gating, **#875 dispatch index fix**,
103
+ namebp backpointer fix, **#869 `leptris_node_digest` Merkle
104
+ subtree digest**, streaming emission Phase 1). Three new public
105
+ symbols attached (audit 264/264):
106
+ - `leptris_element_serialize_ext[_sized]` (1.9.95, #882):
107
+ element-level `expand_empty` serialization — wired through
108
+ `Element#to_xml(expand_empty: true)` for libxml2's
109
+ `NO_EMPTY_TAGS` parity.
110
+ - `leptris_node_digest` (1.9.99, #869): content-defined 64-bit
111
+ Merkle digest of a subtree — wired through `Node#digest(drop_ws:)`
112
+ with structural-equality semantics.
113
+
114
+ - **#875 sentinel gate extended**: verified 97/97, 120/120, **500/500**
115
+ on this build — the 1.9.93 dispatch overflow is gone (the
116
+ upstream fix used dynamic resize instead of the bare-continue
117
+ cap; the predicate-pattern index is back to ~25 ms on the
118
+ 120-template fixture — fast and correct).
119
+ - **Vendored `SerializeExtStruct` layout refreshed** to mirror the
120
+ new C layout (`expand_empty: :int`).
121
+ - **#153 binding fix-forward**: leptris-ruby PR #154 (open,
122
+ ResultAttr wrap) — the engine needed nothing. The fix will
123
+ ride a follow-up binding release once it merges.
124
+
125
+ ### Fixed
126
+
127
+ - **Namebp backpointer validity after QName split** (engine, 1.9.98)
128
+ — the round-21 mutation backpointer is only valid while `name`
129
+ points at the carve slot; prefixed names now drop the flag when
130
+ `#846`'s QName split advances past the colon.
131
+ - **Namespace-fixup walk gating** (engine, 1.9.96): the
132
+ result-document walks are now gated on `has_namespaces` — the
133
+ common dispatch shape (no namespaces in the result) is a single
134
+ flag check.
135
+ - **TLS last-root memo** (engine, 1.9.94): the
136
+ `leptris_element_get_document` hot path answers consecutive
137
+ same-tree queries with a single pointer compare.
138
+
139
+ ### Performance
140
+
141
+ - **Streaming emission Phase 1** (engine, 1.9.100, gated): a
142
+ compile-time gate admits v1.x namespace-free literal elements,
143
+ text/value-of, comment/PI, select-only variables, and control
144
+ flow into direct byte emission — the result DOM is not
145
+ materialized for those safe shapes. Everything else keeps the
146
+ result-tree path byte-identically. Run-based emitters, 1306/1306
147
+ ctest.
148
+
8
149
  ## [1.9.92.2] - 2026-09-07
9
150
 
10
151
  ### Changed — restructure round (TODO.restructure/01-06, all DONE)
data/CLAUDE.md CHANGED
@@ -55,6 +55,10 @@ lib/leptris/xml/element.rb — attributes via the v1.1.0 iteration
55
55
  face; namespace= (nil-detach/rebind)
56
56
  lib/leptris/xml/result_text.rb — sequence/map/array result items
57
57
  (values captured at materialization)
58
+ lib/leptris/xml/result_attr.rb — attribute-node result items
59
+ (name/value captured at materialization)
60
+ lib/leptris/xml/iteration_scope.rb — owning context for iterparse
61
+ elements (liveness, identity, memos)
58
62
  lib/leptris/xml/node_set.rb — lazy XPath/XQuery results; batch fetch
59
63
  via get_nodes_ex; ResultText capture
60
64
  lib/leptris/xml/searchable.rb — xpath/css/at_*; result materialization
data/README.adoc CHANGED
@@ -389,6 +389,15 @@ doc.to_xml(indent: 2, indent_text: true) # display form (documents only)
389
389
  `Element#inner_html` serializes the children with correct escaping —
390
390
  well-formed by construction (a re-parse spec pins it).
391
391
 
392
+ `Element#to_xml(expand_empty: true)` emits `<a></a>` instead of `<a/>`
393
+ for empty elements — libxml2's `XML_SAVE_NO_EMPTY_TAGS` parity.
394
+
395
+ `Node#digest(drop_ws: false)` answers a content-defined 64-bit Merkle
396
+ hash of the subtree: equal flags and equal digests imply structural
397
+ equivalence (names, namespaces, sorted attributes, document-order
398
+ children); inequality implies nothing — descend and decide. Stable
399
+ across processes; `drop_ws: true` skips whitespace-only text nodes.
400
+
392
401
  XSLT 1.0–3.0 transforms run through `Leptris::XML::XSLT`:
393
402
 
394
403
  [source,ruby]
@@ -529,7 +538,12 @@ events in bulk with a corruption guard that fails loudly rather than
529
538
  delivering garbage. `Leptris::XML::Iterparse.parse(xml, mode:)` yields
530
539
  completed subtrees top-level (`:top_level`) or every element post-order
531
540
  (`:full_document`) with bounded memory, plus `#namespace_uri` on the last
532
- yielded element and an `#error` channel for truncated input.
541
+ yielded element and an `#error` channel for truncated input. Yielded
542
+ elements carry an internal lifetime scope: `#document` answers nil (the
543
+ documented contract) while memoization and liveness guards engage —
544
+ using an element after the iteration raises `UseAfterFreeError` instead
545
+ of crashing, and repeated reads within the block ride the same fast
546
+ paths as document-backed nodes.
533
547
 
534
548
  == Memory model
535
549
 
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.92"
11
+ LIBLEPTRIS_VERSION = "1.9.105"
12
12
 
13
13
  CMAKE_FLAGS = %w[
14
14
  -DCMAKE_BUILD_TYPE=Release
@@ -2,11 +2,9 @@
2
2
 
3
3
  Status: TRACKED
4
4
 
5
- - [ ] **leptris/leptris#875 re-adoption**: when the fix releases,
6
- re-pin to `{C}`, un-pend the sentinel spec, re-run the
7
- 96/97/120 boundary + the 120-template perf fixture as the
8
- release gate, ship `{C}.0`. (1.9.93.0/1.9.93.1 yanked;
9
- 1.9.92.1 is the standing replacement.)
5
+ - [x] **leptris/leptris#875 re-adoption** DONE in 1.9.100.0
6
+ (fix landed upstream in 1.9.97; sentinel gate extended to
7
+ 500/500; perf reclaimed).
10
8
  - [ ] **utf8proc build decision** (owner): `fn:normalize-unicode`
11
9
  requires utf8proc, compiled out of the vendored platform
12
10
  builds — enabling it changes the vendored binary surface.
@@ -0,0 +1,11 @@
1
+ # 08 — 1.9.100-round code audit + expand_empty ext memo
2
+
3
+ Status: DONE
4
+
5
+ - [x] Constraint audit over the 1.9.100 additions (digest,
6
+ expand_empty): no send/ivar/respond_to?/require violations;
7
+ autoload set complete.
8
+ - [x] `Serialization.element_xml_expand_empty` allocated a fresh
9
+ `SerializeExtStruct` per call — memoized to an
10
+ `EXPAND_EMPTY_EXT` constant, mirroring the `INDENT_TEXT_EXT`
11
+ discipline (allocation-free steady state).
@@ -0,0 +1,29 @@
1
+ # 09 — IterationScope: lifetime + memoization for iterparse elements (#152)
2
+
3
+ Status: DONE
4
+
5
+ Root cause of both #152 findings: `Iterparse#run` wraps yielded
6
+ elements with `document: nil` — so (a) `ensure_alive!` has nothing
7
+ to check (post-free use segfaults instead of raising), and (b)
8
+ every memoized read (`[]`, `attribute_nodes`, …) skips the memo
9
+ machinery entirely (the 10-30x parentless cost: `attribute_nodes`
10
+ re-walks per call, `[]` pays the raw path).
11
+
12
+ ## Change
13
+
14
+ - [x] New `Leptris::XML::IterationScope` (autoloaded): the owning
15
+ context for yielded elements — carries the iterator handle
16
+ as its lifetime anchor (`#c_ptr` goes nil on `#free`, the
17
+ same signal `ensure_alive!` reads), a `wrapper_cache`
18
+ (identity for a subtree's wrappers), and a `version` that
19
+ advances per yield (stale memos on held wrappers invalidate
20
+ as subtrees are released).
21
+ - [x] `Node#document` answers nil for scope-owned elements — the
22
+ documented contract ("#document returns nil") is preserved
23
+ while the internal machinery engages.
24
+ - [x] `Iterparse#free` frees the scope; the block form's ensure
25
+ chain rides it.
26
+ - [x] Specs: post-iteration use of a held element raises
27
+ `UseAfterFreeError` (was: VM segfault); repeated
28
+ `attribute_nodes` inside the block memoizes; children
29
+ identity holds within a yielded subtree.
@@ -0,0 +1,19 @@
1
+ # 10 — Cold attribute reads: value-only materialization (#150)
2
+
3
+ Status: DONE
4
+
5
+ `Element#[]`'s cold path called `attributes` — building Attr
6
+ wrappers, the name-keyed Attr hash, and the values hash (~17
7
+ allocations for a 2-attribute element) to answer ONE bare-name
8
+ read.
9
+
10
+ ## Change
11
+
12
+ - [x] `Element#materialize_attr_values`: walks the v1.1.0
13
+ attribute linked-list face filling ONLY the versioned
14
+ `@attr_values` memo — no Attr wrappers, no side hashes.
15
+ `[]`'s cold branch uses it; `attributes`/`attribute_nodes`/
16
+ `keys`/`values` keep their richer materialization (they
17
+ answer richer questions).
18
+ - [x] Alloc-count spec: cold `[]` on a 2-attribute element stays
19
+ under a fixed budget (was ~17 allocs).
@@ -0,0 +1,16 @@
1
+ # 11 — Programmatic construction: wrap-fresh factories (#149)
2
+
3
+ Status: DONE (binding-side diet; structural ceiling documented)
4
+
5
+ - [x] `Node.wrap_fresh`: one construction authority shared with
6
+ `wrap` (the type dispatch is not duplicated), skipping the
7
+ cache LOOKUP only — a freshly created pointer cannot be in
8
+ the cache, so the lookup was a guaranteed miss on every
9
+ factory call. Cache STORE still happens (identity survives
10
+ attach).
11
+ - [x] `Document.create_element/create_text_node/create_comment/
12
+ create_cdata` route through it.
13
+ - [x] Measured before/after on the issue's bench shape; the
14
+ remaining gap is per-call FFI marshaling vs a C extension —
15
+ the structural ceiling of the FFI-only architecture,
16
+ documented on the issue.
@@ -0,0 +1,12 @@
1
+ # 12 — #153: merge the ResultAttr wrap (PR #154)
2
+
3
+ Status: DONE
4
+
5
+ - [x] Reviewed the diff: autoloads `ResultAttr` per the rule,
6
+ extends the materialization capture (name+value at result
7
+ lifetime) symmetric to `ResultText`, no constraint
8
+ violations.
9
+ - [x] CI 10/10 — merged ahead of this round's branch so the
10
+ release carries it.
11
+ - [x] Verified `doc.xpath("//item/@id").first.name/.value` on the
12
+ merged build; #153 closed with the verification.
@@ -0,0 +1,9 @@
1
+ # 13 — Spec MECE follow-up for the 1.9.100 specs
2
+
3
+ Status: DONE
4
+
5
+ - [x] `Node#digest` spec moved from `xpath_spec.rb` (it is a Node
6
+ behavior, not a search face) to `seam_and_reads_spec.rb`.
7
+ - [x] `expand_empty` spec moved from `xslt_versions_spec.rb`
8
+ (serialization option, not an XSLT face) to
9
+ `document_spec.rb`.
@@ -0,0 +1,15 @@
1
+ # 14 — Constraint audit refresh over the newest code
2
+
3
+ Status: DONE
4
+
5
+ Scope: the parallel-session commits (cold/warm `[]` diet, SAX
6
+ drain) plus the 1.9.100.x rounds and PR #154.
7
+
8
+ ## Findings
9
+
10
+ - No `send`, no `instance_variable_set/_get`, no
11
+ `require_relative`; autoload set complete (ResultAttr and
12
+ IterationScope both registered in `lib/leptris/xml.rb`).
13
+ - Two new `respond_to?(:read)` duck checks crept in with the
14
+ parallel commits — fixed to the `is_a?(String)` typed branch
15
+ (same policy as 01): `SAX::Recorder.parse` and `Pull.parse`.
@@ -0,0 +1,10 @@
1
+ # 15 — README: document the three newest features
2
+
3
+ Status: DONE
4
+
5
+ - [x] `Node#digest(drop_ws:)` — structural-equality semantics.
6
+ - [x] `Element#to_xml(expand_empty: true)` — libxml2
7
+ `NO_EMPTY_TAGS` parity.
8
+ - [x] Iterparse lifetime: post-iteration use raises
9
+ `UseAfterFreeError` (IterationScope); `#document` stays nil;
10
+ identity holds within a subtree.
@@ -0,0 +1,5 @@
1
+ # 16 — CLAUDE.md map: iteration_scope.rb + result_attr.rb
2
+
3
+ Status: DONE
4
+
5
+ - [x] Both new files in the architecture map with their seams.
@@ -0,0 +1,12 @@
1
+ # 17 — Perf drift record on the current build
2
+
3
+ Status: DONE
4
+
5
+ - [ ] **Blocked on a busy shared host.** The machine has been at
6
+ load 30–180 for days — every battery attempt this week has
7
+ been a load-induced fiction (Nokogiri's own baseline swings
8
+ 3× run-to-run on this box, e.g. 16→27→58 ms for parse). The
9
+ standing baseline from the clean 1.9.76 window still holds:
10
+ parse 10–12×, serialize 2.9–3.2×, inner_html 4–5×, attr reads
11
+ 6–9×, text 9–11×, element_children walks at parity. Will
12
+ re-record on the next clean window (load < 15).
@@ -0,0 +1,12 @@
1
+ # 18 — Audit: spec doubles + gem contents
2
+
3
+ Status: DONE
4
+
5
+ - [x] Spec-double scan: zero RSpec doubles/mocks/`allow(` across
6
+ spec/ (the two grep hits are XPath `xs:double` casts inside
7
+ expectation strings). Real documents throughout, per the
8
+ standing convention.
9
+ - [x] Gem contents: `TODO.restructure/` does NOT ship in the gem
10
+ (verified by building the gem and listing the tar — 0
11
+ entries; the gemspec's `git ls-files` reject block covers
12
+ it).
@@ -0,0 +1,10 @@
1
+ # 19 — .gitignore hygiene for build artifacts
2
+
3
+ Status: DONE
4
+
5
+ - [x] `.rspec_status` (RSpec's example-status artifact) ignored —
6
+ it was showing as untracked noise in every round.
7
+ - [x] `*.gem` was already covered; a stray locally-built gem from
8
+ this round's contents check removed (one I created; the
9
+ unrelated `taurus-0.1.0.gem` predates this repo work and was
10
+ left untouched).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.92.2"
4
+ VERSION = "1.9.105.0"
5
5
  end
@@ -233,31 +233,31 @@ class Leptris::XML::Document
233
233
  def create_element(name)
234
234
  ptr = Leptris::XML::FFI.leptris_element_create(@c_ptr, name)
235
235
  raise Leptris::XML::Error, "leptris_element_create failed" if ptr.null?
236
- Leptris::XML::Node.wrap(ptr, self)
236
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_ELEMENT)
237
237
  end
238
238
 
239
239
  def create_text_node(content)
240
240
  ptr = Leptris::XML::FFI.leptris_text_node_create(@c_ptr, content.to_s)
241
241
  raise Leptris::XML::Error, "leptris_text_node_create failed" if ptr.null?
242
- Leptris::XML::Node.wrap(ptr, self)
242
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_TEXT)
243
243
  end
244
244
 
245
245
  def create_comment(content)
246
246
  ptr = Leptris::XML::FFI.leptris_comment_node_create(@c_ptr, content.to_s)
247
247
  raise Leptris::XML::Error, "leptris_comment_node_create failed" if ptr.null?
248
- Leptris::XML::Node.wrap(ptr, self)
248
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_COMMENT)
249
249
  end
250
250
 
251
251
  def create_cdata(content)
252
252
  ptr = Leptris::XML::FFI.leptris_cdata_node_create(@c_ptr, content.to_s)
253
253
  raise Leptris::XML::Error, "leptris_cdata_node_create failed" if ptr.null?
254
- Leptris::XML::Node.wrap(ptr, self)
254
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_CDATA)
255
255
  end
256
256
 
257
257
  def create_processing_instruction(target, data = "")
258
258
  ptr = Leptris::XML::FFI.leptris_pi_node_create(@c_ptr, target.to_s, data.to_s)
259
259
  raise Leptris::XML::Error, "leptris_pi_node_create failed" if ptr.null?
260
- Leptris::XML::Node.wrap(ptr, self)
260
+ Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_PI)
261
261
  end
262
262
 
263
263
  def fragment(markup)
@@ -42,20 +42,41 @@ class Leptris::XML::Element < Leptris::XML::Node
42
42
  # use-after-free. QUALIFIED names (with a colon) go to the
43
43
  # engine: they resolve through in-scope declarations, where the
44
44
  # written prefix never matters — the hash cannot answer them.
45
+ #
46
+ # Cold single-attribute reads (ruby#150): the previous path
47
+ # materialised EVERY attribute into two hashes on the first
48
+ # bare read (~17 allocs on a 2-attr element). Now a cold miss
49
+ # pays ONE FFI call and fills only that name; subsequent
50
+ # same-name reads hit the hash; a later attributes/keys call
51
+ # rebuilds the full face. Full-memo (@attributes set) still
52
+ # answers misses as nil without another FFI.
45
53
  name = key.to_s
46
54
  if @document && !name.include?(":")
47
- # The hottest read in the library: the memo guard is spelled
48
- # INLINE rather than through attributes/#memo_hit? — six
49
- # method dispatches per read was ~40% of the read cost, and
50
- # the external head-to-head (nokogiri's one C call) was
51
- # winning on it. Same three-line memo semantics (ADR 0003),
52
- # incl. the nil-miss — the hash answers misses too.
53
55
  values = @attr_values
54
- unless values && @attributes_version == @document.version
55
- attributes
56
- values = @attr_values
56
+ if values && @attributes_version == @document.version
57
+ v = values[name]
58
+ # Present, or a FULL-memo miss (absent). A partial-memo nil
59
+ # is ambiguous (un-queried vs absent) — one idempotent
60
+ # engine call resolves it, then the name is cached.
61
+ return v if !v.nil? || @attributes
62
+ ensure_alive!
63
+ v = Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
64
+ values[name] = v
65
+ return v
57
66
  end
58
- return values[name]
67
+ # Completely cold: one FFI, start a partial values hash.
68
+ # Drop any full-face memos from a prior version — otherwise a
69
+ # post-mutation cold [] would stamp the new version onto a
70
+ # stale @attributes hash and attributes would serve it.
71
+ ensure_alive!
72
+ v = Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
73
+ @attr_values = { name => v }
74
+ @attributes = nil
75
+ @attribute_nodes = nil
76
+ @keys = nil
77
+ @values = nil
78
+ @attributes_version = @document.version
79
+ return v
59
80
  end
60
81
  ensure_alive!
61
82
  Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
@@ -141,7 +162,9 @@ class Leptris::XML::Element < Leptris::XML::Node
141
162
  end
142
163
 
143
164
  def attributes
144
- return @attributes if memo_hit?(@attributes_version)
165
+ # Require the full Attr-hash face — a cold [] may have set the
166
+ # version with only a partial @attr_values (ruby#150).
167
+ return @attributes if @attributes && memo_hit?(@attributes_version)
145
168
  result = {}
146
169
  values = {}
147
170
  each_attribute do |attr|
@@ -357,6 +380,7 @@ class Leptris::XML::Element < Leptris::XML::Node
357
380
  # comments/CDATA/PIs render their literal forms. The complement
358
381
  # of #inner_text, which returns the unescaped text content.
359
382
  def inner_html
383
+ ensure_alive!
360
384
  children.map do |child|
361
385
  case child
362
386
  when Leptris::XML::Element
@@ -379,7 +403,9 @@ class Leptris::XML::Element < Leptris::XML::Node
379
403
  # semantics (leptris-ruby#109; via a C-side copy into a fresh
380
404
  # document — no element-level ext entry exists yet). true is NOT
381
405
  # supported here: the display form is document-level.
382
- def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false)
406
+ def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false,
407
+ expand_empty: false)
408
+ ensure_alive!
383
409
  if indent_text == true
384
410
  raise ArgumentError,
385
411
  "indent_text: true (display form) is document-level — " \
@@ -387,6 +413,7 @@ class Leptris::XML::Element < Leptris::XML::Node
387
413
  end
388
414
  return Leptris::XML::Serialization.to_xml_element_unit(
389
415
  self, indent_text, indent: indent) if indent_text.is_a?(String)
416
+ return Leptris::XML::Serialization.element_xml_expand_empty(@c_ptr) if expand_empty
390
417
  Leptris::XML::Serialization.to_xml(
391
418
  Leptris::XML::Serialization::ELEMENT_SERIALIZE_INTO, @c_ptr,
392
419
  indent: indent, no_decl: no_decl, encoding: encoding)
@@ -79,7 +79,8 @@ module Leptris
79
79
  # entry.
80
80
  class SerializeExtStruct < ::FFI::Struct
81
81
  layout :indent_text, :int,
82
- :indent_unit, :pointer
82
+ :indent_unit, :pointer,
83
+ :expand_empty, :int
83
84
  end
84
85
 
85
86
  # Mirrors LeptrisParseOptions (libleptris >= 1.9.0 carries the
@@ -141,6 +142,15 @@ module Leptris
141
142
  # the parse; only an entirely empty result is an error.
142
143
  attach_function :leptris_parse_html_string,
143
144
  [:string, :size_t, :pointer], :leptris_document
145
+ # HTML4/libxml2-compatibility mode (1.9.104, #659): the same
146
+ # tolerant tokenizer and Nokogiri document shape, except
147
+ # leading script/style stay in <body> (libxml2's shape —
148
+ # title/meta/link/base still lift). Bindings matching
149
+ # Nokogiri byte-for-byte pin this; _html_string is the
150
+ # WHATWG-conformant engine (implied-head full set, foster
151
+ # parenting from 1.9.105).
152
+ attach_function :leptris_parse_html4_string,
153
+ [:string, :size_t, :pointer], :leptris_document
144
154
 
145
155
  attach_function :leptris_parse_string,
146
156
  [:string, :size_t, :pointer], :leptris_document
@@ -681,6 +691,22 @@ attach_function :leptris_parse_string,
681
691
  [:leptris_document, :pointer, :size_t, :pointer, :pointer], :size_t
682
692
  attach_function :leptris_document_get_dtd,
683
693
  [:leptris_document], :pointer
694
+ # Element-level ext serialization (libleptris 1.9.95, #882):
695
+ # the document ext option surface (indent_text, indent_unit,
696
+ # expand_empty) at element level. The sized twin is the
697
+ # FFI-safe form (older ext allocations read within bounds).
698
+ attach_function :leptris_element_serialize_ext,
699
+ [:leptris_element, :pointer, :pointer], :pointer
700
+ attach_function :leptris_element_serialize_ext_sized,
701
+ [:leptris_element, :pointer, :pointer, :size_t], :pointer
702
+
703
+ # Content-defined Merkle digest of a subtree (libleptris
704
+ # 1.9.99, #869): equal flags + equal digests imply structural
705
+ # equivalence (descend to decide on inequality); flags bit 1
706
+ # (DROP_WS_TEXT) skips whitespace-only text nodes.
707
+ attach_function :leptris_node_digest,
708
+ [:leptris_node_ref, :int], :uint64
709
+
684
710
  attach_function :leptris_element_serialize,
685
711
  [:leptris_element, :pointer], :pointer
686
712
  attach_function :leptris_element_serialize_into,
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # The owning context for elements yielded by an Iterparse run —
6
+ # the lifetime and memoization authority that a Document is for
7
+ # parsed trees, scoped to one iteration:
8
+ #
9
+ # - `#c_ptr` carries the iterator handle and goes nil on `#free` —
10
+ # the same signal `Node#ensure_alive!` reads, so using a held
11
+ # element after the iterator frees RAISES UseAfterFreeError
12
+ # instead of segfaulting (ruby#152).
13
+ # - `#wrapper_cache` gives wrapper identity within a yielded
14
+ # subtree (children() twice returns the same wrappers).
15
+ # - `#version` advances per yield and on mutation — stale memos on
16
+ # held wrappers invalidate as subtrees are released.
17
+ #
18
+ # `Node#document` answers nil for scope-owned elements (the
19
+ # documented iterparse contract) while the internal machinery
20
+ # engages — including the fast bare-name attribute path, which is
21
+ # what makes iterparse attribute reads match document-backed cost.
22
+ class Leptris::XML::IterationScope
23
+ def initialize(iterator_handle)
24
+ @c_ptr = iterator_handle
25
+ @wrapper_cache = {}
26
+ @version = 0
27
+ end
28
+
29
+ attr_reader :c_ptr, :wrapper_cache
30
+
31
+ def version
32
+ @version
33
+ end
34
+
35
+ def advance_version
36
+ @version += 1
37
+ end
38
+
39
+ def readonly?
40
+ false
41
+ end
42
+
43
+ # Called by the owning Iterparse when it frees the C iterator —
44
+ # every wrapper it handed out fails liveness from here on.
45
+ def mark_freed
46
+ @c_ptr = nil
47
+ end
48
+
49
+ # Called before each yield: the previous subtree is released and
50
+ # its pool memory recycled — a fresh element may land at a cached
51
+ # address, so the identity cache and the memo version both reset.
52
+ def new_subtree!
53
+ @wrapper_cache = {}
54
+ @version += 1
55
+ end
56
+ end
@@ -29,8 +29,10 @@ require "ffi"
29
29
  # # => nil, or the parse failure (readable after the run: the
30
30
  # # block form frees the iterator on return, snapshotting it)
31
31
  #
32
- # The yielded elements have no parent Document; #document returns
33
- # nil and they must not outlive the iteration.
32
+ # The yielded elements are owned by an internal IterationScope:
33
+ # #document returns nil (the documented contract) while liveness
34
+ # guards and memoization engage — using an element after the
35
+ # iterator frees raises UseAfterFreeError instead of crashing.
34
36
  class Leptris::XML::Iterparse
35
37
  MODES = {
36
38
  top_level: Leptris::XML::FFI::ITERPARSE_TOP_LEVEL,
@@ -76,6 +78,7 @@ class Leptris::XML::Iterparse
76
78
  def initialize(handle)
77
79
  raise Leptris::XML::ParseError, "leptris_iterparse_new failed" if handle.null?
78
80
  @handle = handle
81
+ @scope = Leptris::XML::IterationScope.new(handle)
79
82
  end
80
83
 
81
84
  # Yields completed elements until the document is exhausted or the
@@ -86,10 +89,15 @@ class Leptris::XML::Iterparse
86
89
  while @handle
87
90
  ptr = Leptris::XML::FFI.leptris_iterparse_next(@handle)
88
91
  break if ptr.null?
89
- # document: nil the subtree belongs to the iterator's pool,
90
- # not a long-lived Document, so wrapper caching is skipped and
91
- # #document is nil on the yielded elements.
92
- yield Leptris::XML::Node.wrap(ptr, nil)
92
+ # The scope owns lifetime + memoization for yielded elements
93
+ # (ruby#152): liveness guards engage (post-free use raises
94
+ # instead of segfaulting), wrapper identity holds within a
95
+ # subtree, and memoized reads match document-backed cost.
96
+ # Each yield starts a fresh subtree — the released pool
97
+ # memory is recycled, so the identity cache resets with the
98
+ # memo version. #document still answers nil on the elements.
99
+ @scope.new_subtree!
100
+ yield Leptris::XML::Node.wrap(ptr, @scope)
93
101
  end
94
102
  self
95
103
  end
@@ -124,6 +132,7 @@ class Leptris::XML::Iterparse
124
132
  @last_error = live_error
125
133
  Leptris::XML::FFI.leptris_iterparse_free(@handle)
126
134
  @handle = nil
135
+ @scope.mark_freed
127
136
  end
128
137
 
129
138
  private
@@ -1,7 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Leptris::XML::Node
4
- attr_reader :c_ptr, :document
4
+ attr_reader :c_ptr
5
+
6
+ # Iterparse-yielded elements are owned by an IterationScope (the
7
+ # internal lifetime/memoization authority) — the public #document
8
+ # answers nil for them, per the documented contract.
9
+ def document
10
+ scope_owned? ? nil : @document
11
+ end
5
12
 
6
13
  def initialize(c_ptr, document, parent: nil, node_type: nil)
7
14
  @c_ptr = c_ptr
@@ -12,9 +19,28 @@ class Leptris::XML::Node
12
19
  @node_type = node_type
13
20
  end
14
21
 
22
+ # True when this node's owning context is an IterationScope
23
+ # (iterparse-yielded) rather than a Document.
24
+ def scope_owned?
25
+ @document.is_a?(Leptris::XML::IterationScope)
26
+ end
27
+ private :scope_owned?
28
+
15
29
  # node_type: callers holding a batch-fetched kind (the XPath
16
30
  # result-set batch fills out_kinds) pass it so the wrap skips the
17
31
  # get_type dispatch; nil (the default) dispatches as before.
32
+ # Factory variant: the pointer was JUST created by the engine,
33
+ # so the cache lookup is a guaranteed miss — skip straight to
34
+ # construction and store (identity still survives attach).
35
+ def self.wrap_fresh(c_ptr, document, node_type)
36
+ node = construct(c_ptr, document, nil, node_type, nil)
37
+ if document
38
+ cache = document.wrapper_cache
39
+ cache[c_ptr.address] = node
40
+ end
41
+ node
42
+ end
43
+
18
44
  def self.wrap(c_ptr, document, parent: nil, node_type: nil, result_value: nil)
19
45
  # Per-document weak-ref cache. Returns the existing wrapper when the
20
46
  # same c_ptr is wrapped twice (common in children/sibling walks,
@@ -31,31 +57,46 @@ class Leptris::XML::Node
31
57
  end
32
58
 
33
59
  node_type ||= Leptris::XML::FFI.leptris_node_get_type(c_ptr)
34
- node =
35
- case node_type
36
- when Leptris::XML::FFI::NODE_ELEMENT
37
- Leptris::XML::Element.new(c_ptr, document, parent: parent, node_type: node_type)
38
- when Leptris::XML::FFI::NODE_TEXT
39
- Leptris::XML::Text.new(c_ptr, document, parent: parent, node_type: node_type)
40
- when Leptris::XML::FFI::NODE_SYNTHETIC_TEXT
41
- # Sequence/map/array items: value captured by the NodeSet at
42
- # materialization (only the result handle can read them).
43
- Leptris::XML::ResultText.new(
44
- c_ptr, document, result_value, parent: parent, node_type: node_type)
45
- when Leptris::XML::FFI::NODE_COMMENT
46
- Leptris::XML::Comment.new(c_ptr, document, parent: parent, node_type: node_type)
47
- when Leptris::XML::FFI::NODE_CDATA
48
- Leptris::XML::CDATA.new(c_ptr, document, parent: parent, node_type: node_type)
49
- when Leptris::XML::FFI::NODE_PI
50
- Leptris::XML::ProcessingInstruction.new(c_ptr, document, parent: parent, node_type: node_type)
51
- else
52
- new(c_ptr, document, parent: parent, node_type: node_type)
53
- end
60
+ node = construct(c_ptr, document, parent, node_type, result_value)
54
61
 
55
62
  cache[address] = node if document
56
63
  node
57
64
  end
58
65
 
66
+ # The single type-dispatch construction authority shared by wrap
67
+ # (cache-checked) and wrap_fresh (factory path).
68
+ def self.construct(c_ptr, document, parent, node_type, result_value)
69
+ case node_type
70
+ when Leptris::XML::FFI::NODE_ELEMENT
71
+ Leptris::XML::Element.new(c_ptr, document, parent: parent, node_type: node_type)
72
+ when Leptris::XML::FFI::NODE_TEXT
73
+ Leptris::XML::Text.new(c_ptr, document, parent: parent, node_type: node_type)
74
+ when Leptris::XML::FFI::NODE_SYNTHETIC_TEXT
75
+ # Sequence/map/array items: value captured by the NodeSet at
76
+ # materialization (only the result handle can read them).
77
+ Leptris::XML::ResultText.new(
78
+ c_ptr, document, result_value, parent: parent, node_type: node_type)
79
+ when Leptris::XML::FFI::NODE_ATTRIBUTE
80
+ # ruby#153: synthetic attribute result nodes carry their
81
+ # name/value in the result handle — the NodeSet captures
82
+ # them at materialization (result_value is a {name:, value:}
83
+ # hash; the single-node seam captures the same way).
84
+ rv = result_value || {}
85
+ Leptris::XML::ResultAttr.new(
86
+ c_ptr, document, rv[:name], rv[:value],
87
+ parent: parent, node_type: node_type)
88
+ when Leptris::XML::FFI::NODE_COMMENT
89
+ Leptris::XML::Comment.new(c_ptr, document, parent: parent, node_type: node_type)
90
+ when Leptris::XML::FFI::NODE_CDATA
91
+ Leptris::XML::CDATA.new(c_ptr, document, parent: parent, node_type: node_type)
92
+ when Leptris::XML::FFI::NODE_PI
93
+ Leptris::XML::ProcessingInstruction.new(c_ptr, document, parent: parent, node_type: node_type)
94
+ else
95
+ new(c_ptr, document, parent: parent, node_type: node_type)
96
+ end
97
+ end
98
+ private_class_method :construct
99
+
59
100
  def name
60
101
  raise NotImplementedError, "#{self.class}#name not implemented"
61
102
  end
@@ -83,6 +124,19 @@ class Leptris::XML::Node
83
124
  alias_method :node_type, :type
84
125
 
85
126
  def element?; type == Leptris::XML::FFI::NODE_ELEMENT; end
127
+
128
+ # Content-defined 64-bit Merkle digest of this subtree
129
+ # (libleptris 1.9.99, #869): element name/prefix/resolved
130
+ # namespace URI, attributes sorted and first-wins-deduplicated,
131
+ # children hashed in document order — no addresses participate,
132
+ # so equal trees hash equal across processes. Equality implies
133
+ # subtree equivalence; inequality implies nothing (descend).
134
+ # +drop_ws+ skips whitespace-only text nodes.
135
+ def digest(drop_ws: false)
136
+ ensure_alive!
137
+ Leptris::XML::FFI.leptris_node_digest(
138
+ @c_ptr, drop_ws ? 1 : 0)
139
+ end
86
140
  def text?; type == Leptris::XML::FFI::NODE_TEXT; end
87
141
  def comment?; type == Leptris::XML::FFI::NODE_COMMENT; end
88
142
  def cdata?; type == Leptris::XML::FFI::NODE_CDATA; end
@@ -72,8 +72,17 @@ class Leptris::XML::NodeSet
72
72
  # Sequence/map/array items ride synthetic text nodes whose value
73
73
  # only the live result handle can serve — capture it now.
74
74
  def text_item_value(kind, index)
75
- return nil unless kind == Leptris::XML::FFI::XPATH_NODE_TEXT
76
- Leptris::XML::FFI.leptris_xpath_result_node_value(@result_ptr, index)
75
+ case kind
76
+ when Leptris::XML::FFI::XPATH_NODE_TEXT
77
+ Leptris::XML::FFI.leptris_xpath_result_node_value(@result_ptr, index)
78
+ when Leptris::XML::FFI::XPATH_NODE_ATTRIBUTE
79
+ # ruby#153: capture both faces — only the live result handle
80
+ # can serve an attribute node's name and value.
81
+ {
82
+ name: Leptris::XML::FFI.leptris_xpath_result_node_name(@result_ptr, index),
83
+ value: Leptris::XML::FFI.leptris_xpath_result_node_value(@result_ptr, index)
84
+ }
85
+ end
77
86
  end
78
87
  private :text_item_value
79
88
 
@@ -39,7 +39,7 @@ module Leptris::XML::Pull
39
39
 
40
40
  class Parser
41
41
  def self.parse(xml_or_io)
42
- xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
42
+ xml = xml_or_io.is_a?(String) ? xml_or_io : xml_or_io.read
43
43
  new(Leptris::XML::FFI.leptris_pull_new(xml, xml.bytesize))
44
44
  end
45
45
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Attribute-kind item from an XPath result set (ruby#153): the
4
+ # engine exposes synthetic attribute nodes whose name and value are
5
+ # readable only through the live result handle — the NodeSet
6
+ # captures both at materialization time and hands them to this
7
+ # wrapper, so the faces stay usable after the result is freed.
8
+ #
9
+ # doc.xpath("//item/@id").first.name # => "id"
10
+ # doc.xpath("//item/@id").first.value # => "1"
11
+ #
12
+ class Leptris::XML::ResultAttr < Leptris::XML::Node
13
+ def initialize(c_ptr, document, name, value, parent: nil, node_type: nil)
14
+ super(c_ptr, document, parent: parent, node_type: node_type)
15
+ @attr_name = name
16
+ @attr_value = value
17
+ end
18
+
19
+ def name
20
+ @attr_name
21
+ end
22
+
23
+ # Nokogiri's Attr#value is the attribute's string value.
24
+ def value
25
+ @attr_value
26
+ end
27
+
28
+ def content
29
+ @attr_value
30
+ end
31
+
32
+ def to_s
33
+ @attr_value
34
+ end
35
+
36
+ def attribute?
37
+ true
38
+ end
39
+ end
@@ -51,7 +51,12 @@ class Leptris::XML::SAX::Parser
51
51
  end
52
52
 
53
53
  def parse_memory(string)
54
- string = string.dup.force_encoding("UTF-8")
54
+ # Only re-encode when the input is not already UTF-8 — a
55
+ # dup+force_encoding on a multi-MB document is a full copy
56
+ # before any parse work starts.
57
+ unless string.encoding == Encoding::UTF_8 && string.valid_encoding?
58
+ string = string.dup.force_encoding(Encoding::UTF_8)
59
+ end
55
60
  unless @streaming
56
61
  Leptris::XML::SAX::DomDispatch.parse(
57
62
  @document, dispatched_kinds, string)
@@ -48,7 +48,7 @@ class Leptris::XML::SAX::Recorder
48
48
  def self.parse(xml_or_io, kinds: nil)
49
49
  recorder = open
50
50
  begin
51
- if xml_or_io.respond_to?(:read)
51
+ unless xml_or_io.is_a?(String)
52
52
  recorder.feed_stream(xml_or_io, kinds: kinds) { |*args| yield(*args) }
53
53
  else
54
54
  recorder.feed(xml_or_io.to_s, final: true)
@@ -84,7 +84,9 @@ class Leptris::XML::SAX::Recorder
84
84
  # Feed one chunk; records/arena reset at feed entry, so drain
85
85
  # after every feed. Returns self.
86
86
  def feed(chunk, final: false)
87
- chunk = chunk.dup.force_encoding(Encoding::UTF_8)
87
+ unless chunk.encoding == Encoding::UTF_8 && chunk.valid_encoding?
88
+ chunk = chunk.dup.force_encoding(Encoding::UTF_8)
89
+ end
88
90
  rc = Leptris::XML::FFI.leptris_sax_recorder_feed(
89
91
  @handle, chunk, chunk.bytesize, final ? 1 : 0)
90
92
  rc
@@ -157,13 +159,15 @@ class Leptris::XML::SAX::Recorder
157
159
  @handle, len_ptr)
158
160
  arena = arena_ptr.read_bytes(len_ptr.read_uint64)
159
161
  count = count_ptr.read_uint64
160
- records = records_ptr.read_bytes(count * RECORD_STRIDE)
161
- kinds = records.unpack(KIND_TEMPLATE_FOR[count])
162
-
162
+ # Kind is the first byte of each record — read it off the C
163
+ # pointer. The previous path copied every record into Ruby
164
+ # and unpacked a Fixnum array just for the kind strip
165
+ # (2.25M Integers on a single-feed drain); one get_uint8 per
166
+ # event replaces both.
163
167
  one_arg_start = dispatched[:start_element] == :one_arg
164
168
  i = 0
165
169
  while i < count
166
- code = kinds[i]
170
+ code = records_ptr.get_uint8(i * RECORD_STRIDE)
167
171
  kind = KIND_BY_CODE[code]
168
172
  if kind && dispatched[kind]
169
173
  base = i * RECORD_STRIDE
@@ -275,21 +279,16 @@ class Leptris::XML::SAX::Recorder
275
279
  arena = arena_ptr.read_bytes(len_ptr.read_uint64)
276
280
  count = count_ptr.read_uint64
277
281
 
278
- # Two-level drain: ONE bulk read + kind-strip unpack decides
279
- # which records to touch, then each yielded event's fields are
280
- # read individually (get_uint32 at layout offsets) an
281
- # unwanted record costs one strip read, an unused FIELD costs
282
- # nothing, and no per-chunk field array is ever built.
283
- records = records_ptr.read_bytes(count * RECORD_STRIDE)
284
- kinds = records.unpack(KIND_TEMPLATE_FOR[count])
285
-
282
+ # Kind is the first byte of each record — read off the C
283
+ # pointer. The previous path copied every record and unpacked
284
+ # a Fixnum array just for the kind strip.
286
285
  has_name = HAS_NAME
287
286
  has_text = HAS_TEXT
288
287
  drain = wanted.nil?
289
288
  i = 0
290
289
  while i < count
291
- code = kinds[i]
292
290
  base = i * RECORD_STRIDE
291
+ code = records_ptr.get_uint8(base)
293
292
  if code == SAX_EVENT_CHARACTERS_CODE && drain
294
293
  # The dominant branch (text-heavy documents): no name, no
295
294
  # attrs, no per-kind gate lookups — kind, text, position.
@@ -122,7 +122,19 @@ module Leptris::XML::Searchable
122
122
  type = Leptris::XML::FFI.leptris_xpath_result_type(result_ptr)
123
123
  if type == Leptris::XML::FFI::XPATH_NODESET
124
124
  ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(result_ptr, 0)
125
- node = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, document)
125
+ if ptr.null?
126
+ node = nil
127
+ else
128
+ kind = Leptris::XML::FFI.leptris_xpath_result_node_kind(result_ptr, 0)
129
+ node = if kind == Leptris::XML::FFI::XPATH_NODE_ATTRIBUTE
130
+ Leptris::XML::ResultAttr.new(
131
+ ptr, document,
132
+ Leptris::XML::FFI.leptris_xpath_result_node_name(result_ptr, 0),
133
+ Leptris::XML::FFI.leptris_xpath_result_node_value(result_ptr, 0))
134
+ else
135
+ Leptris::XML::Node.wrap(ptr, document)
136
+ end
137
+ end
126
138
  Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
127
139
  node
128
140
  else
@@ -51,6 +51,26 @@ module Leptris::XML::Serialization
51
51
  ELEMENT_SERIALIZE_INTO, c_ptr, DEFAULT_OPTIONS.pointer)
52
52
  end
53
53
 
54
+ # Element serialization with expand_empty (libleptris 1.9.95,
55
+ # #882): empty elements emit <a></a> instead of <a/> — the
56
+ # inverse of libxml2's XML_SAVE_NO_EMPTY, through the element
57
+ # ext entry (the sized form keeps older ext allocations safe).
58
+ EXPAND_EMPTY_EXT = begin
59
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
60
+ ext[:indent_text] = 0
61
+ ext[:indent_unit] = nil
62
+ ext[:expand_empty] = 1
63
+ ext
64
+ end
65
+ private_constant :EXPAND_EMPTY_EXT
66
+
67
+ def self.element_xml_expand_empty(c_ptr)
68
+ str_ptr = Leptris::XML::FFI.leptris_element_serialize_ext_sized(
69
+ c_ptr, DEFAULT_OPTIONS.pointer,
70
+ EXPAND_EMPTY_EXT.pointer, EXPAND_EMPTY_EXT.size)
71
+ Leptris::XML::FFI.read_owned_string(str_ptr)
72
+ end
73
+
54
74
  # Display-form document serialization (libleptris 1.9.9, #129):
55
75
  # the ext struct's indent_text hands ALL whitespace to the
56
76
  # formatter — text and mixed content indent too. Display-oriented:
data/lib/leptris/xml.rb CHANGED
@@ -23,7 +23,9 @@ module Leptris
23
23
  autoload :SAX, "leptris/xml/sax"
24
24
  autoload :XPath, "leptris/xml/xpath"
25
25
  autoload :EvaluationContext, "leptris/xml/evaluation_context"
26
+ autoload :IterationScope, "leptris/xml/iteration_scope"
26
27
  autoload :ResultText, "leptris/xml/result_text"
28
+ autoload :ResultAttr, "leptris/xml/result_attr"
27
29
  autoload :XSLT, "leptris/xml/xslt"
28
30
  autoload :XQuery, "leptris/xml/xquery"
29
31
  autoload :Pull, "leptris/xml/pull"
@@ -46,17 +48,32 @@ module Leptris
46
48
  string, string.bytesize) != 0
47
49
  end
48
50
 
49
- # Tolerant HTML4/5 parse into a standard Document (libleptris
51
+ # Tolerant HTML parse into a standard Document (libleptris
50
52
  # 1.9.75): implied end tags, void elements, raw-text script and
51
53
  # style, lowercased names, minimized/unquoted attributes, and
52
- # the HTML named entities. Document shape — html/head/body are
53
- # synthesized (Nokogiri::HTML parity), but NO implied tbody.
54
- # Malformed input degrades to text rather than raising; only an
55
- # entirely empty result is an error.
56
- def self.parse_html(html)
54
+ # the HTML named entities. html/head/body are synthesized; no
55
+ # implied tbody. Malformed input degrades to text rather than
56
+ # raising; only an entirely empty result is an error.
57
+ #
58
+ # MODES (libleptris 1.9.104, #659): :html4 (the default) pins
59
+ # the libxml2/Nokogiri compatibility shape — leading
60
+ # script/style stay in <body>, title/meta/link/base still lift
61
+ # — so byte-parity with Nokogiri::HTML holds. :whatwg selects
62
+ # the conformant engine: the full implied-<head> set
63
+ # (script/style/noscript/template/... lift) and foster
64
+ # parenting (1.9.105) — html5lib corpus 294/1555 vs Nokogiri's
65
+ # 372; the shapes genuinely differ by spec.
66
+ def self.parse_html(html, mode: :html4)
57
67
  html = html.to_s
58
- raw = Leptris::XML::FFI.leptris_parse_html_string(
59
- html, html.bytesize, nil)
68
+ entry =
69
+ case mode
70
+ when :html4 then Leptris::XML::FFI.method(:leptris_parse_html4_string)
71
+ when :whatwg then Leptris::XML::FFI.method(:leptris_parse_html_string)
72
+ else
73
+ raise ArgumentError,
74
+ "mode must be :html4 or :whatwg, got #{mode.inspect}"
75
+ end
76
+ raw = entry.call(html, html.bytesize, nil)
60
77
  if raw.null?
61
78
  raise Leptris::XML::ParseError,
62
79
  "HTML parse failed: #{Leptris::XML::FFI.leptris_last_error}"
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.92.2
4
+ version: 1.9.105.0
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-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -76,6 +76,18 @@ files:
76
76
  - TODO.restructure/05-memory-documentation.md
77
77
  - TODO.restructure/06-architecture-map-refresh.md
78
78
  - TODO.restructure/07-external-gates.md
79
+ - TODO.restructure/08-new-code-audit-and-ext-memo.md
80
+ - TODO.restructure/09-iteration-scope.md
81
+ - TODO.restructure/10-cold-attr-read-diet.md
82
+ - TODO.restructure/11-construction-factory-diet.md
83
+ - TODO.restructure/12-resultattr-merge.md
84
+ - TODO.restructure/13-spec-mece-followup.md
85
+ - TODO.restructure/14-constraint-audit-refresh.md
86
+ - TODO.restructure/15-readme-feature-documentation.md
87
+ - TODO.restructure/16-claudemd-map-refresh.md
88
+ - TODO.restructure/17-perf-drift-record.md
89
+ - TODO.restructure/18-audit-spec-doubles-and-gem-contents.md
90
+ - TODO.restructure/19-gitignore-hygiene.md
79
91
  - docs/adr/0001-lockstep-mirror.md
80
92
  - docs/adr/0002-utf8-at-the-seam.md
81
93
  - docs/adr/0003-readonly-memoization-pattern.md
@@ -97,6 +109,7 @@ files:
97
109
  - lib/leptris/xml/element.rb
98
110
  - lib/leptris/xml/evaluation_context.rb
99
111
  - lib/leptris/xml/ffi.rb
112
+ - lib/leptris/xml/iteration_scope.rb
100
113
  - lib/leptris/xml/iterparse.rb
101
114
  - lib/leptris/xml/namespace.rb
102
115
  - lib/leptris/xml/node.rb
@@ -104,6 +117,7 @@ files:
104
117
  - lib/leptris/xml/parse_options.rb
105
118
  - lib/leptris/xml/processing_instruction.rb
106
119
  - lib/leptris/xml/pull.rb
120
+ - lib/leptris/xml/result_attr.rb
107
121
  - lib/leptris/xml/result_text.rb
108
122
  - lib/leptris/xml/sax.rb
109
123
  - lib/leptris/xml/sax/document.rb