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 +4 -4
- data/CHANGELOG.md +141 -0
- data/CLAUDE.md +4 -0
- data/README.adoc +15 -1
- data/Rakefile +1 -1
- data/TODO.restructure/07-external-gates.md +3 -5
- data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
- data/TODO.restructure/09-iteration-scope.md +29 -0
- data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
- data/TODO.restructure/11-construction-factory-diet.md +16 -0
- data/TODO.restructure/12-resultattr-merge.md +12 -0
- data/TODO.restructure/13-spec-mece-followup.md +9 -0
- data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
- data/TODO.restructure/15-readme-feature-documentation.md +10 -0
- data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
- data/TODO.restructure/17-perf-drift-record.md +12 -0
- data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
- data/TODO.restructure/19-gitignore-hygiene.md +10 -0
- data/lib/leptris/version.rb +1 -1
- data/lib/leptris/xml/document.rb +5 -5
- data/lib/leptris/xml/element.rb +39 -12
- data/lib/leptris/xml/ffi.rb +27 -1
- data/lib/leptris/xml/iteration_scope.rb +56 -0
- data/lib/leptris/xml/iterparse.rb +15 -6
- data/lib/leptris/xml/node.rb +75 -21
- data/lib/leptris/xml/node_set.rb +11 -2
- data/lib/leptris/xml/pull.rb +1 -1
- data/lib/leptris/xml/result_attr.rb +39 -0
- data/lib/leptris/xml/sax/parser.rb +6 -1
- data/lib/leptris/xml/sax/recorder.rb +14 -15
- data/lib/leptris/xml/searchable.rb +13 -1
- data/lib/leptris/xml/serialization.rb +20 -0
- data/lib/leptris/xml.rb +25 -8
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d66d917b7658ce7016fc697f5c26bc0f26a4c529a2ff4cccdc241dc43524b61
|
|
4
|
+
data.tar.gz: 711c7ad4aa90101509bddcf8dbd9a5d456736b774e00bf4603b135b72e2b1f81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
- [
|
|
6
|
-
|
|
7
|
-
|
|
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,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).
|
data/lib/leptris/version.rb
CHANGED
data/lib/leptris/xml/document.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
260
|
+
Leptris::XML::Node.wrap_fresh(ptr, self, Leptris::XML::FFI::NODE_PI)
|
|
261
261
|
end
|
|
262
262
|
|
|
263
263
|
def fragment(markup)
|
data/lib/leptris/xml/element.rb
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/leptris/xml/ffi.rb
CHANGED
|
@@ -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
|
|
33
|
-
#
|
|
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
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
|
|
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
|
data/lib/leptris/xml/node.rb
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Leptris::XML::Node
|
|
4
|
-
attr_reader :c_ptr
|
|
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
|
data/lib/leptris/xml/node_set.rb
CHANGED
|
@@ -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
|
-
|
|
76
|
-
Leptris::XML::FFI
|
|
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
|
|
data/lib/leptris/xml/pull.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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 =
|
|
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
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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.
|
|
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-
|
|
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
|