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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68bba6b5b68f0266571ec17a5809ea42ce7b1f37f0ba3577f9a06afd57db578f
4
- data.tar.gz: d15442dd463ba2b4d09c6415e51c478c385c851eb6630ffc8c4d00e6a9ad7a57
3
+ metadata.gz: '001396e9d1eca04e38bc3223964f4d8b8aa22f7672d32d6dc936f4730db35c60'
4
+ data.tar.gz: a4a24591d60276ea69fc9be41cd1b30aa6ac66df1cb77aa9f258489f2a8e7f28
5
5
  SHA512:
6
- metadata.gz: 52a5f4a5307b9b219911a9ebb7050672be1ad27096266e1c0732eb98ab40779055dcf25e4dd31c0ef0813966539e43894c6d4cdce20bd79e778a7df043c5631a
7
- data.tar.gz: cd2e0732f88060f6ff02e3496bedca3d986fde26c9400c91efe458c05e96379caeddd50a5950eb65b965be23487aac243ada697855ade71d0ed6017bcfe49433
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.
@@ -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.7"
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)
@@ -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
@@ -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,7 +1,7 @@
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.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