leptris 1.0.0

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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/CHANGELOG.md +529 -0
  5. data/CLAUDE.md +104 -0
  6. data/LICENSE.md +33 -0
  7. data/README.adoc +405 -0
  8. data/Rakefile +7 -0
  9. data/TODO.impl/01-architecture.md +217 -0
  10. data/TODO.impl/02-ffi-declarations.md +236 -0
  11. data/TODO.impl/03-document-node-element-nodeset.md +382 -0
  12. data/TODO.impl/04-sax-parser.md +203 -0
  13. data/TODO.impl/05-serialize-c14n-memory-specs-css.md +276 -0
  14. data/benchmark/README.md +168 -0
  15. data/benchmark/leptris_vs_nokogiri.rb +105 -0
  16. data/docs/ARCHITECTURE.adoc +559 -0
  17. data/docs/BUILD.md +395 -0
  18. data/docs/ERROR_MESSAGES.md +458 -0
  19. data/docs/FFI_ARCHITECTURE.md +439 -0
  20. data/docs/FUTURE_VISION.md +303 -0
  21. data/docs/GITHUB_ACTIONS.md +293 -0
  22. data/docs/OPTIMIZATIONS_IMPLEMENTED.adoc +459 -0
  23. data/docs/PERFORMANCE.adoc +668 -0
  24. data/docs/PERFORMANCE.md +448 -0
  25. data/docs/RELEASE_NOTES_v1.0.0.md +515 -0
  26. data/docs/XPATH_SPEC_COMPLIANCE.md +298 -0
  27. data/docs/completion/leptris.bash +86 -0
  28. data/docs/completion/leptris.zsh +74 -0
  29. data/docs/man/leptris-format.1 +227 -0
  30. data/docs/man/leptris-parse.1 +178 -0
  31. data/docs/man/leptris-xpath.1 +312 -0
  32. data/docs/man/leptris.1 +160 -0
  33. data/docs/v0.9.0_PERFORMANCE_IMPROVEMENTS.md +217 -0
  34. data/docs/v0.9.0_RELEASE_SUMMARY.md +281 -0
  35. data/docs/v1.0.0_CONTINUATION_PLAN.md +172 -0
  36. data/docs/v1.0.0_CONTINUATION_PROMPT.md +382 -0
  37. data/docs/v1.0.0_SESSION_6_CONTINUATION.md +434 -0
  38. data/docs/v1.0.0_SESSION_6_PROMPT.md +231 -0
  39. data/docs/v1.0.0_STATUS_TRACKER.md +224 -0
  40. data/docs/v1.1.0_CONTINUATION_PLAN.md +299 -0
  41. data/docs/v1.1.0_FINAL_CONTINUATION_PLAN.md +201 -0
  42. data/docs/v1.1.0_SESSION_3_PROMPT.md +223 -0
  43. data/docs/v1.1.0_STATUS_TRACKER.md +355 -0
  44. data/docs/xml-performance.adoc +115 -0
  45. data/docs/xpath-performance.adoc +379 -0
  46. data/leptris.gemspec +42 -0
  47. data/lib/leptris/version.rb +5 -0
  48. data/lib/leptris/xml/attr.rb +43 -0
  49. data/lib/leptris/xml/c14n.rb +23 -0
  50. data/lib/leptris/xml/cdata.rb +16 -0
  51. data/lib/leptris/xml/comment.rb +16 -0
  52. data/lib/leptris/xml/css_to_xpath.rb +177 -0
  53. data/lib/leptris/xml/doc_type.rb +54 -0
  54. data/lib/leptris/xml/document.rb +202 -0
  55. data/lib/leptris/xml/document_fragment.rb +42 -0
  56. data/lib/leptris/xml/element.rb +278 -0
  57. data/lib/leptris/xml/ffi.rb +420 -0
  58. data/lib/leptris/xml/namespace.rb +43 -0
  59. data/lib/leptris/xml/node.rb +221 -0
  60. data/lib/leptris/xml/node_set.rb +143 -0
  61. data/lib/leptris/xml/parse_options.rb +19 -0
  62. data/lib/leptris/xml/processing_instruction.rb +26 -0
  63. data/lib/leptris/xml/sax/document.rb +45 -0
  64. data/lib/leptris/xml/sax/parser.rb +148 -0
  65. data/lib/leptris/xml/sax.rb +12 -0
  66. data/lib/leptris/xml/searchable.rb +93 -0
  67. data/lib/leptris/xml/text.rb +16 -0
  68. data/lib/leptris/xml.rb +39 -0
  69. data/lib/leptris.rb +7 -0
  70. metadata +157 -0
