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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68bba6b5b68f0266571ec17a5809ea42ce7b1f37f0ba3577f9a06afd57db578f
4
- data.tar.gz: d15442dd463ba2b4d09c6415e51c478c385c851eb6630ffc8c4d00e6a9ad7a57
3
+ metadata.gz: 8f306f9bc45d304b82c3db1ac2936dcc0d708b21d2caf595e4a9484da5c6b48a
4
+ data.tar.gz: ce9b04f6f1e91e6dffd1ff68ce70967b8b2bcff8351132db2f3312e4b534e803
5
5
  SHA512:
6
- metadata.gz: 52a5f4a5307b9b219911a9ebb7050672be1ad27096266e1c0732eb98ab40779055dcf25e4dd31c0ef0813966539e43894c6d4cdce20bd79e778a7df043c5631a
7
- data.tar.gz: cd2e0732f88060f6ff02e3496bedca3d986fde26c9400c91efe458c05e96379caeddd50a5950eb65b965be23487aac243ada697855ade71d0ed6017bcfe49433
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 audit
92
- attached-vs-exported (`nm -gU` on a fresh build).
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 `Element` (via `Leptris::XML::Searchable`) support:
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.6"
4
+ VERSION = "1.9.8"
5
5
  end
@@ -35,30 +35,34 @@ module Leptris
35
35
 
36
36
  module_function
37
37
 
38
- # Translation is a pure function of the rule string, and real
39
- # workloads repeat a small selector vocabulary in loops —
40
- # memoize. Failed translations raise before caching.
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.to_s
46
- CACHE.fetch(key) { CACHE[key] = convert_rule(key) }
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).map { |r| convert_one(r.strip) }.join(" | ")
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 "//*" if rule == "*"
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
@@ -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)
@@ -5,6 +5,8 @@
5
5
  # the fragment itself isn't part of any document tree but borrows its
6
6
  # document's lifetime.
7
7
  class Leptris::XML::DocumentFragment
8
+ include Leptris::XML::Searchable
9
+
8
10
  attr_reader :document, :c_ptr
9
11
 
10
12
  def initialize(document, c_ptr)
@@ -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
@@ -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
- # Specialized hot path: skips the intermediate NodeSet allocation that
209
- # Element#children would create, walking via raw FFI calls and wrapping
210
- # nodes directly. Saves one Array + one NodeSet allocation per parent
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
- expr = args.map { |r| Leptris::XML::CssToXPath.convert(r) }.join(" | ")
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.6
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-25 00:00:00.000000000 Z
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