leptris 1.9.6 → 1.9.7
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 +32 -0
- data/CLAUDE.md +3 -0
- data/CONTEXT.md +39 -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 +4 -0
- data/lib/leptris/xml/node.rb +3 -9
- data/lib/leptris/xml/searchable.rb +5 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '001396e9d1eca04e38bc3223964f4d8b8aa22f7672d32d6dc936f4730db35c60'
|
|
4
|
+
data.tar.gz: a4a24591d60276ea69fc9be41cd1b30aa6ac66df1cb77aa9f258489f2a8e7f28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94b3e177f5419851483df347346993d6e42b9eb7a2ed7cca238804547cd65ab81ef7cdd3de04fe9898493f35e13178b359b99aecf653aea379a5714be088fc87
|
|
7
|
+
data.tar.gz: d30f779432c68c767754e55d2695e53c920e94d3f37b31603a76841c4c462b4afd62f192ba02de647ba762b4c4be59748615803fbdaedd570dd69c3ab5a488bd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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.7] - 2026-08-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **DocumentFragment is searchable**: `fragment.xpath/at_xpath/
|
|
13
|
+
css/at_css/search` — Nokogiri fragment parity. The engine already
|
|
14
|
+
accepted the fragment handle as an XPath context node; the Ruby
|
|
15
|
+
class now includes Searchable.
|
|
16
|
+
- **`Document#last_error_position`** → `[line, column]` (1-based,
|
|
17
|
+
thread-global and sticky per the C contract) — the position
|
|
18
|
+
companion to the recover mode's failure detail. Closes an
|
|
19
|
+
export-audit gap: `leptris_last_error_position` was exported but
|
|
20
|
+
unattached.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **css is receiver-relative** (Nokogiri semantics): `element.css`
|
|
25
|
+
and `fragment.css` scope to the receiver (`.//`), `Document#css`
|
|
26
|
+
stays document-absolute (`//`). Previously every receiver
|
|
27
|
+
translated to document-absolute XPath — wrong results for any
|
|
28
|
+
non-root element or fragment context. Document-context behavior
|
|
29
|
+
is unchanged.
|
|
30
|
+
|
|
31
|
+
### Meta
|
|
32
|
+
|
|
33
|
+
- **CONTEXT.md + docs/adr/**: the domain glossary (ownership model,
|
|
34
|
+
borrowed handles, wrapper identity, readonly contract, the seam
|
|
35
|
+
and its two faces) and five ADRs recording the settled decisions
|
|
36
|
+
(lockstep mirror, UTF-8 at the seam, hand-rolled memoization
|
|
37
|
+
pattern, the lifetime guard and its measured price, autoload
|
|
38
|
+
ordering) — future reviews stop re-deriving them.
|
|
39
|
+
|
|
8
40
|
## [1.9.6] - 2026-08-26
|
|
9
41
|
|
|
10
42
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -87,6 +87,9 @@ 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
94
|
contract; when symbols change, bump lockstep and audit
|
|
92
95
|
attached-vs-exported (`nm -gU` on a fresh build).
|
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.
|
|
@@ -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
|
@@ -553,6 +553,10 @@ module Leptris
|
|
|
553
553
|
[:leptris_document, :pointer, :pointer], :leptris_status
|
|
554
554
|
attach_function :leptris_status_string, [:leptris_status], :string
|
|
555
555
|
attach_function :leptris_error_message, [:leptris_status], :string
|
|
556
|
+
# Thread-global last-failure position (error.h): companion to
|
|
557
|
+
# leptris_last_error; populated by recover parses (#547).
|
|
558
|
+
attach_function :leptris_last_error_position,
|
|
559
|
+
[:pointer, :pointer], :void
|
|
556
560
|
# Thread-local since v1.3.0; reliable under the
|
|
557
561
|
# one-document-per-thread contract.
|
|
558
562
|
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,7 +1,7 @@
|
|
|
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.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -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
|