leptris 1.9.93.0-aarch64-linux → 1.9.100.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ebdc082d76b283134bda0418f21d6b65358f2391a585f831b3d7798883f07b5
4
- data.tar.gz: ccfb4ffc72881b4333fc529b772554b0d9fec1698ec7072ead485726e8718762
3
+ metadata.gz: 44ed8b08a6de1caebd58bc36a7851af7910e715c741a3ba9df613559d1fd3962
4
+ data.tar.gz: 725b2f7a0efdfa263178c7761d98f1458ed8ee028c8750e0ef475c304450c2c8
5
5
  SHA512:
6
- metadata.gz: e14942acf892ed7be8e4e699556fd2f2576c275102f1c0cc6ddae2b481dc47567a3744b07446670d07965b147a216e970824f74fafe506e2dad0cc12988446ac
7
- data.tar.gz: d37b4f9268de085bd276dc58d64530d2be975509298627bbe09dfad766c412352ff441ece4573a6068970aafb6a5a6df987dbd4039e162b3389f3e86a0c09faa
6
+ metadata.gz: 5ffd33f5dbc8ca062921ae57cc1b3114cad81a7c811a5c6a49d34cf6fc035d877c496354b4e0a0c29e75aaf24539a04c51b2ee33b49b9f52916a692076170250
7
+ data.tar.gz: f98c4c66fe9b83ae722bdc18bf61093ff2e25fa96b861d5f0bd6df027e4a16285cb15061b70ab387fd77cc0123459f09058d683b66510abd8ec4a112da215cb6
data/CHANGELOG.md CHANGED
@@ -5,56 +5,161 @@ 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.93.0] - 2026-09-06
8
+ ## [1.9.100.0] - 2026-09-07
9
9
 
10
10
  ### Changed
11
11
 
