leptris 1.9.6 → 1.9.8
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 +50 -0
- data/CLAUDE.md +6 -2
- data/CONTEXT.md +39 -0
- data/README.adoc +8 -2
- data/Rakefile +35 -0
- data/docs/adr/0001-lockstep-mirror.md +17 -0
- data/docs/adr/0002-utf8-at-the-seam.md +19 -0
- data/docs/adr/0003-readonly-memoization-pattern.md +18 -0
- data/docs/adr/0004-lifetime-guard.md +21 -0
- data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
- data/lib/leptris/version.rb +1 -1
- data/lib/leptris/xml/css_to_xpath.rb +20 -16
- data/lib/leptris/xml/document.rb +16 -0
- data/lib/leptris/xml/document_fragment.rb +2 -0
- data/lib/leptris/xml/ffi.rb +22 -0
- data/lib/leptris/xml/node.rb +3 -9
- data/lib/leptris/xml/searchable.rb +5 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f306f9bc45d304b82c3db1ac2936dcc0d708b21d2caf595e4a9484da5c6b48a
|
|
4
|
+
data.tar.gz: ce9b04f6f1e91e6dffd1ff68ce70967b8b2bcff8351132db2f3312e4b534e803
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40618f16214157768b018c774816f738a8cbce00e757286822a69f22f71413605c165448a22644b1ff5cc66a387ce63d6e2c496167b8049f2e80ab7cdf124582
|
|
7
|
+
data.tar.gz: f3c5d00e54f051510d76db44582806252ccf84091c9c9eb11915fc43675282721f4224fada0331f45616250be411362f981d545fb5de47bfcaadfaf09a4954bb
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ 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.8] - 2026-08-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`rake audit:symbols`**: the lockstep drift detector as an
|
|
13
|
+
executable step — `nm -gU` on the vendored library vs the ffi.rb
|
|
14
|
+
attach list, failing with both directions of drift (ADR 0001's
|
|
15
|
+
enforcement arm; previously prose plus ad-hoc pipelines).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- ffi.rb attaches the five header-declared exports past cleanups
|
|
20
|
+
had dropped (element_children, serialize_document,
|
|
21
|
+
xpath_ns_set_add, xpath_result_get, xpath_result_get_nodes) —
|
|
22
|
+
mirror-only, supersession noted inline. The audit now runs green:
|
|
23
|
+
**224/224 symbols in lockstep**. Pull-attribute batch fetch asked
|
|
24
|
+
upstream (leptris/leptris#562).
|
|
25
|
+
|
|
26
|
+
## [1.9.7] - 2026-08-26
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **DocumentFragment is searchable**: `fragment.xpath/at_xpath/
|
|
31
|
+
css/at_css/search` — Nokogiri fragment parity. The engine already
|
|
32
|
+
accepted the fragment handle as an XPath context node; the Ruby
|
|
33
|
+
class now includes Searchable.
|
|
34
|
+
- **`Document#last_error_position`** → `[line, column]` (1-based,
|
|
35
|
+
thread-global and sticky per the C contract) — the position
|
|
36
|
+
companion to the recover mode's failure detail. Closes an
|
|
37
|
+
export-audit gap: `leptris_last_error_position` was exported but
|
|
38
|
+
unattached.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **css is receiver-relative** (Nokogiri semantics): `element.css`
|
|
43
|
+
and `fragment.css` scope to the receiver (`.//`), `Document#css`
|
|
44
|
+
stays document-absolute (`//`). Previously every receiver
|
|
45
|
+
translated to document-absolute XPath — wrong results for any
|
|
46
|
+
non-root element or fragment context. Document-context behavior
|
|
47
|
+
is unchanged.
|
|
48
|
+
|
|
49
|
+
### Meta
|
|
50
|
+
|
|
51
|
+
- **CONTEXT.md + docs/adr/**: the domain glossary (ownership model,
|
|
52
|
+
borrowed handles, wrapper identity, readonly contract, the seam
|
|
53
|
+
and its two faces) and five ADRs recording the settled decisions
|
|
54
|
+
(lockstep mirror, UTF-8 at the seam, hand-rolled memoization
|
|
55
|
+
pattern, the lifetime guard and its measured price, autoload
|
|
56
|
+
ordering) — future reviews stop re-deriving them.
|
|
57
|
+
|
|
8
58
|
## [1.9.6] - 2026-08-26
|
|
9
59
|
|
|
10
60
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -87,7 +87,11 @@ lib/leptris/xml/css_to_xpath.rb — minimal CSS translation
|
|
|
87
87
|
|
|
88
88
|
## Reference
|
|
89
89
|
|
|
90
|
+
- Domain vocabulary: CONTEXT.md. Settled decisions: docs/adr/ —
|
|
91
|
+
check both before proposing refactors; they exist so reviews
|
|
92
|
+
stop re-deriving settled questions.
|
|
90
93
|
- libleptris public headers (`src/include/leptris/*.h`) are the
|
|
91
|
-
contract; when symbols change, bump lockstep and
|
|
92
|
-
|
|
94
|
+
contract; when symbols change, bump lockstep and run
|
|
95
|
+
`rake audit:symbols` (attached == exported on the vendored
|
|
96
|
+
library; fails listing both directions of drift).
|
|
93
97
|
- Upstream issues worth tracking live at leptris/leptris.
|
data/CONTEXT.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Domain glossary
|
|
2
|
+
|
|
3
|
+
The vocabulary for talking about leptris-ruby. Architecture reviews
|
|
4
|
+
use these names; see docs/adr/ for settled decisions.
|
|
5
|
+
|
|
6
|
+
**The DOM** — the C tree built by libleptris. Single source of
|
|
7
|
+
truth: every Ruby object is a view over it, and one Ruby method is
|
|
8
|
+
one FFI call wherever possible.
|
|
9
|
+
|
|
10
|
+
**The seam** — `Leptris::XML::FFI`: every attach, every struct
|
|
11
|
+
layout, every buffer protocol, and the encoding policy (UTF-8 on
|
|
12
|
+
both faces: returns and callback params). ABI knowledge appears
|
|
13
|
+
nowhere else (document.rb's finalizer is ownership machinery;
|
|
14
|
+
CStringArray is the char** adapter).
|
|
15
|
+
|
|
16
|
+
**Ownership / the borrowed handle** — Document owns C memory
|
|
17
|
+
(explicit `#free` or GC finalizer); every other object (Node,
|
|
18
|
+
Element, Text, Comment, CDATA, PI, Attr, DocumentFragment, DocType)
|
|
19
|
+
is a handle borrowed for its document's lifetime. Mutations raise
|
|
20
|
+
ReadOnlyError on readonly documents; use of a handle after `#free`
|
|
21
|
+
raises UseAfterFreeError.
|
|
22
|
+
|
|
23
|
+
**Wrapper identity** — `Node.wrap` is the only wrapper constructor;
|
|
24
|
+
the per-document strong cache makes the same C node always yield the
|
|
25
|
+
same Ruby object (`doc.root.equal?(doc.root)`).
|
|
26
|
+
|
|
27
|
+
**The readonly contract** — `readonly: true` makes reads memoizable
|
|
28
|
+
because they cannot go stale; every read method follows the same
|
|
29
|
+
three-line pattern (return memo → compute → store memo). Mutations
|
|
30
|
+
are refused; detached factories still work.
|
|
31
|
+
|
|
32
|
+
**Lockstep** — the gem's major.minor tracks libleptris; a C release
|
|
33
|
+
is one PR (version.rb + Rakefile pin + CHANGELOG) then the release
|
|
34
|
+
workflow; ffi.rb mirrors the public header so the attached-vs-
|
|
35
|
+
exported audit (`nm -gU`) catches drift.
|
|
36
|
+
|
|
37
|
+
**The recover mode** — `recover: true` parses return an empty
|
|
38
|
+
document with the failure recorded on the thread-global last error
|
|
39
|
+
(+ position), instead of raising ParseError.
|
data/README.adoc
CHANGED
|
@@ -169,12 +169,13 @@ against a readonly document is legal; mutating the frozen one is not.
|
|
|
169
169
|
|
|
170
170
|
== Searching: XPath and CSS
|
|
171
171
|
|
|
172
|
-
`Document` and `
|
|
172
|
+
`Document`, `Element`, and `DocumentFragment` (via
|
|
173
|
+
`Leptris::XML::Searchable`) support:
|
|
173
174
|
|
|
174
175
|
[horizontal]
|
|
175
176
|
`#xpath(*exprs)` :: evaluate XPath; returns `NodeSet`, `true`/`false`, `Float`, or `String` depending on the expression.
|
|
176
177
|
`#at_xpath(*exprs)` :: first match (or scalar), like `xpath(*exprs).first`.
|
|
177
|
-
`#css(*selectors)` :: minimal CSS-to-XPath translation, then `xpath`.
|
|
178
|
+
`#css(*selectors)` :: minimal CSS-to-XPath translation, then `xpath`. Receiver-relative (Nokogiri semantics): scoped to the element or fragment, document-wide from a Document.
|
|
178
179
|
`#at_css(*selectors)` :: first match of `css`.
|
|
179
180
|
`#search(*exprs)` :: dispatches on syntax — `/`-prefixed or `,`-separated → `xpath`, otherwise `css`.
|
|
180
181
|
`#at(*exprs)` :: first match of `search`.
|
|
@@ -190,6 +191,11 @@ doc.at_xpath("string(//book[1]/@id)") # => "b1"
|
|
|
190
191
|
doc.css("book[lang='en'] title") # => NodeSet[<title>Refactoring</title>]
|
|
191
192
|
doc.at_css("book#b1 title") # => <title>Refactoring</title> (id selector)
|
|
192
193
|
doc.css("book:first-child") # first <book>
|
|
194
|
+
|
|
195
|
+
# css is receiver-relative: scoped to the receiver, not the document
|
|
196
|
+
doc.root.at_css("book").css("title") # titles under THAT book only
|
|
197
|
+
frag = doc.fragment("<a x='1'><n/></a>")
|
|
198
|
+
frag.css("a > n") # searches the fragment
|
|
193
199
|
----
|
|
194
200
|
|
|
195
201
|
XPath result type follows XPath 1.0 semantics:
|
data/Rakefile
CHANGED
|
@@ -49,6 +49,41 @@ task spec: :compile unless ENV.key?("LEPTRIS_LIB_PATH")
|
|
|
49
49
|
|
|
50
50
|
task default: :spec
|
|
51
51
|
|
|
52
|
+
# Lockstep drift detector (ADR 0001): ffi.rb mirrors the public
|
|
53
|
+
# header, so attached == exported must hold on the vendored library.
|
|
54
|
+
# Run after `rake compile` and before any lockstep release.
|
|
55
|
+
namespace :audit do
|
|
56
|
+
desc "Fail when ffi.rb attachments and library exports drift"
|
|
57
|
+
task :symbols do
|
|
58
|
+
lib = Dir.glob("lib/libleptris.{dylib,so,dll}").first
|
|
59
|
+
unless lib && system("which nm > /dev/null 2>&1")
|
|
60
|
+
abort "audit:symbols: vendored library or nm not found — run rake compile"
|
|
61
|
+
end
|
|
62
|
+
exported = `nm -gU #{lib}`
|
|
63
|
+
.lines.map { |l| l.split[2] }.compact
|
|
64
|
+
.map { |n| n.sub(/\A_/, "") }
|
|
65
|
+
.select { |n| n.start_with?("leptris_") }
|
|
66
|
+
.map { |n| n.sub(/\Aleptris_/, "") }.sort
|
|
67
|
+
attached = File.read("lib/leptris/xml/ffi.rb")
|
|
68
|
+
.scan(/attach_function :leptris_([a-z_0-9]+)/).flatten.sort
|
|
69
|
+
unattached = exported - attached
|
|
70
|
+
unexported = attached - exported
|
|
71
|
+
unless unattached.empty?
|
|
72
|
+
puts "exported but NOT attached (upstream surface drift):"
|
|
73
|
+
unattached.each { |s| puts " leptris_#{s}" }
|
|
74
|
+
end
|
|
75
|
+
unless unexported.empty?
|
|
76
|
+
puts "attached but NOT exported (stale attachment):"
|
|
77
|
+
unexported.each { |s| puts " leptris_#{s}" }
|
|
78
|
+
end
|
|
79
|
+
if unattached.empty? && unexported.empty?
|
|
80
|
+
puts "audit:symbols: #{attached.length}/#{exported.length} symbols in lockstep"
|
|
81
|
+
else
|
|
82
|
+
abort "audit:symbols: drift detected"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
52
87
|
require "rubygems/package_task"
|
|
53
88
|
|
|
54
89
|
desc "Build the pure-Ruby gem"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ADR 0001: ffi.rb mirrors the public header
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
ffi.rb attaches ~30 functions the binding never calls (typed
|
|
5
|
+
attribute accessors, copy-inserts, allocator hooks, xinclude
|
|
6
|
+
getters). Reviews keep proposing to strip them.
|
|
7
|
+
|
|
8
|
+
## Decision
|
|
9
|
+
Keep attaching every public-header symbol. The mirror makes the
|
|
10
|
+
attached-vs-exported audit (`nm -gU` on a fresh build) a drift
|
|
11
|
+
detector: when libleptris changes its surface, the audit fails
|
|
12
|
+
loudly instead of the binding silently missing ABI.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
Attachment is declarative and cheap; unused functions cost nothing.
|
|
16
|
+
When a call site starts using a string-returning mirror function,
|
|
17
|
+
add it to the UTF8_RETURNS wrap-list (ADR 0002).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# ADR 0002: UTF-8 at the seam, both faces
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
The headers contract every C string as UTF-8, but FFI's implicit
|
|
5
|
+
string conversion returns ASCII-8BIT — on returns (2026-08-25,
|
|
6
|
+
release 1.9.3) and on FFI::Function callback params (2026-08-25,
|
|
7
|
+
1.9.4; only `characters` was corrected before).
|
|
8
|
+
|
|
9
|
+
## Decision
|
|
10
|
+
The encoding policy lives entirely in ffi.rb: the UTF8_RETURNS
|
|
11
|
+
wrap-list for called string-returning attaches, plus
|
|
12
|
+
read_owned_string, CStringArray.to_ruby, and the SAX adapter's
|
|
13
|
+
nil-safe utf8 for callback params. Mirror-only attachments stay
|
|
14
|
+
raw; they join the list when a call site appears.
|
|
15
|
+
|
|
16
|
+
## Consequences
|
|
17
|
+
Memoized readonly results can safely cache strings (they freeze as
|
|
18
|
+
UTF-8, the correct encoding, not BINARY). Non-ASCII content can no
|
|
19
|
+
longer leak BINARY into consumer code.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ADR 0003: readonly memoization stays hand-rolled
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
Every readonly-mode read repeats the same three lines:
|
|
5
|
+
`return @x if readonly_cached?(:@x); v = compute; @x = v if
|
|
6
|
+
@document&.readonly?`. ~25 sites. Reviews keep proposing a
|
|
7
|
+
`readonly_memo(ivar) { }` concentrator.
|
|
8
|
+
|
|
9
|
+
## Decision
|
|
10
|
+
Keep the pattern hand-rolled. Concentrating it requires
|
|
11
|
+
instance_variable_get/set — banned project-wide (encapsulation:
|
|
12
|
+
no reaching into another object's ivars, and metaprogrammed ivar
|
|
13
|
+
access hides the memo identity from readers).
|
|
14
|
+
|
|
15
|
+
## Consequences
|
|
16
|
+
The repetition IS the invariant — greppable, one file at a time.
|
|
17
|
+
New readonly reads copy the three lines verbatim; a site that
|
|
18
|
+
deviates is visible in review.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ADR 0004: borrowed handles validate their lender
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
Before 1.9.5, only Document's own methods checked freed state:
|
|
5
|
+
`doc.free; node.name` read freed memory silently (verified
|
|
6
|
+
returning ""), stale mutations risked segfaults.
|
|
7
|
+
|
|
8
|
+
## Decision
|
|
9
|
+
`Node#ensure_alive!` guards every c_ptr-derefercing entry point —
|
|
10
|
+
after memo checks (memoized readonly results stay check-free) and
|
|
11
|
+
folded into `ensure_writable!` for mutations. The check is the
|
|
12
|
+
cheapest sufficient one: `Document#c_ptr.nil?` (#free nils it; the
|
|
13
|
+
GC finalizer cannot fire while any handle exists). `Document#freed?`
|
|
14
|
+
is the accurate public predicate.
|
|
15
|
+
|
|
16
|
+
## Consequences
|
|
17
|
+
Measured cost (harness, 1.9.4→1.9.6): visible only on the
|
|
18
|
+
single-cheapest call shape (Element#[] hot loops, ~13-28%); every
|
|
19
|
+
other loop at parity. Accepted as the price of the contract;
|
|
20
|
+
readonly hot loops have the memoized attributes/keys alternatives.
|
|
21
|
+
Do not re-litigate without a C-side lifetime mechanism.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# ADR 0005: the eager FFI require runs after autoload registration
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
Issue #53: eager-loading ffi.rb from inside `module Leptris`
|
|
5
|
+
BEFORE `autoload :XML` was registered let ffi.rb's module opening
|
|
6
|
+
create Leptris::XML first; the registration was shadowed, xml.rb
|
|
7
|
+
never loaded, and the whole API was unreachable
|
|
8
|
+
(constants == [:FFI]).
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
leptris.rb: register `autoload :XML` first, then the eager
|
|
12
|
+
`require "leptris/xml/ffi"` at top level — ffi.rb's module opening
|
|
13
|
+
triggers the autoload, landing FFI inside the real manifest module;
|
|
14
|
+
a downstream `require "leptris/xml"` is then a no-op. A subprocess
|
|
15
|
+
spec guards the cold path (the in-process suite cannot see it).
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
Any new eager require in leptris.rb must come after the autoload
|
|
19
|
+
block. Eager resolution at require time (issue #49) is unchanged.
|
data/lib/leptris/version.rb
CHANGED
|
@@ -35,30 +35,34 @@ module Leptris
|
|
|
35
35
|
|
|
36
36
|
module_function
|
|
37
37
|
|
|
38
|
-
# Translation is a pure function of the rule string
|
|
39
|
-
# workloads repeat a small selector
|
|
40
|
-
# memoize. Failed translations raise
|
|
38
|
+
# Translation is a pure function of the rule string AND the
|
|
39
|
+
# context prefix, and real workloads repeat a small selector
|
|
40
|
+
# vocabulary in loops — memoize. Failed translations raise
|
|
41
|
+
# before caching. +prefix+ scopes the result: "//" for
|
|
42
|
+
# document receivers, ".//" for elements and fragments
|
|
43
|
+
# (Nokogiri receiver-relative semantics).
|
|
41
44
|
CACHE = {}
|
|
42
45
|
private_constant :CACHE
|
|
43
46
|
|
|
44
|
-
def convert(rule)
|
|
45
|
-
key = rule
|
|
46
|
-
CACHE.fetch(key) { CACHE[key] = convert_rule(
|
|
47
|
+
def convert(rule, prefix: "//")
|
|
48
|
+
key = "#{prefix}\t#{rule}"
|
|
49
|
+
CACHE.fetch(key) { CACHE[key] = convert_rule(rule, prefix) }
|
|
47
50
|
end
|
|
48
51
|
|
|
49
|
-
def convert_rule(rule)
|
|
50
|
-
rule.split(COMMA_SPLIT)
|
|
52
|
+
def convert_rule(rule, prefix)
|
|
53
|
+
rule.split(COMMA_SPLIT)
|
|
54
|
+
.map { |r| convert_one(r.strip, prefix) }.join(" | ")
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
def convert_one(rule)
|
|
54
|
-
return "
|
|
57
|
+
def convert_one(rule, prefix)
|
|
58
|
+
return "#{prefix}*" if rule == "*"
|
|
55
59
|
|
|
56
60
|
# Tokenize chain first (handles > and whitespace)
|
|
57
61
|
if rule =~ /\s/ || rule.include?(">")
|
|
58
|
-
return convert_chain(rule)
|
|
62
|
+
return convert_chain(rule, prefix)
|
|
59
63
|
end
|
|
60
64
|
|
|
61
|
-
convert_simple(rule, prefix:
|
|
65
|
+
convert_simple(rule, prefix: prefix)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
# Parse a single simple selector into (tag, predicates) where
|
|
@@ -140,9 +144,9 @@ module Leptris
|
|
|
140
144
|
|
|
141
145
|
# Tokenize chain into [sel, op, sel, op, sel, ...] where op is :child or
|
|
142
146
|
# :descendant. Then build XPath.
|
|
143
|
-
def convert_chain(rule)
|
|
147
|
+
def convert_chain(rule, prefix)
|
|
144
148
|
tokens = tokenize_chain(rule)
|
|
145
|
-
build_chain_xpath(tokens)
|
|
149
|
+
build_chain_xpath(tokens, prefix)
|
|
146
150
|
end
|
|
147
151
|
private_class_method :convert_chain
|
|
148
152
|
|
|
@@ -167,11 +171,11 @@ module Leptris
|
|
|
167
171
|
end
|
|
168
172
|
private_class_method :tokenize_chain
|
|
169
173
|
|
|
170
|
-
def build_chain_xpath(tokens)
|
|
174
|
+
def build_chain_xpath(tokens, prefix)
|
|
171
175
|
first = tokens.shift
|
|
172
176
|
raise ArgumentError, "empty CSS chain" unless first.is_a?(String)
|
|
173
177
|
|
|
174
|
-
xpath = convert_simple(first, prefix:
|
|
178
|
+
xpath = convert_simple(first, prefix: prefix)
|
|
175
179
|
until tokens.empty?
|
|
176
180
|
op = tokens.shift
|
|
177
181
|
sel = tokens.shift
|
data/lib/leptris/xml/document.rb
CHANGED
|
@@ -287,6 +287,22 @@ class Leptris::XML::Document
|
|
|
287
287
|
@freed.state == :freed || @c_ptr.nil?
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# The thread-global last-failure [line, column] (1-based), or nil
|
|
291
|
+
# when no error is recorded — the position companion to
|
|
292
|
+
# Document#last_error; populated by recover parses.
|
|
293
|
+
def last_error_position
|
|
294
|
+
line = ::FFI::MemoryPointer.new(:int)
|
|
295
|
+
column = ::FFI::MemoryPointer.new(:int)
|
|
296
|
+
begin
|
|
297
|
+
Leptris::XML::FFI.leptris_last_error_position(line, column)
|
|
298
|
+
line.read_int.zero? && column.read_int.zero? ? nil :
|
|
299
|
+
[line.read_int, column.read_int]
|
|
300
|
+
ensure
|
|
301
|
+
line.free
|
|
302
|
+
column.free
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
290
306
|
# The most recent error recorded against this document, or nil.
|
|
291
307
|
def last_error
|
|
292
308
|
msg = Leptris::XML::FFI.leptris_document_last_error(@c_ptr)
|
data/lib/leptris/xml/ffi.rb
CHANGED
|
@@ -173,6 +173,10 @@ module Leptris
|
|
|
173
173
|
# count-only query returning the TOTAL across every child kind.
|
|
174
174
|
attach_function :leptris_node_children,
|
|
175
175
|
[:leptris_node_ref, :pointer, :size_t], :size_t
|
|
176
|
+
# Element-only batch; mirror-only — fetch_children rides the
|
|
177
|
+
# all-kind node_children above.
|
|
178
|
+
attach_function :leptris_element_children,
|
|
179
|
+
[:leptris_element, :pointer, :size_t], :size_t
|
|
176
180
|
attach_function :leptris_node_as_element,
|
|
177
181
|
[:leptris_node_ref], :leptris_element
|
|
178
182
|
attach_function :leptris_element_as_node,
|
|
@@ -402,6 +406,12 @@ module Leptris
|
|
|
402
406
|
# which (element / synthetic attribute / text / other).
|
|
403
407
|
attach_function :leptris_xpath_result_node_kind,
|
|
404
408
|
[:leptris_xpath_result, :size_t], :int
|
|
409
|
+
# Pre-_ex variants, superseded by xpath_result_get_nodes_ex /
|
|
410
|
+
# xpath_result_get_node; mirror-only (ADR 0001).
|
|
411
|
+
attach_function :leptris_xpath_result_get,
|
|
412
|
+
[:leptris_xpath_result, :size_t], :leptris_element
|
|
413
|
+
attach_function :leptris_xpath_result_get_nodes,
|
|
414
|
+
[:leptris_xpath_result, :pointer, :size_t], :size_t
|
|
405
415
|
attach_function :leptris_xpath_result_get_node,
|
|
406
416
|
[:leptris_xpath_result, :size_t], :leptris_node_ref
|
|
407
417
|
attach_function :leptris_xpath_result_node_name,
|
|
@@ -447,6 +457,10 @@ module Leptris
|
|
|
447
457
|
[:leptris_xpath_ns_set], :void
|
|
448
458
|
# One-call constructor: flat array of 2*pair_count alternating
|
|
449
459
|
# prefix/URI strings.
|
|
460
|
+
# Per-pair add; superseded by ns_set_new_from_pairs (one call);
|
|
461
|
+
# mirror-only (ADR 0001).
|
|
462
|
+
attach_function :leptris_xpath_ns_set_add,
|
|
463
|
+
[:leptris_xpath_ns_set, :string, :string], :leptris_status
|
|
450
464
|
attach_function :leptris_xpath_ns_set_new_from_pairs,
|
|
451
465
|
[:pointer, :size_t], :leptris_xpath_ns_set
|
|
452
466
|
attach_function :leptris_xpath_eval_ns,
|
|
@@ -507,6 +521,10 @@ module Leptris
|
|
|
507
521
|
|
|
508
522
|
attach_function :leptris_document_serialize,
|
|
509
523
|
[:leptris_document, :pointer], :pointer
|
|
524
|
+
# Deprecated automatic-options alias (header: prefer
|
|
525
|
+
# leptris_document_serialize); mirror-only.
|
|
526
|
+
attach_function :leptris_serialize_document,
|
|
527
|
+
[:leptris_document], :pointer
|
|
510
528
|
# libleptris >= 1.9.0: caller-buffer serialization with options
|
|
511
529
|
# (leptris#541). buf=NULL is a size query; the size-query +
|
|
512
530
|
# fill pair reuses one serialization through a per-document
|
|
@@ -553,6 +571,10 @@ module Leptris
|
|
|
553
571
|
[:leptris_document, :pointer, :pointer], :leptris_status
|
|
554
572
|
attach_function :leptris_status_string, [:leptris_status], :string
|
|
555
573
|
attach_function :leptris_error_message, [:leptris_status], :string
|
|
574
|
+
# Thread-global last-failure position (error.h): companion to
|
|
575
|
+
# leptris_last_error; populated by recover parses (#547).
|
|
576
|
+
attach_function :leptris_last_error_position,
|
|
577
|
+
[:pointer, :pointer], :void
|
|
556
578
|
# Thread-local since v1.3.0; reliable under the
|
|
557
579
|
# one-document-per-thread contract.
|
|
558
580
|
attach_function :leptris_last_error, [], :string
|
data/lib/leptris/xml/node.rb
CHANGED
|
@@ -205,15 +205,9 @@ class Leptris::XML::Node
|
|
|
205
205
|
|
|
206
206
|
# Walks the subtree in post-order DFS (matches Nokogiri's semantics).
|
|
207
207
|
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
# node. For a tree of N nodes that's ~N fewer allocations on a full
|
|
212
|
-
# traversal.
|
|
213
|
-
#
|
|
214
|
-
# Still pays ~2 FFI calls per visited node (first_child + next_sibling).
|
|
215
|
-
# Beating Nokogiri on this benchmark needs C-side traverse with a
|
|
216
|
-
# callback (libleptris #273); the per-node FFI cost is the floor.
|
|
208
|
+
# One FFI call dispatches the whole walk; the C engine invokes the
|
|
209
|
+
# callback once per visited node (the only per-node cost is the
|
|
210
|
+
# C-to-Ruby callback dispatch, not FFI round-trips).
|
|
217
211
|
def traverse
|
|
218
212
|
return enum_for(:traverse) unless block_given?
|
|
219
213
|
ensure_alive!
|
|
@@ -44,7 +44,11 @@ module Leptris::XML::Searchable
|
|
|
44
44
|
handler, ns, _ = parse_search_args(args)
|
|
45
45
|
raise ArgumentError, "namespace bindings not supported in css" if ns && !ns.empty?
|
|
46
46
|
raise ArgumentError, "custom CSS handlers not supported" if handler
|
|
47
|
-
|
|
47
|
+
# Nokogiri semantics: css is receiver-relative — absolute "//"
|
|
48
|
+
# from a Document, descendant ".//" from elements and fragments.
|
|
49
|
+
prefix = is_a?(Leptris::XML::Document) ? "//" : ".//"
|
|
50
|
+
expr = args.map { |r| Leptris::XML::CssToXPath.convert(r, prefix: prefix) }
|
|
51
|
+
.join(" | ")
|
|
48
52
|
xpath(expr)
|
|
49
53
|
end
|
|
50
54
|
|
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.8
|
|
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-08-
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ffi
|
|
@@ -65,9 +65,15 @@ extra_rdoc_files: []
|
|
|
65
65
|
files:
|
|
66
66
|
- CHANGELOG.md
|
|
67
67
|
- CLAUDE.md
|
|
68
|
+
- CONTEXT.md
|
|
68
69
|
- LICENSE.md
|
|
69
70
|
- README.adoc
|
|
70
71
|
- Rakefile
|
|
72
|
+
- docs/adr/0001-lockstep-mirror.md
|
|
73
|
+
- docs/adr/0002-utf8-at-the-seam.md
|
|
74
|
+
- docs/adr/0003-readonly-memoization-pattern.md
|
|
75
|
+
- docs/adr/0004-lifetime-guard.md
|
|
76
|
+
- docs/adr/0005-autoload-manifest-ordering.md
|
|
71
77
|
- leptris.gemspec
|
|
72
78
|
- lib/leptris.rb
|
|
73
79
|
- lib/leptris/version.rb
|