@@ -0,0 +1,236 @@
1
+ # TODO 2 — FFI declarations: complete libleptris v0.4.2 public API
2
+
3
+ ## Goal
4
+
5
+ Create `lib/leptris/xml/ffi.rb` that attaches to EVERY public function
6
+ in libleptris v0.4.2 via the `ffi` gem. This is the single source of
7
+ truth for the C ↔ Ruby boundary.
8
+
9
+ ## Library loading
10
+
11
+ ```ruby
12
+ module Leptris
13
+ module XML
14
+ module FFI
15
+ extend ::FFI
16
+
17
+ ffi_lib [
18
+ ENV['LEPTRIS_LIB_PATH'],
19
+ 'leptris',
20
+ '/usr/local/lib/libleptris.dylib',
21
+ '/usr/local/lib/libleptris.so',
22
+ File.expand_path('../../../build/src/libleptris.dylib', __dir__),
23
+ File.expand_path('../../../build/src/libleptris.so', __dir__),
24
+ ].compact
25
+ end
26
+ end
27
+ end
28
+ ```
29
+
30
+ ## Opaque type declarations
31
+
32
+ ```ruby
33
+ typedef :pointer, :document
34
+ typedef :pointer, :element
35
+ typedef :pointer, :node_ref
36
+ typedef :pointer, :xpath_result
37
+ typedef :pointer, :sax_parser
38
+ typedef :pointer, :attribute
39
+ ```
40
+
41
+ ## Complete function list (attach all of these)
42
+
43
+ Source: `src/include/leptris/types.h`, `src/include/leptris.h`, and
44
+ `src/include/leptris/*.h`.
45
+
46
+ ### Version
47
+ ```ruby
48
+ attach_function :leptris_version, [], :string
49
+ ```
50
+
51
+ ### Document lifecycle
52
+ ```ruby
53
+ attach_function :leptris_parse_string, [:string, :size_t, :pointer], :document
54
+ attach_function :leptris_document_free, [:document], :void
55
+ attach_function :leptris_document_root, [:document], :element
56
+ attach_function :leptris_document_serialize, [:document, :pointer], :pointer
57
+ attach_function :leptris_document_set_strict, [:document, :int], :void
58
+ attach_function :leptris_xinclude_process, [:document, :string], :int
59
+ ```
60
+
61
+ ### Node access
62
+ ```ruby
63
+ attach_function :leptris_node_get_type, [:node_ref], :int
64
+ attach_function :leptris_node_first_child, [:node_ref], :node_ref
65
+ attach_function :leptris_node_next_sibling, [:node_ref], :node_ref
66
+ attach_function :leptris_node_previous_sibling, [:node_ref], :node_ref
67
+ attach_function :leptris_node_child_count, [:node_ref], :size_t
68
+ attach_function :leptris_node_as_element, [:node_ref], :element
69
+ attach_function :leptris_element_as_node, [:element], :node_ref
70
+ attach_function :leptris_element_first_child_any, [:element], :element
71
+ ```
72
+
73
+ ### Element queries
74
+ ```ruby
75
+ attach_function :leptris_element_name, [:element], :string
76
+ attach_function :leptris_element_text, [:element], :string
77
+ attach_function :leptris_element_attribute, [:element, :string, :string], :string
78
+ attach_function :leptris_element_attribute_count, [:element], :size_t
79
+ attach_function :leptris_element_first_attribute, [:element], :pointer
80
+ attach_function :leptris_element_parent, [:element], :element
81
+ attach_function :leptris_element_next_sibling_any, [:element], :element
82
+ attach_function :leptris_element_get_namespace_uri, [:element], :string
83
+ attach_function :leptris_element_get_prefix, [:element], :string
84
+ attach_function :leptris_element_get_name, [:element], :string
85
+ ```
86
+
87
+ ### Element mutation
88
+ ```ruby
89
+ attach_function :leptris_element_set_name, [:element, :string], :void
90
+ attach_function :leptris_element_set_attribute, [:element, :string, :string], :void
91
+ attach_function :leptris_element_remove_attribute, [:element, :string], :void
92
+ attach_function :leptris_element_append_child, [:element, :element], :int
93
+ attach_function :leptris_element_create_child, [:element, :string], :element
94
+ attach_function :leptris_element_set_text, [:element, :string], :void
95
+ attach_function :leptris_element_remove_child, [:element, :element], :void
96
+ ```
97
+
98
+ ### Element creation
99
+ ```ruby
100
+ attach_function :leptris_element_create, [:string], :element
101
+ attach_function :leptris_text_node_create, [:string], :element
102
+ attach_function :leptris_comment_node_create, [:string], :element
103
+ attach_function :leptris_cdata_node_create, [:string], :element
104
+ attach_function :leptris_pi_node_create, [:string, :string], :element
105
+ ```
106
+
107
+ ### Text / Comment / CDATA / PI access
108
+ ```ruby
109
+ attach_function :leptris_text_node_get_content, [:node_ref], :string
110
+ attach_function :leptris_comment_node_get_content, [:node_ref], :string
111
+ attach_function :leptris_cdata_node_get_content, [:node_ref], :string
112
+ attach_function :leptris_pi_node_get_target, [:node_ref], :string
113
+ attach_function :leptris_pi_node_get_data, [:node_ref], :string
114
+ ```
115
+
116
+ ### XPath
117
+ ```ruby
118
+ attach_function :leptris_xpath_eval,
119
+ [:document, :element, :string], :xpath_result
120
+ attach_function :leptris_xpath_eval_with_vars,
121
+ [:document, :string, :pointer], :xpath_result
122
+ attach_function :leptris_xpath_result_type, [:xpath_result], :int
123
+ attach_function :leptris_xpath_result_count, [:xpath_result], :size_t
124
+ attach_function :leptris_xpath_result_get, [:xpath_result, :size_t], :element
125
+ attach_function :leptris_xpath_result_boolean, [:xpath_result], :int
126
+ attach_function :leptris_xpath_result_number, [:xpath_result], :double
127
+ attach_function :leptris_xpath_result_string, [:xpath_result], :pointer
128
+ attach_function :leptris_xpath_result_free, [:xpath_result], :void
129
+ ```
130
+
131
+ ### XPath variables
132
+ ```ruby
133
+ attach_function :leptris_xpath_variable_set_new, [], :pointer
134
+ attach_function :leptris_xpath_variable_set_free, [:pointer], :void
135
+ attach_function :leptris_xpath_variable_set_boolean, [:pointer, :string, :int], :int
136
+ attach_function :leptris_xpath_variable_set_number, [:pointer, :string, :double], :int
137
+ attach_function :leptris_xpath_variable_set_string, [:pointer, :string, :string], :int
138
+ ```
139
+
140
+ ### SAX
141
+ ```ruby
142
+ # LeptrisSAXHandler is a struct of function pointers. Use FFI::Struct.
143
+ class SAXHandler < ::FFI::Struct
144
+ layout \
145
+ :start_document, :pointer,
146
+ :end_document, :pointer,
147
+ :start_element, :pointer,
148
+ :end_element, :pointer,
149
+ :characters, :pointer,
150
+ :comment, :pointer,
151
+ :cdata, :pointer,
152
+ :processing_instruction, :pointer,
153
+ :start_prefix_mapping, :pointer,
154
+ :end_prefix_mapping, :pointer,
155
+ :error, :pointer
156
+ end
157
+
158
+ attach_function :leptris_sax_parse,
159
+ [:string, :size_t, SAXHandler.by_pointer, :pointer], :int
160
+ attach_function :leptris_sax_parser_create,
161
+ [SAXHandler.by_pointer, :pointer], :sax_parser
162
+ attach_function :leptris_sax_parser_feed,
163
+ [:sax_parser, :string, :size_t, :int], :int
164
+ attach_function :leptris_sax_parser_free, [:sax_parser], :void
165
+ attach_function :leptris_sax_parser_set_streaming,
166
+ [:sax_parser, :int], :int
167
+ ```
168
+
169
+ ### Serialization
170
+ ```ruby
171
+ # LeptrisSerializeOptions struct
172
+ class SerializeOptions < ::FFI::Struct
173
+ layout \
174
+ :indent, :int,
175
+ :xml_declaration, :int,
176
+ :no_empty_tags, :int,
177
+ :preserve_whitespace, :int
178
+ end
179
+
180
+ attach_function :leptris_serialize_document,
181
+ [:document, :pointer], :pointer
182
+ attach_function :leptris_c14n_canonicalize,
183
+ [:document, :int, :int], :pointer
184
+ ```
185
+
186
+ ### Memory
187
+ ```ruby
188
+ attach_function :leptris_free_string, [:pointer], :void
189
+ ```
190
+
191
+ ## Status codes
192
+
193
+ ```ruby
194
+ LEPTRIS_OK = 0
195
+ LEPTRIS_ERROR_MEMORY = -1
196
+ LEPTRIS_ERROR_PARSE = -2
197
+ LEPTRIS_ERROR_XPATH = -3
198
+ LEPTRIS_ERROR_NULL_ARG = -4
199
+ LEPTRIS_ERROR_INVALID_ARG = -5
200
+ LEPTRIS_ERROR_NOT_FOUND = -6
201
+ LEPTRIS_ERROR_IO = -7
202
+ ```
203
+
204
+ ## Node type constants
205
+
206
+ ```ruby
207
+ NODE_ELEMENT = 0
208
+ NODE_ATTRIBUTE = 1
209
+ NODE_TEXT = 2
210
+ NODE_COMMENT = 3
211
+ NODE_CDATA = 4
212
+ NODE_PI = 5
213
+ NODE_DOCTYPE = 6
214
+ ```
215
+
216
+ ## XPath result type constants
217
+
218
+ ```ruby
219
+ XPATH_NODESET = 0
220
+ XPATH_BOOLEAN = 1
221
+ XPATH_NUMBER = 2
222
+ XPATH_STRING = 3
223
+ ```
224
+
225
+ ## Notes
226
+
227
+ - Use `Blocking: true` for SAX callbacks (FFI::Function).
228
+ - Use `AutoPointer` for document and xpath_result to get automatic
229
+ cleanup. But ALSO provide explicit `#free` methods since GC timing
230
+ is non-deterministic.
231
+ - The `leptris_element_*` functions that return `:string` return
232
+ document-owned strings (valid until `leptris_document_free`).
233
+ Ruby copies them automatically on FFI return — safe.
234
+ - Functions that return `:pointer` for strings (like
235
+ `leptris_xpath_result_string`, `leptris_serialize_document`) return
236
+ heap-owned strings that the caller must free via `leptris_free_string`.
@@ -0,0 +1,382 @@
1
+ # TODO 3 — Document, Node, Element, NodeSet, Searchable implementation
2
+
3
+ ## Goal
4
+
5
+ Implement the core Nokogiri-compatible Ruby classes backed by FFI
6
+ calls to libleptris. Each Ruby method = one C function call.
7
+
8
+ ## File layout
9
+
10
+ ```
11
+ lib/leptris.rb # autoload + version
12
+ lib/leptris/xml.rb # XML module + parse entry points
13
+ lib/leptris/xml/ffi.rb # FFI declarations (TODO 2)
14
+ lib/leptris/xml/document.rb # Document class
15
+ lib/leptris/xml/node.rb # Node base class
16
+ lib/leptris/xml/element.rb # Element < Node
17
+ lib/leptris/xml/text.rb # Text < Node
18
+ lib/leptris/xml/comment.rb # Comment < Node
19
+ lib/leptris/xml/cdata.rb # CDATA < Node
20
+ lib/leptris/xml/processing_instruction.rb # ProcessingInstruction < Node
21
+ lib/leptris/xml/attr.rb # Attr class
22
+ lib/leptris/xml/node_set.rb # NodeSet class
23
+ lib/leptris/xml/searchable.rb # Searchable mixin (xpath/css/search)
24
+ lib/leptris/xml/parse_options.rb # ParseOptions class
25
+ ```
26
+
27
+ ## Autoload pattern
28
+
29
+ ```ruby
30
+ # lib/leptris.rb
31
+ module Leptris
32
+ autoload :XML, 'leptris/xml'
33
+ end
34
+
35
+ # lib/leptris/xml.rb
36
+ module Leptris
37
+ module XML
38
+ autoload :FFI, 'leptris/xml/ffi'
39
+ autoload :Document, 'leptris/xml/document'
40
+ autoload :Node, 'leptris/xml/node'
41
+ autoload :Element, 'leptris/xml/element'
42
+ autoload :Text, 'leptris/xml/text'
43
+ autoload :Comment, 'leptris/xml/comment'
44
+ autoload :CDATA, 'leptris/xml/cdata'
45
+ autoload :ProcessingInstruction, 'leptris/xml/processing_instruction'
46
+ autoload :Attr, 'leptris/xml/attr'
47
+ autoload :NodeSet, 'leptris/xml/node_set'
48
+ autoload :Searchable, 'leptris/xml/searchable'
49
+ autoload :ParseOptions, 'leptris/xml/parse_options'
50
+
51
+ def self.parse(string_or_io, options = nil)
52
+ xml = string_or_io.respond_to?(:read) ? string_or_io.read : string_or_io
53
+ status = ::FFI::MemoryPointer.new(:int)
54
+ ptr = FFI.leptris_parse_string(xml, xml.bytesize, status)
55
+ raise ParseError, "leptris_parse_string failed (status=#{status.read_int})" if ptr.nil? || ptr.null?
56
+ Document.wrap(ptr)
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Document
63
+
64
+ ```ruby
65
+ class Leptris::XML::Document < Leptris::XML::Node
66
+ def self.wrap(c_ptr)
67
+ doc = allocate
68
+ doc.instance_variable_set(:@c_ptr, c_ptr)
69
+ doc
70
+ end
71
+
72
+ def root
73
+ ptr = FFI.leptris_document_root(@c_ptr)
74
+ return nil if ptr.nil? || ptr.null?
75
+ Leptris::XML::Element.wrap(ptr, self)
76
+ end
77
+
78
+ def xpath(expr)
79
+ Leptris::XML::XPath.evaluate(@c_ptr, nil, expr)
80
+ end
81
+
82
+ def at_xpath(expr)
83
+ result = xpath(expr)
84
+ result.is_a?(NodeSet) ? result.first : result
85
+ end
86
+
87
+ def to_xml(options = {})
88
+ opts = SerializeOptions.new
89
+ opts[:indent] = options[:indent] || 0
90
+ opts[:xml_declaration] = options[:no_decl] ? 0 : 1
91
+ ptr = FFI.leptris_serialize_document(@c_ptr, opts.pointer)
92
+ return '' if ptr.nil? || ptr.null?
93
+ str = ptr.read_string
94
+ FFI.leptris_free_string(ptr)
95
+ str
96
+ end
97
+
98
+ def free
99
+ return unless @c_ptr
100
+ FFI.leptris_document_free(@c_ptr)
101
+ @c_ptr = nil
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Node (base class)
107
+
108
+ ```ruby
109
+ class Leptris::XML::Node
110
+ attr_reader :c_ptr, :document
111
+
112
+ def initialize(c_ptr, document)
113
+ @c_ptr = c_ptr
114
+ @document = document
115
+ end
116
+
117
+ def self.wrap(c_ptr, document)
118
+ type = FFI.leptris_node_get_type(c_ptr)
119
+ case type
120
+ when NODE_ELEMENT then Leptris::XML::Element.new(c_ptr, document)
121
+ when NODE_TEXT then Leptris::XML::Text.new(c_ptr, document)
122
+ when NODE_COMMENT then Leptris::XML::Comment.new(c_ptr, document)
123
+ when NODE_CDATA then Leptris::XML::CDATA.new(c_ptr, document)
124
+ when NODE_PI then Leptris::XML::ProcessingInstruction.new(c_ptr, document)
125
+ else new(c_ptr, document)
126
+ end
127
+ end
128
+
129
+ def name
130
+ raise NotImplementedError
131
+ end
132
+
133
+ def content
134
+ raise NotImplementedError
135
+ end
136
+
137
+ def type
138
+ FFI.leptris_node_get_type(@c_ptr)
139
+ end
140
+
141
+ def element?; type == NODE_ELEMENT; end
142
+ def text?; type == NODE_TEXT; end
143
+ def comment?; type == NODE_COMMENT; end
144
+ def cdata?; type == NODE_CDATA; end
145
+ def processing_instruction?; type == NODE_PI; end
146
+
147
+ def next_sibling
148
+ ptr = FFI.leptris_node_next_sibling(@c_ptr)
149
+ return nil if ptr.null?
150
+ Node.wrap(ptr, @document)
151
+ end
152
+
153
+ def previous_sibling
154
+ ptr = FFI.leptris_node_previous_sibling(@c_ptr)
155
+ return nil if ptr.null?
156
+ Node.wrap(ptr, @document)
157
+ end
158
+
159
+ def parent
160
+ ptr = FFI.leptris_element_parent(@c_ptr) # only works for elements
161
+ return nil if ptr.null?
162
+ Element.wrap(ptr, @document)
163
+ end
164
+
165
+ def children
166
+ NodeSet.new(@document, self)
167
+ end
168
+
169
+ def child
170
+ ptr = FFI.leptris_node_first_child(@c_ptr)
171
+ return nil if ptr.null?
172
+ Node.wrap(ptr, @document)
173
+ end
174
+
175
+ def traverse(&block)
176
+ return enum_for(:traverse) unless block_given?
177
+ block.call(self)
178
+ children.each { |c| c.traverse(&block) }
179
+ end
180
+
181
+ include Searchable
182
+ end
183
+ ```
184
+
185
+ ## Element
186
+
187
+ ```ruby
188
+ class Leptris::XML::Element < Leptris::XML::Node
189
+ def name
190
+ FFI.leptris_element_name(@c_ptr)
191
+ end
192
+
193
+ def name=(n)
194
+ FFI.leptris_element_set_name(@c_ptr, n)
195
+ end
196
+
197
+ def content
198
+ FFI.leptris_element_text(@c_ptr)
199
+ end
200
+ alias_method :text, :content
201
+
202
+ def [](attr_name)
203
+ FFI.leptris_element_attribute(@c_ptr, attr_name, nil)
204
+ end
205
+
206
+ def []=(attr_name, value)
207
+ FFI.leptris_element_set_attribute(@c_ptr, attr_name, value.to_s)
208
+ end
209
+
210
+ def attributes
211
+ # Walk the C attribute list, build a hash of Attr objects
212
+ result = {}
213
+ count = FFI.leptris_element_attribute_count(@c_ptr)
214
+ # ... iterate attribute linked list ...
215
+ result
216
+ end
217
+
218
+ def keys
219
+ attributes.keys
220
+ end
221
+
222
+ def values
223
+ attributes.values.map(&:value)
224
+ end
225
+
226
+ def first_element_child
227
+ ptr = FFI.leptris_element_first_child_any(@c_ptr)
228
+ return nil if ptr.null?
229
+ Element.wrap(ptr, @document)
230
+ end
231
+
232
+ def add_child(node)
233
+ FFI.leptris_element_append_child(@c_ptr, node.c_ptr)
234
+ node
235
+ end
236
+
237
+ def add_class(names) ... end
238
+ def remove_class(names = nil) ... end
239
+ def classes
240
+ (self['class'] || '').split
241
+ end
242
+ end
243
+ ```
244
+
245
+ ## NodeSet
246
+
247
+ ```ruby
248
+ class Leptris::XML::NodeSet
249
+ include Enumerable
250
+ include Searchable
251
+
252
+ def initialize(document, result_ptr = nil)
253
+ @document = document
254
+ @result_ptr = result_ptr # LeptrisXPathResult pointer
255
+ end
256
+
257
+ def length
258
+ return @cached_length if @cached_length
259
+ @cached_length =
260
+ @result_ptr ? FFI.leptris_xpath_result_count(@result_ptr) : 0
261
+ end
262
+ alias_method :size, :length
263
+
264
+ def [](index)
265
+ return nil if index < 0 || index >= length
266
+ ptr = FFI.leptris_xpath_result_get(@result_ptr, index)
267
+ return nil if ptr.null?
268
+ Node.wrap(ptr, @document)
269
+ end
270
+
271
+ def first(n = nil)
272
+ return self[0] if n.nil?
273
+ NodeSet.new(@document).tap { |ns| n.times { |i| ns << self[i] } }
274
+ end
275
+
276
+ def last
277
+ self[length - 1]
278
+ end
279
+
280
+ def each
281
+ return enum_for(:each) unless block_given?
282
+ length.times { |i| yield self[i] }
283
+ end
284
+
285
+ def empty?
286
+ length == 0
287
+ end
288
+
289
+ def inner_text
290
+ map(&:content).join
291
+ end
292
+
293
+ def to_xml
294
+ map(&:to_xml).join
295
+ end
296
+
297
+ def free
298
+ return unless @result_ptr
299
+ FFI.leptris_xpath_result_free(@result_ptr)
300
+ @result_ptr = nil
301
+ end
302
+ end
303
+ ```
304
+
305
+ ## Searchable (mixin for xpath/css)
306
+
307
+ ```ruby
308
+ module Leptris::XML::Searchable
309
+ def xpath(*paths)
310
+ expr = paths.join(' | ')
311
+ result_ptr = FFI.leptris_xpath_eval(
312
+ document.c_ptr,
313
+ respond_to?(:c_ptr) ? c_ptr : nil,
314
+ expr
315
+ )
316
+ return nil if result_ptr.null?
317
+
318
+ result_type = FFI.leptris_xpath_result_type(result_ptr)
319
+ case result_type
320
+ when XPATH_NODESET
321
+ NodeSet.new(@document, result_ptr)
322
+ when XPATH_NUMBER
323
+ n = FFI.leptris_xpath_result_number(result_ptr)
324
+ FFI.leptris_xpath_result_free(result_ptr)
325
+ n
326
+ when XPATH_STRING
327
+ s = FFI.leptris_xpath_result_string(result_ptr)
328
+ FFI.leptris_xpath_result_free(result_ptr)
329
+ s
330
+ when XPATH_BOOLEAN
331
+ b = FFI.leptris_xpath_result_boolean(result_ptr)
332
+ FFI.leptris_xpath_result_free(result_ptr)
333
+ b == 1
334
+ end
335
+ end
336
+
337
+ def at_xpath(*paths)
338
+ ns = xpath(*paths)
339
+ ns.is_a?(NodeSet) ? ns.first : ns
340
+ end
341
+
342
+ def search(*args)
343
+ expr = args.first.to_s
344
+ if expr.start_with?('/') || expr.start_with?('//')
345
+ xpath(expr)
346
+ else
347
+ css(expr)
348
+ end
349
+ end
350
+
351
+ def at(*args)
352
+ result = search(*args)
353
+ result.is_a?(NodeSet) ? result.first : result
354
+ end
355
+
356
+ def css(*selectors)
357
+ # Convert CSS to XPath (minimal converter)
358
+ xpath_expr = CssToXPath.convert(selectors.join(', '))
359
+ xpath(xpath_expr)
360
+ end
361
+
362
+ def at_css(*selectors)
363
+ result = css(*selectors)
364
+ result.is_a?(NodeSet) ? result.first : result
365
+ end
366
+ end
367
+ ```
368
+
369
+ ## Implementation notes
370
+
371
+ - **No `require_relative`** anywhere. Use `autoload` defined in the
372
+ immediate parent namespace's file (e.g., `lib/leptris/xml.rb`).
373
+ - **No `instance_variable_set`/`instance_variable_get`** on other
374
+ objects. Use public accessor methods.
375
+ - **No `send`** to call private methods.
376
+ - **No `respond_to?`** for type checks. Use `is_a?`.
377
+ - Wrap C pointers in Ruby objects via `Node.wrap(ptr, doc)` which
378
+ dispatches on the C node type.
379
+ - Node objects are lightweight: just a pointer + document reference.
380
+ No caching of properties (each call goes through FFI).
381
+ - The document is the ONLY object that owns memory. All other objects
382
+ are non-owning handles.