12
- - **Lockstep with libleptris 1.9.93** (1.9.91 → 1.9.93; no new C
13
- surface — the audit holds at 261/261):
14
- - 1.9.93 — **predicate-pattern dispatch indexes** (leptris/
15
- leptris#866, filed from this binding last round): name keys
16
- (a `foo` element never evaluates `item[...]` candidates) and a
17
- literal `(name, attr, value)` bucket. Our exact fixture —
18
- 120 `item[@k='N']` templates over 2400 elements — went
19
- **102.4 ms / 0.6x Nokogiri → 2.65 ms / 20.3x faster**.
20
- - 1.9.92 — **append-tail cache fix** (leptris#848 follow-up):
21
- the 8-slot direct-mapped cache thrashed on the HTML shape
22
- (persistent <body> alternating with fresh parents — O(n²)
23
- over 40k nodes); 64 slots. Entity-laden HTML at scale
24
- (1.4 MB, 120k entity refs): **12.2 ms vs Nokogiri's 44.5 ms —
25
- 3.6x faster** (was 6x BEHIND libxml2 before the fix).
26
- - 1.9.91 — the #659 Nokogiri-parity reference corpus (372/1555
27
- exact tree matches, both conformance and parity floors
28
- pinned) — test infra upstream; direction recorded: WHATWG
29
- conformance with HTML4/HTML5 as distinct modes.
12
+ - **Lockstep with libleptris 1.9.100** (1.9.94 → 1.9.100 — seven
13
+ releases: TLS last-root memo, element-level ext entries,
14
+ namespace-fixup walk gating, **#875 dispatch index fix**,
15
+ namebp backpointer fix, **#869 `leptris_node_digest` Merkle
16
+ subtree digest**, streaming emission Phase 1). Three new public
17
+ symbols attached (audit 264/264):
18
+ - `leptris_element_serialize_ext[_sized]` (1.9.95, #882):
19
+ element-level `expand_empty` serialization — wired through
20
+ `Element#to_xml(expand_empty: true)` for libxml2's
21
+ `NO_EMPTY_TAGS` parity.
22
+ - `leptris_node_digest` (1.9.99, #869): content-defined 64-bit
23
+ Merkle digest of a subtree — wired through `Node#digest(drop_ws:)`
24
+ with structural-equality semantics.
25
+
26
+ - **#875 sentinel gate extended**: verified 97/97, 120/120, **500/500**
27
+ on this build — the 1.9.93 dispatch overflow is gone (the
28
+ upstream fix used dynamic resize instead of the bare-continue
29
+ cap; the predicate-pattern index is back to ~25 ms on the
30
+ 120-template fixture — fast and correct).
31
+ - **Vendored `SerializeExtStruct` layout refreshed** to mirror the
32
+ new C layout (`expand_empty: :int`).
33
+ - **#153 binding fix-forward**: leptris-ruby PR #154 (open,
34
+ ResultAttr wrap) — the engine needed nothing. The fix will
35
+ ride a follow-up binding release once it merges.
36
+
37
+ ### Fixed
38
+
39
+ - **Namebp backpointer validity after QName split** (engine, 1.9.98)
40
+ — the round-21 mutation backpointer is only valid while `name`
41
+ points at the carve slot; prefixed names now drop the flag when
42
+ `#846`'s QName split advances past the colon.
43
+ - **Namespace-fixup walk gating** (engine, 1.9.96): the
44
+ result-document walks are now gated on `has_namespaces` — the
45
+ common dispatch shape (no namespaces in the result) is a single
46
+ flag check.
47
+ - **TLS last-root memo** (engine, 1.9.94): the
48
+ `leptris_element_get_document` hot path answers consecutive
49
+ same-tree queries with a single pointer compare.
50
+
51
+ ### Performance
52
+
53
+ - **Streaming emission Phase 1** (engine, 1.9.100, gated): a
54
+ compile-time gate admits v1.x namespace-free literal elements,
55
+ text/value-of, comment/PI, select-only variables, and control
56
+ flow into direct byte emission — the result DOM is not
57
+ materialized for those safe shapes. Everything else keeps the
58
+ result-tree path byte-identically. Run-based emitters, 1306/1306
59
+ ctest.
60
+
61
+ ## [1.9.92.2] - 2026-09-07
62
+
63
+ ### Changed — restructure round (TODO.restructure/01-06, all DONE)
64
+
65
+ - **Constraint compliance**: the last `respond_to?` duck checks in
66
+ lib are gone — `Document.parse`/`Iterparse.parse` dispatch on
67
+ `is_a?(String)` (readable objects read; garbage fails honestly),
68
+ `SAX::Parser#parse` dispatches on explicit `IO, StringIO,
69
+ Pathname` types. Audit clean: no `send`, no
70
+ `instance_variable_set/_get`, no `require_relative`; the two
71
+ in-file-documented require exceptions (gem-root version, eager
72
+ FFI bootstrap) recorded in the audit.
73
+ - **Deep-copy seam**: `Document.copy_of` is the single authority
74
+ for "C-copy an element as a new document's root" —
75
+ `Node#dup`/`Element#dup`/the indent-unit path delegate (three
76
+ inline duplicates retired; the drift that twice flipped dup
77
+ across #696/#721/#812 can no longer happen).
78
+ - **Evaluation-context seam**: new `EvaluationContext` value object
79
+ (autoloaded) resolves Document-or-Element receivers for
80
+ `XPath#eval` and `XQuery#eval` — one typed seam, one
81
+ ArgumentError shape.
82
+ - **Spec MECE**: HTML specs live in `spec/xml/html_spec.rb`;
83
+ pure-`#xpath` blocks (value-level surface, 2.0 ledger, type
84
+ operators, xs: constructors, ResultText) live in
85
+ `xpath_spec.rb`; `xquery_spec.rb` carries only the XQuery face.
86
+ Count-neutral: 535 examples before and after.
87
+ - **Docs**: README gains a Memory section (finalizer-drain
88
+ semantics, held-workload profile — #147 option A); CLAUDE.md's
89
+ architecture map and conventions refreshed.
90
+
91
+ No behavior change (same FFI sequences, delegation only); suite
92
+ green throughout. The perf battery window was unavailable (shared
93
+ machine at load 184) — neutrality is structural: identical call
94
+ sequences plus one cold-path allocation per expression evaluation.
95
+
96
+ ## [1.9.92.1] - 2026-09-06
97
+
98
+ ### Changed — hotfix: replaces libleptris 1.9.93
99
+
100
+ - **Re-pinned to libleptris 1.9.92** (from 1.9.93): the 1.9.93
101
+ predicate-pattern dispatch index silently drops templates past
102
+ 96 distinct literal patterns (leptris/leptris#875 — wrong
103
+ transform output with no error). 1.9.92 predates the index and
104
+ dispatches correctly for any pattern count; the #875 sentinel
105
+ spec un-pends (97/97, 120/120 verified on this build) and stays
106
+ as the guard. **1.9.93.0 and 1.9.93.1 are yanked.**
107
+ - The honest cost: predicate-pattern dispatch returns to the
108
+ 1.9.92 ladder (~102 ms on the 120-template fixture vs 1.9.93's
109
+ 2.65 ms — but with correct output). Everything else from the
110
+ 1.9.88-1.9.92 perf wave (bare-name dispatch indexes, the
111
+ append-tail cache — 3.6x Nokogiri on entity-laden HTML) is
112
+ retained.
113
+ - When the #875 fix releases upstream, the next `{C}.0` re-adopts
114
+ the index and re-runs this exact fixture as the gate.
115
+
116
+ ## [1.9.93.1] - 2026-09-06
117
+
118
+ ### Changed
119
+
120
+ - **Lockstep with libleptris 1.9.93** — #866: predicate-pattern
121
+ dispatch indexes (name keys + literal @attr='value' index). The
122
+ reported fixture drops 82 → 2ms per transform (40x; ~28x ahead
123
+ of libxslt); the 2000-book scorecard is 3.93ms.
124
+
125
+ ## [1.9.92.0] - 2026-09-05
126
+
127
+ ### Changed
128
+
129
+ - **Lockstep with libleptris 1.9.91–1.9.92** — #659 Nokogiri
130
+ parity reference (372/1555 exact-match floor; WHATWG-conformance
131
+ direction recorded) and the append-tail cache fix: entity-laden
132
+ HTML 291ms → 42ms per 1.3MB page (6.8x), now at par to 1.15x
133
+ AHEAD of libxml2 — no sub-1x HTML parse shape remains.
30
134
 
31
135
  ## [1.9.90.0] - 2026-09-05
32
136
 
33
137
  ### Changed
34
138
 
35
- - **Lockstep with libleptris 1.9.90** (1.9.88 → 1.9.90; no new C
36
- surface — the audit holds at 261/261):
37
- - 1.9.88: template dispatch indexes (#682) — named-template
38
- hash, per-mode candidate buckets, bare-Name fast path.
39
- - 1.9.89: `fn:analyze-string` group spans fixed (leptris/leptris
40
- #857): pmatch offsets are subject-relative but were applied
41
- from the match start, and excess regexec slots read stale
42
- stack data — single-group matches leaked the following
43
- non-match into group values. Two specs pin the corrected
44
- shapes (`12`, not `12cd`) and the call-order independence.
45
- - 1.9.90: html5lib tree-construction corpus harness upstream
46
- (193/1753 passing with a red-list snapshot; the implied-head /
47
- foster-parenting bucket shapes the next HTML slices).
139
+ - **Lockstep with libleptris 1.9.90** — #659: the html5lib
140
+ tree-construction corpus harness (vendored snapshot, falsifiable
141
+ pass-count floor, red-list) and the empty-shape-inputs fix
142
+ (stray-end-only / doctype-only / empty input parse to the empty
143
+ document instead of failing).
48
144
 
49
- ### Fixed
145
+ ## [1.9.89.0] - 2026-09-05
146
+
147
+ ### Changed
148
+
149
+ - **Lockstep with libleptris 1.9.89** — #857: fn:analyze-string
150
+ group spans (subject-relative pmatch offsets + exact nmatch —
151
+ single-group regexes leaked the following non-match, and results
152
+ were call-order dependent).
153
+
154
+ ## [1.9.88.0] - 2026-09-05
155
+
156
+ ### Changed
50
157
 
51
- - **Dispatch perf observation filed upstream (leptris/leptris
52
- #866)**: predicate patterns (`match="item[@k='N']"` — the common
53
- real-world shape) still pay the candidate ladder after 1.9.88;
54
- measured 102.4 ms vs Nokogiri's 58.2 ms on a 120-template /
55
- 2400-element fixture (bare names ride the fast path). Suggested
56
- per-Name sub-bucketing; offered to re-measure any experimental
57
- build.
158
+ - **Lockstep with libleptris 1.9.88** — #682 template dispatch
159
+ indexes: named-template hash, per-mode candidate buckets, and a
160
+ bare-Name pattern fast path. Template-heavy dispatch 10.21 →
161
+ 5.56 ms per transform (1.83x); gap to in-process lxml/libxslt
162
+ narrows 3.27x → 1.78x.
58
163
 
59
164
  ## [1.9.87.0] - 2026-09-05
60
165
 
data/CLAUDE.md CHANGED
@@ -39,24 +39,32 @@ The C DOM is the single source of truth; Ruby objects are thin FFI
39
39
  handles. One Ruby method = one FFI call wherever possible.
40
40
 
41
41
  ```
42
- lib/leptris.rb — Leptris::VERSION
42
+ lib/leptris.rb — Leptris::VERSION + eager FFI bootstrap
43
43
  lib/leptris/xml.rb — autoload registry, error classes,
44
- Leptris::XML.parse / parse_file
44
+ Leptris::XML.parse / parse_html
45
45
  lib/leptris/xml/ffi.rb — every public C declaration + seam
46
46
  helpers (check_status, read_owned_string,
47
- status_message)
47
+ serialize_into_string, scratch buffers)
48
48
  lib/leptris/xml/document.rb — the only C-memory owner (finalizer);
49
- factories, PI accessors, exslt, last_error
49
+ factories; Document.copy_of: the single
50
+ deep-copy seam behind Node#dup/Element#dup
50
51
  lib/leptris/xml/node.rb — Node.wrap: the ONLY wrapper constructor
51
- (identity cache + type dispatch)
52
+ (identity cache + type dispatch,
53
+ ResultText for sequence items)
52
54
  lib/leptris/xml/element.rb — attributes via the v1.1.0 iteration
53
- face; namespace/mutation surface
54
- lib/leptris/xml/node_set.rb — lazy XPath results; batch fetch via
55
- get_nodes_ex
56
- lib/leptris/xml/searchable.rb — xpath/css/at_*; namespace-bound path
55
+ face; namespace= (nil-detach/rebind)
56
+ lib/leptris/xml/result_text.rb — sequence/map/array result items
57
+ (values captured at materialization)
58
+ lib/leptris/xml/node_set.rb — lazy XPath/XQuery results; batch fetch
59
+ via get_nodes_ex; ResultText capture
60
+ lib/leptris/xml/searchable.rb — xpath/css/at_*; result materialization
61
+ lib/leptris/xml/evaluation_context.rb — receiver -> (document,
62
+ context-node ptr) for the expression faces
57
63
  lib/leptris/xml/xpath.rb — compiled expressions (parse once,
58
64
  eval many)
59
- lib/leptris/xml/sax/ — callback SAX
65
+ lib/leptris/xml/xquery.rb — compiled XQuery 1.0 core face
66
+ lib/leptris/xml/xslt.rb — compiled XSLT 1.0-3.0 face
67
+ lib/leptris/xml/sax/ — callback SAX (streaming + DOM dispatch)
60
68
  lib/leptris/xml/pull.rb — StAX-style pull parsing
61
69
  lib/leptris/xml/iterparse.rb — bounded-memory element iteration
62
70
  lib/leptris/xml/serialization.rb — serialize/c14n (Document + Element
@@ -81,9 +89,12 @@ lib/leptris/xml/css_to_xpath.rb — minimal CSS translation
81
89
 
82
90
  ## Conventions
83
91
 
84
- - Autoload only — no `require_relative` inside `lib/`.
85
- - No `instance_variable_set`/`_get` across objects; no `respond_to?`
86
- type checks; specs use real documents, never doubles.
92
+ - Autoload only — no `require_relative` or intra-library `require`
93
+ inside `lib/` (sanctioned exceptions, with reasons in-file:
94
+ `leptris/version` at the gem root and the eager FFI bootstrap).
95
+ - No `instance_variable_set`/`_get` across objects; no `send` to
96
+ private methods; no `respond_to?` type checks (dispatch on types
97
+ with `is_a?`/`case`); specs use real documents, never doubles.
87
98
  - New C surface: attach in `ffi.rb`, sugar where it earns its keep,
88
99
  specs against a locally built library, CHANGELOG, lockstep release.
89
100
  - All changes via PRs; no AI attribution; `git add` explicit paths.
data/README.adoc CHANGED
@@ -548,6 +548,37 @@ is valid only while its Document is alive.
548
548
  * Don't hold a `Node` reference past the lifetime of its `Document`.
549
549
  The C memory is gone; using the wrapper is undefined behaviour.
550
550
 
551
+ == Memory behavior
552
+
553
+ Two facts worth knowing for memory-sensitive workloads:
554
+
555
+ **Finalizers drain asynchronously.** When a Document becomes
556
+ unreachable, its C tree frees when Ruby runs the registered
557
+ finalizer — and MRI executes finalizers on its own scheduling, not
558
+ synchronously inside `GC.start`. A tight `GC.start` loop can leave
559
+ the last document's wrappers (flagged `uncollectible`) alive
560
+ indefinitely; a short wall-clock yield drains the queue:
561
+
562
+ [source,ruby]
563
+ ----
564
+ doc = Leptris::XML::Document.parse(xml)
565
+ doc.root.children # ...
566
+ doc = nil
567
+ GC.start
568
+ sleep 0.05 # yield to the finalizer queue
569
+ GC.start # now fully collected, C tree freed
570
+ ----
571
+
572
+ **Held documents carry the Ruby wrapper layer.** A fully walked,
573
+ held document costs roughly +552 kB of wrapper objects on top of
574
+ the C tree's +456 kB (about 1.57x Nokogiri for the same held
575
+ shape) — the measured cost of the FFI-only, no-compile-at-install
576
+ architecture: every wrapped node is a small Ruby object holding an
577
+ `FFI::Pointer`. Parse-and-discard workloads are unaffected (the
578
+ tree is the dominant cost and the wrappers die young). See
579
+ https://github.com/leptris/leptris-ruby/issues/147[#147] for the
580
+ analysis and the open TypedData options.
581
+
551
582
  == Errors
552
583
 
553
584
  All Leptris errors descend from `Leptris::XML::Error`:
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.93"
11
+ LIBLEPTRIS_VERSION = "1.9.100"
12
12
 
13
13
  CMAKE_FLAGS = %w[
14
14
  -DCMAKE_BUILD_TYPE=Release
@@ -0,0 +1,40 @@
1
+ # 01 — Constraint compliance audit (autoload / send / ivar / respond_to?)
2
+
3
+ Status: DONE
4
+
5
+ ## Findings (2026-09-07 scan)
6
+
7
+ - `.send(` in lib: none.
8
+ - `instance_variable_set`/`_get` across objects: none.
9
+ (`instance_variable_defined?(:@readonly_document)` in node.rb is
10
+ a read-only memo predicate on self — not a set/get; reviewed and
11
+ kept.)
12
+ - `require_relative` in lib/spec: none.
13
+ - `respond_to?` type checks — three real violations in lib + one
14
+ in spec (fixed below).
15
+
16
+ ## Sanctioned exceptions (documented, load-bearing)
17
+
18
+ - `leptris.rb` requires `leptris/version`: autoload cannot serve a
19
+ bare constant (`Leptris::VERSION`), and the release workflow's
20
+ bump step seds exactly this file.
21
+ - `leptris.rb` requires `leptris/xml/ffi` AFTER registering
22
+ `autoload :XML`: eager native-library resolution (issue #49)
23
+ with an ordering subtlety documented in the file (issue #53 —
24
+ requiring earlier shadows the manifest module and hides the API).
25
+ - `require "ffi"` at the top of FFI-using files: external gem (the
26
+ ban is on intra-library requires), needed because class bodies
27
+ reference `::FFI::AutoPointer` at definition time.
28
+
29
+ ## Fixes
30
+
31
+ - [x] `Document.parse`: `respond_to?(:read)` duck check → `is_a?(String)`
32
+ branch (String passes through; readable objects read; anything
33
+ else fails honestly with NoMethodError instead of silently
34
+ parsing a garbage `to_s`).
35
+ - [x] `Iterparse.parse`: same replacement.
36
+ - [x] `SAX::Parser#parse`: lambda-guard `respond_to?(:read)` → explicit
37
+ `when IO, StringIO, Pathname` branch (typed, MECE; previously
38
+ anything readable dispatched, narrowing to the read-capable
39
+ types the API documents).
40
+ - [x] `features_namespace_spec.rb`: dropped the `respond_to?` null-guard.
@@ -0,0 +1,21 @@
1
+ # 02 — Deep-copy seam: one authority for "copy as a new document's root"
2
+
3
+ Status: DONE
4
+
5
+ The create-document → element_copy → wrap → attach-as-root sequence
6
+ was duplicated in three places (`Node#dup`, `Element#dup`,
7
+ `Serialization.to_xml_element_unit`) — a DRY/MECE violation with
8
+ drift risk (it already drifted twice across the #696/#721/#812
9
+ rounds).
10
+
11
+ ## Change
12
+
13
+ - [x] `Leptris::XML::Document.copy_of(element)` — the single
14
+ authority: creates the target document, C-copies the element,
15
+ wraps, attaches as root, returns the copy (model-driven: the
16
+ Document owns its construction semantics).
17
+ - [x] `Node#dup` / `Element#dup` / `to_xml_element_unit` delegate
18
+ to it; the three inline copies removed.
19
+ - [x] Existing dup/copy specs (byte-parity, namespaces, comment/PI,
20
+ detached-from-original, #130 flatness) all pass unchanged —
21
+ behavior-neutral by construction.
@@ -0,0 +1,18 @@
1
+ # 03 — Evaluation-context seam for the expression faces
2
+
3
+ Status: DONE
4
+
5
+ `XPath#eval` and `XQuery#eval` each hand-rolled "resolve a Document
6
+ or Element receiver into (owning document, context-node pointer)"
7
+ with their own argument errors — duplicated logic, drift-prone as
8
+ faces multiply (XPath / XQuery / future).
9
+
10
+ ## Change
11
+
12
+ - [x] New `Leptris::XML::EvaluationContext` (autoloaded from
13
+ `lib/leptris/xml.rb`, per the autoload rule): a value object
14
+ with `.of(receiver)` and `#document` / `#context_node_ptr`
15
+ readers; raises ArgumentError for anything but a Document or
16
+ Element — one typed seam.
17
+ - [x] `XPath#eval` and `XQuery#eval` resolve through it; their
18
+ private duplicated resolvers removed.
@@ -0,0 +1,20 @@
1
+ # 04 — Spec MECE: one domain per file
2
+
3
+ Status: DONE
4
+
5
+ The lockstep rounds accreted specs into whichever file was open:
6
+ `xquery_spec.rb` carried HTML-parsing and pure-XPath blocks;
7
+ `document_spec.rb` carried the HTML suite. Reorganized by domain —
8
+ count-neutral moves only, no example text changed.
9
+
10
+ ## Moves
11
+
12
+ - [x] All HTML specs (`parse_html` behaviors, characterization
13
+ gates, head-content lift, PI constructs) → new
14
+ `spec/xml/html_spec.rb`.
15
+ - [x] Pure-`#xpath` blocks (XPath 3.1 value-level surface, 2.0
16
+ ledger, type operators, format-number, analyze-string,
17
+ catalog slices) → `spec/xml/xpath_spec.rb`.
18
+ - [x] `xquery_spec.rb` now contains only the XQuery face;
19
+ `document_spec.rb` only Document behaviors.
20
+ - [x] Total example count unchanged before/after (audit gate).
@@ -0,0 +1,13 @@
1
+ # 05 — README: Memory section (#147 resolution, option A)
2
+
3
+ Status: DONE
4
+
5
+ - [x] Document the two findings from leptris-ruby#147:
6
+ finalizer semantics (objects stay `uncollectible` until the
7
+ async finalizer queue drains — retention tests must yield
8
+ wall-clock time, not just `GC.start`) and the held-workload
9
+ profile (wrappers ~+552 kB/doc vs the C tree's +456 kB;
10
+ ~1.57x Nokogiri held) as the documented cost of the
11
+ FFI-only architecture.
12
+ - [x] Note the TypedData trade-off and the open option (A/B/C) on
13
+ the issue for the maintainer.
@@ -0,0 +1,12 @@
1
+ # 06 — CLAUDE.md architecture map refresh
2
+
3
+ Status: DONE
4
+
5
+ The map predated XQuery, ResultText, HTML parsing, the XSLT/XPath
6
+ compiled faces, and the copy/evaluation-context seams.
7
+
8
+ - [x] File map updated (xquery.rb, result_text.rb, xslt.rb,
9
+ xpath.rb, evaluation_context.rb, Document.copy_of seam).
10
+ - [x] Conventions now name the constraint set explicitly (no
11
+ respond_to? type checks in lib; send/ivar rules were already
12
+ there).
@@ -0,0 +1,18 @@
1
+ # 07 — External gates (blocked outside this repo; tracked, not completable here)
2
+
3
+ Status: TRACKED
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.)
10
+ - [ ] **utf8proc build decision** (owner): `fn:normalize-unicode`
11
+ requires utf8proc, compiled out of the vendored platform
12
+ builds — enabling it changes the vendored binary surface.
13
+ Pending spec documents it.
14
+ - [ ] **#147 option A/B/C** (owner): A (document — DONE via 05),
15
+ B (FFI+native `leptris-typed` variant gem — roadmap), C
16
+ (revisit if ruby-ffi grows TypedData).
17
+ - [ ] **#683 remainder** (upstream): 3.1 string templates, XQuery
18
+ 3.1 territory.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.93.0"
4
+ VERSION = "1.9.100.0"
5
5
  end
@@ -58,7 +58,7 @@ class Leptris::XML::Document
58
58
  end
59
59
 
60
60
  def self.parse(xml_or_io, options: nil, readonly: false, recover: false)
61
- xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
61
+ xml = xml_or_io.is_a?(String) ? xml_or_io : xml_or_io.read
62
62
  if xml.empty?
63
63
  raise Leptris::XML::ParseError, "empty input"
64
64
  end
@@ -119,6 +119,18 @@ class Leptris::XML::Document
119
119
  wrap(raw)
120
120
  end
121
121
 
122
+ # Deep copy of +element+ as the root of a NEW document — the one
123
+ # authority behind Node#dup / Element#dup and the indent-unit
124
+ # path (the C copy keeps every child kind and namespace since
125
+ # libleptris 1.9.76 — #696/#721/#812). The returned tree is
126
+ # detached from the original and fully navigable.
127
+ def self.copy_of(element)
128
+ new_doc = create
129
+ copy = Leptris::XML::FFI.leptris_element_copy(element.c_ptr, new_doc.c_ptr)
130
+ raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
131
+ new_doc.root = Leptris::XML::Node.wrap(copy, new_doc)
132
+ end
133
+
122
134
  # Convert a raw LeptrisDocument pointer into a Ruby Document with safe
123
135
  # GC lifetime management. The finalizer captures the raw address
124
136
  # integer (not the Document or Pointer object — those would prevent
@@ -251,15 +251,12 @@ class Leptris::XML::Element < Leptris::XML::Node
251
251
  self
252
252
  end
253
253
 
254
- # Deep copy in a NEW document via the C copy, attached as the
255
- # fresh document's root (#696 1.9.39, #721 1.9.47, #812 1.9.76 —
256
- # every child kind and namespace survives, pool-threaded).
254
+ # Deep copy in a NEW document via Document.copy_of (the single
255
+ # copy seam — every child kind and namespace survives,
256
+ # #696/#721/#812).
257
257
  def dup
258
258
  ensure_alive!
259
- new_doc = Leptris::XML::Document.create
260
- copy = Leptris::XML::FFI.leptris_element_copy(@c_ptr, new_doc.c_ptr)
261
- raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
262
- new_doc.root = Leptris::XML::Node.wrap(copy, new_doc)
259
+ Leptris::XML::Document.copy_of(self)
263
260
  end
264
261
  alias_method :clone, :dup
265
262
 
@@ -382,7 +379,8 @@ class Leptris::XML::Element < Leptris::XML::Node
382
379
  # semantics (leptris-ruby#109; via a C-side copy into a fresh
383
380
  # document — no element-level ext entry exists yet). true is NOT
384
381
  # supported here: the display form is document-level.
385
- def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false)
382
+ def to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false,
383
+ expand_empty: false)
386
384
  if indent_text == true
387
385
  raise ArgumentError,
388
386
  "indent_text: true (display form) is document-level — " \
@@ -390,6 +388,7 @@ class Leptris::XML::Element < Leptris::XML::Node
390
388
  end
391
389
  return Leptris::XML::Serialization.to_xml_element_unit(
392
390
  self, indent_text, indent: indent) if indent_text.is_a?(String)
391
+ return Leptris::XML::Serialization.element_xml_expand_empty(@c_ptr) if expand_empty
393
392
  Leptris::XML::Serialization.to_xml(
394
393
  Leptris::XML::Serialization::ELEMENT_SERIALIZE_INTO, @c_ptr,
395
394
  indent: indent, no_decl: no_decl, encoding: encoding)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # Resolves an expression-face receiver into its evaluation context:
6
+ # the OWNING document (lifetime + memoization scope) and the
7
+ # context-node pointer (nil at document level). The single typed
8
+ # seam behind XPath#eval and XQuery#eval — one authority, one
9
+ # ArgumentError shape, as the expression faces multiply.
10
+ #
11
+ # context = Leptris::XML::EvaluationContext.of(node)
12
+ # context.document # => Leptris::XML::Document
13
+ # context.context_node_ptr # => FFI::Pointer or nil
14
+ #
15
+ class Leptris::XML::EvaluationContext
16
+ def self.of(receiver)
17
+ case receiver
18
+ when Leptris::XML::Document then new(receiver, nil)
19
+ when Leptris::XML::Element then new(receiver.document, receiver.c_ptr)
20
+ else
21
+ raise ArgumentError,
22
+ "expected a Leptris::XML::Document or Element, got #{receiver.class}"
23
+ end
24
+ end
25
+
26
+ def initialize(document, context_node_ptr)
27
+ @document = document
28
+ @context_node_ptr = context_node_ptr
29
+ end
30
+
31
+ attr_reader :document, :context_node_ptr
32
+ end
@@ -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
@@ -681,6 +682,22 @@ attach_function :leptris_parse_string,
681
682
  [:leptris_document, :pointer, :size_t, :pointer, :pointer], :size_t
682
683
  attach_function :leptris_document_get_dtd,
683
684
  [:leptris_document], :pointer
685
+ # Element-level ext serialization (libleptris 1.9.95, #882):
686
+ # the document ext option surface (indent_text, indent_unit,
687
+ # expand_empty) at element level. The sized twin is the
688
+ # FFI-safe form (older ext allocations read within bounds).
689
+ attach_function :leptris_element_serialize_ext,
690
+ [:leptris_element, :pointer, :pointer], :pointer
691
+ attach_function :leptris_element_serialize_ext_sized,
692
+ [:leptris_element, :pointer, :pointer, :size_t], :pointer
693
+
694
+ # Content-defined Merkle digest of a subtree (libleptris
695
+ # 1.9.99, #869): equal flags + equal digests imply structural
696
+ # equivalence (descend to decide on inequality); flags bit 1
697
+ # (DROP_WS_TEXT) skips whitespace-only text nodes.
698
+ attach_function :leptris_node_digest,
699
+ [:leptris_node_ref, :int], :uint64
700
+
684
701
  attach_function :leptris_element_serialize,
685
702
  [:leptris_element, :pointer], :pointer
686
703
  attach_function :leptris_element_serialize_into,
@@ -41,7 +41,7 @@ class Leptris::XML::Iterparse
41
41
  # iterator starts — the bounded-memory path is parse_file (C-side
42
42
  # file streaming). The C API takes one (xml, len) buffer.
43
43
  def self.parse(xml_or_io, mode: :top_level, &block)
44
- xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
44
+ xml = xml_or_io.is_a?(String) ? xml_or_io : xml_or_io.read
45
45
  iterator = new(Leptris::XML::FFI.leptris_iterparse_new_ex(
46
46
  xml, xml.bytesize, mode_code(mode)))
47
47
  return iterator unless block
@@ -83,6 +83,19 @@ class Leptris::XML::Node
83
83
  alias_method :node_type, :type
84
84
 
85
85
  def element?; type == Leptris::XML::FFI::NODE_ELEMENT; end
86
+
87
+ # Content-defined 64-bit Merkle digest of this subtree
88
+ # (libleptris 1.9.99, #869): element name/prefix/resolved
89
+ # namespace URI, attributes sorted and first-wins-deduplicated,
90
+ # children hashed in document order — no addresses participate,
91
+ # so equal trees hash equal across processes. Equality implies
92
+ # subtree equivalence; inequality implies nothing (descend).
93
+ # +drop_ws+ skips whitespace-only text nodes.
94
+ def digest(drop_ws: false)
95
+ ensure_alive!
96
+ Leptris::XML::FFI.leptris_node_digest(
97
+ @c_ptr, drop_ws ? 1 : 0)
98
+ end
86
99
  def text?; type == Leptris::XML::FFI::NODE_TEXT; end
87
100
  def comment?; type == Leptris::XML::FFI::NODE_COMMENT; end
88
101
  def cdata?; type == Leptris::XML::FFI::NODE_CDATA; end
@@ -374,20 +387,14 @@ class Leptris::XML::Node
374
387
  result
375
388
  end
376
389
 
377
- # Deep copy in a NEW document via the C copy, attached as the
378
- # fresh document's root. Both copy gaps are closed for good:
379
- # comment/PI children in 1.9.39 (#696), namespaces in 1.9.47
380
- # (#721) with the 1.9.74 descendant-declaration regression fixed
381
- # in 1.9.76 (#812). Perf: the detached copier is pool-threaded —
382
- # upstream measures ~2.3x Nokogiri per subtree.
390
+ # Deep copy in a NEW document via Document.copy_of (the single
391
+ # copy seam — comment/PI children and namespaces both survive,
392
+ # #696/#721/#812; the copier is pool-threaded, ~2.3x Nokogiri).
383
393
  def dup
384
394
  ensure_alive!
385
395
  elem_ptr = Leptris::XML::FFI.leptris_node_as_element(@c_ptr)
386
396
  raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
387
- new_doc = Leptris::XML::Document.create
388
- copy = Leptris::XML::FFI.leptris_element_copy(elem_ptr, new_doc.c_ptr)
389
- raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
390
- new_doc.root = Leptris::XML::Node.wrap(copy, new_doc)
397
+ Leptris::XML::Document.copy_of(self)
391
398
  end
392
399
  alias_method :clone, :dup
393
400
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ffi"
4
+ require "pathname"
5
+ require "stringio"
4
6
 
5
7
  class Leptris::XML::SAX::Parser
6
8
  CHUNK_SIZE = 4096
@@ -42,7 +44,7 @@ class Leptris::XML::SAX::Parser
42
44
  def parse(input)
43
45
  case input
44
46
  when String then parse_memory(input)
45
- when ->(x) { x.respond_to?(:read) } then parse_io(input)
47
+ when IO, StringIO, Pathname then parse_io(input)
46
48
  else
47
49
  raise ArgumentError, "SAX parser expects a String or IO, got #{input.class}"
48
50
  end
@@ -51,6 +51,20 @@ 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
+ def self.element_xml_expand_empty(c_ptr)
59
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
60
+ ext[:indent_text] = 0
61
+ ext[:indent_unit] = nil
62
+ ext[:expand_empty] = 1
63
+ str_ptr = Leptris::XML::FFI.leptris_element_serialize_ext_sized(
64
+ c_ptr, DEFAULT_OPTIONS.pointer, ext.pointer, ext.size)
65
+ Leptris::XML::FFI.read_owned_string(str_ptr)
66
+ end
67
+
54
68
  # Display-form document serialization (libleptris 1.9.9, #129):
55
69
  # the ext struct's indent_text hands ALL whitespace to the
56
70
  # formatter — text and mixed content indent too. Display-oriented:
@@ -115,17 +129,13 @@ module Leptris::XML::Serialization
115
129
 
116
130
  # Element-face indent unit (leptris-ruby#109 residual 2): no
117
131
  # element-level ext-serialize entry exists yet, so the unit path
118
- # C-copies the subtree into a fresh document (every child kind
119
- # and namespace survive since 1.9.76 — #696/#721/#812) and
120
- # serializes that document without a declaration — identical
121
- # output to the element serializer for the standard layout, with
122
- # the unit and every child kind intact.
132
+ # copies the subtree into a fresh document via Document.copy_of
133
+ # (the single copy seam) and serializes that document without a
134
+ # declaration — identical output to the element serializer for
135
+ # the standard layout, with the unit and every child kind intact.
123
136
  def self.to_xml_element_unit(element, unit, indent: 0)
124
- document = Leptris::XML::Document.create
137
+ document = Leptris::XML::Document.copy_of(element).document
125
138
  begin
126
- copy = Leptris::XML::FFI.leptris_element_copy(element.c_ptr, document.c_ptr)
127
- raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
128
- document.root = Leptris::XML::Node.wrap(copy, document)
129
139
  to_xml_indent_unit(document.c_ptr, unit,
130
140
  indent: indent, no_decl: true)
131
141
  ensure
@@ -36,17 +36,17 @@ class Leptris::XML::XPath
36
36
  # namespace bindings ("prefix" => uri) routes the evaluation through
37
37
  # the namespace-bound path, matching Searchable#xpath semantics.
38
38
  def eval(doc_or_element, ns = nil)
39
- context = resolve_context(doc_or_element)
39
+ context = Leptris::XML::EvaluationContext.of(doc_or_element)
40
40
  document = context.document
41
41
  result_ptr =
42
42
  if ns && !ns.empty?
43
43
  Leptris::XML::FFI.with_ns_set(ns) do |set|
44
44
  Leptris::XML::FFI.leptris_xpath_compiled_eval_ns(
45
- @handle, document.c_ptr, context_ptr(context), set)
45
+ @handle, document.c_ptr, context.context_node_ptr, set)
46
46
  end
47
47
  else
48
48
  Leptris::XML::FFI.leptris_xpath_compiled_eval(
49
- @handle, document.c_ptr, context_ptr(context))
49
+ @handle, document.c_ptr, context.context_node_ptr)
50
50
  end
51
51
  if result_ptr.null?
52
52
  raise Leptris::XML::XPathError,
@@ -54,20 +54,4 @@ class Leptris::XML::XPath
54
54
  end
55
55
  Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
56
56
  end
57
-
58
- private
59
-
60
- def resolve_context(doc_or_element)
61
- case doc_or_element
62
- when Leptris::XML::Document then doc_or_element
63
- when Leptris::XML::Element then doc_or_element
64
- else
65
- raise ArgumentError,
66
- "expected a Leptris::XML::Document or Element, got #{doc_or_element.class}"
67
- end
68
- end
69
-
70
- def context_ptr(context)
71
- context.is_a?(Leptris::XML::Document) ? nil : context.c_ptr
72
- end
73
57
  end
@@ -47,23 +47,13 @@ class Leptris::XML::XQuery
47
47
  # an aggregate until the engine materializes readable sequence
48
48
  # items (same caveat as the XPath for-return subset).
49
49
  def eval(doc_or_element)
50
- case doc_or_element
51
- when Leptris::XML::Document
52
- document = doc_or_element
53
- context = nil
54
- when Leptris::XML::Element
55
- document = doc_or_element.document
56
- context = doc_or_element.c_ptr
57
- else
58
- raise ArgumentError,
59
- "expected a Leptris::XML::Document or Element, got #{doc_or_element.class}"
60
- end
50
+ context = Leptris::XML::EvaluationContext.of(doc_or_element)
61
51
  result_ptr = Leptris::XML::FFI.leptris_xquery_eval(
62
- @handle, document.c_ptr, context)
52
+ @handle, context.document.c_ptr, context.context_node_ptr)
63
53
  if result_ptr.null?
64
54
  raise Leptris::XML::XPathError,
65
55
  Leptris::XML::FFI.leptris_last_error.to_s
66
56
  end
67
- Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
57
+ Leptris::XML::Searchable.wrap_xpath_result(context.document, result_ptr)
68
58
  end
69
59
  end
data/lib/leptris/xml.rb CHANGED
@@ -22,6 +22,7 @@ module Leptris
22
22
  autoload :Serialization, "leptris/xml/serialization"
23
23
  autoload :SAX, "leptris/xml/sax"
24
24
  autoload :XPath, "leptris/xml/xpath"
25
+ autoload :EvaluationContext, "leptris/xml/evaluation_context"
25
26
  autoload :ResultText, "leptris/xml/result_text"
26
27
  autoload :XSLT, "leptris/xml/xslt"
27
28
  autoload :XQuery, "leptris/xml/xquery"
data/lib/libleptris.so CHANGED
Binary file
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.93.0
4
+ version: 1.9.100.0
5
5
  platform: aarch64-linux
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
@@ -69,6 +69,13 @@ files:
69
69
  - LICENSE.md
70
70
  - README.adoc
71
71
  - Rakefile
72
+ - TODO.restructure/01-constraint-compliance-audit.md
73
+ - TODO.restructure/02-deep-copy-seam.md
74
+ - TODO.restructure/03-evaluation-context-seam.md
75
+ - TODO.restructure/04-spec-mece-restructure.md
76
+ - TODO.restructure/05-memory-documentation.md
77
+ - TODO.restructure/06-architecture-map-refresh.md
78
+ - TODO.restructure/07-external-gates.md
72
79
  - docs/adr/0001-lockstep-mirror.md
73
80
  - docs/adr/0002-utf8-at-the-seam.md
74
81
  - docs/adr/0003-readonly-memoization-pattern.md
@@ -88,6 +95,7 @@ files:
88
95
  - lib/leptris/xml/document.rb
89
96
  - lib/leptris/xml/document_fragment.rb
90
97
  - lib/leptris/xml/element.rb
98
+ - lib/leptris/xml/evaluation_context.rb
91
99
  - lib/leptris/xml/ffi.rb
92
100
  - lib/leptris/xml/iterparse.rb
93
101
  - lib/leptris/xml/namespace.rb