leptris 1.1.2-aarch64-linux

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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/CHANGELOG.md +619 -0
  5. data/CLAUDE.md +104 -0
  6. data/LICENSE.md +33 -0
  7. data/README.adoc +405 -0
  8. data/Rakefile +98 -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 +41 -0
  49. data/lib/leptris/xml/c_string_array.rb +37 -0
  50. data/lib/leptris/xml/cdata.rb +15 -0
  51. data/lib/leptris/xml/comment.rb +15 -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 +212 -0
  55. data/lib/leptris/xml/document_fragment.rb +42 -0
  56. data/lib/leptris/xml/element.rb +259 -0
  57. data/lib/leptris/xml/ffi.rb +475 -0
  58. data/lib/leptris/xml/namespace.rb +43 -0
  59. data/lib/leptris/xml/node.rb +211 -0
  60. data/lib/leptris/xml/node_set.rb +150 -0
  61. data/lib/leptris/xml/parse_options.rb +29 -0
  62. data/lib/leptris/xml/processing_instruction.rb +24 -0
  63. data/lib/leptris/xml/sax/document.rb +45 -0
  64. data/lib/leptris/xml/sax/parser.rb +136 -0
  65. data/lib/leptris/xml/sax.rb +12 -0
  66. data/lib/leptris/xml/searchable.rb +92 -0
  67. data/lib/leptris/xml/serialization.rb +41 -0
  68. data/lib/leptris/xml/text.rb +15 -0
  69. data/lib/leptris/xml.rb +40 -0
  70. data/lib/leptris.rb +7 -0
  71. data/lib/libleptris.so +0 -0
  72. metadata +162 -0
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::Node
4
+ attr_reader :c_ptr, :document
5
+
6
+ def initialize(c_ptr, document, parent: nil)
7
+ @c_ptr = c_ptr
8
+ @document = document
9
+ @parent = parent
10
+ end
11
+
12
+ def self.wrap(c_ptr, document, parent: nil)
13
+ # Per-document weak-ref cache. Returns the existing wrapper when the
14
+ # same c_ptr is wrapped twice (common in children/sibling walks,
15
+ # repeated xpath queries, traverse-then-access patterns). The cache
16
+ # dies with the document so no stale entries.
17
+ if document && (cached = document.wrapper_cache[c_ptr.address])
18
+ return cached
19
+ end
20
+
21
+ node =
22
+ case Leptris::XML::FFI.leptris_node_get_type(c_ptr)
23
+ when Leptris::XML::FFI::NODE_ELEMENT
24
+ Leptris::XML::Element.new(c_ptr, document, parent: parent)
25
+ when Leptris::XML::FFI::NODE_TEXT
26
+ Leptris::XML::Text.new(c_ptr, document, parent: parent)
27
+ when Leptris::XML::FFI::NODE_COMMENT
28
+ Leptris::XML::Comment.new(c_ptr, document, parent: parent)
29
+ when Leptris::XML::FFI::NODE_CDATA
30
+ Leptris::XML::CDATA.new(c_ptr, document, parent: parent)
31
+ when Leptris::XML::FFI::NODE_PI
32
+ Leptris::XML::ProcessingInstruction.new(c_ptr, document, parent: parent)
33
+ else
34
+ new(c_ptr, document, parent: parent)
35
+ end
36
+
37
+ document.wrapper_cache[c_ptr.address] = node if document
38
+ node
39
+ end
40
+
41
+ def name
42
+ raise NotImplementedError, "#{self.class}#name not implemented"
43
+ end
44
+
45
+ def content
46
+ raise NotImplementedError, "#{self.class}#content not implemented"
47
+ end
48
+ alias_method :text, :content
49
+ alias_method :inner_text, :content
50
+
51
+ def type
52
+ Leptris::XML::FFI.leptris_node_get_type(@c_ptr)
53
+ end
54
+ alias_method :node_type, :type
55
+
56
+ def element?; type == Leptris::XML::FFI::NODE_ELEMENT; end
57
+ def text?; type == Leptris::XML::FFI::NODE_TEXT; end
58
+ def comment?; type == Leptris::XML::FFI::NODE_COMMENT; end
59
+ def cdata?; type == Leptris::XML::FFI::NODE_CDATA; end
60
+ def processing_instruction?
61
+ type == Leptris::XML::FFI::NODE_PI
62
+ end
63
+ alias_method :pi?, :processing_instruction?
64
+
65
+ def parent
66
+ return @parent if @parent
67
+ ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
68
+ return nil if ptr.null?
69
+ Leptris::XML::Node.wrap(ptr, @document)
70
+ end
71
+
72
+ def line
73
+ Leptris::XML::FFI.leptris_node_line(@c_ptr)
74
+ end
75
+
76
+ def <=>(other)
77
+ return nil unless other.is_a?(Leptris::XML::Node)
78
+ return nil unless @document == other.document
79
+ Leptris::XML::FFI.leptris_node_compare(@c_ptr, other.c_ptr)
80
+ end
81
+
82
+ def child
83
+ ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
84
+ return nil if ptr.null?
85
+ Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
86
+ end
87
+
88
+ def children
89
+ nodes = []
90
+ ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
91
+ until ptr.nil? || ptr.null?
92
+ nodes << Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
93
+ ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
94
+ end
95
+ Leptris::XML::NodeSet.new(@document, nodes)
96
+ end
97
+
98
+ def next_sibling
99
+ ptr = Leptris::XML::FFI.leptris_node_next_sibling(@c_ptr)
100
+ return nil if ptr.null?
101
+ Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
102
+ end
103
+ alias_method :next, :next_sibling
104
+
105
+ def previous_sibling
106
+ ptr = Leptris::XML::FFI.leptris_node_previous_sibling(@c_ptr)
107
+ return nil if ptr.null?
108
+ Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
109
+ end
110
+ alias_method :previous, :previous_sibling
111
+
112
+ def first_element_child
113
+ ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
114
+ until ptr.nil? || ptr.null?
115
+ node = Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
116
+ return node if node.element?
117
+ ptr = Leptris::XML::FFI.leptris_node_next_sibling(ptr)
118
+ end
119
+ nil
120
+ end
121
+
122
+ def last_element_child
123
+ children.reverse_each.find(&:element?)
124
+ end
125
+
126
+ def element_children
127
+ children.select(&:element?)
128
+ end
129
+ alias_method :elements, :element_children
130
+
131
+ def next_element
132
+ sibling = next_sibling
133
+ sibling = sibling.next_sibling until sibling.nil? || sibling.element?
134
+ sibling
135
+ end
136
+
137
+ def previous_element
138
+ sibling = previous_sibling
139
+ sibling = sibling.previous_sibling until sibling.nil? || sibling.element?
140
+ sibling
141
+ end
142
+
143
+ def unlink
144
+ Leptris::XML::FFI.check_status(
145
+ Leptris::XML::FFI.leptris_node_unlink(@c_ptr))
146
+ @parent = nil
147
+ self
148
+ end
149
+ alias_method :remove, :unlink
150
+
151
+ # Walks the subtree in post-order DFS (matches Nokogiri's semantics).
152
+ #
153
+ # Specialized hot path: skips the intermediate NodeSet allocation that
154
+ # Element#children would create, walking via raw FFI calls and wrapping
155
+ # nodes directly. Saves one Array + one NodeSet allocation per parent
156
+ # node. For a tree of N nodes that's ~N fewer allocations on a full
157
+ # traversal.
158
+ #
159
+ # Still pays ~2 FFI calls per visited node (first_child + next_sibling).
160
+ # Beating Nokogiri on this benchmark needs C-side traverse with a
161
+ # callback (libleptris #273); the per-node FFI cost is the floor.
162
+ def traverse
163
+ return enum_for(:traverse) unless block_given?
164
+ callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
165
+ yield Leptris::XML::Node.wrap(node_ptr, @document)
166
+ 0
167
+ end
168
+ Leptris::XML::FFI.leptris_node_traverse(
169
+ @c_ptr, Leptris::XML::FFI::TRAVERSE_POST_ORDER, callback, nil)
170
+ end
171
+
172
+ def path
173
+ str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
174
+ return nil if str_ptr.null?
175
+ Leptris::XML::FFI.read_owned_string(str_ptr)
176
+ end
177
+
178
+ def css_path
179
+ return nil if path.nil?
180
+ path.split("/").filter_map do |part|
181
+ next nil if part.empty?
182
+ part.gsub(/\[(\d+)\]/, ':nth-of-type(\1)')
183
+ end.join(" > ")
184
+ end
185
+
186
+ def dup
187
+ elem_ptr = Leptris::XML::FFI.leptris_node_as_element(@c_ptr)
188
+ raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
189
+ copy_ptr = Leptris::XML::FFI.leptris_element_copy(elem_ptr, @document.c_ptr)
190
+ raise Leptris::XML::Error, "leptris_element_copy failed" if copy_ptr.null?
191
+ Leptris::XML::Node.wrap(copy_ptr, @document)
192
+ end
193
+ alias_method :clone, :dup
194
+
195
+ def ==(other)
196
+ return false unless other.is_a?(Leptris::XML::Node)
197
+ @c_ptr == other.c_ptr
198
+ end
199
+
200
+ def inspect
201
+ "#<#{self.class.name} ptr=#{c_ptr}>"
202
+ end
203
+
204
+ protected
205
+
206
+ def as_element_or_self
207
+ is_a?(Leptris::XML::Element) ? self : nil
208
+ end
209
+
210
+ include Leptris::XML::Searchable
211
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ class Leptris::XML::NodeSet
6
+ include Enumerable
7
+ include Leptris::XML::Searchable
8
+
9
+ attr_reader :document
10
+
11
+ # Two construction modes:
12
+ # - eager: pass an Array of Nodes (e.g. Element#children builds one)
13
+ # - lazy: pass an FFI::Pointer to a LeptrisXPathResult that this NodeSet
14
+ # will keep alive and free on GC. Each [i] / each call goes
15
+ # through leptris_xpath_result_get instead of pre-materializing.
16
+ def initialize(document, source = nil)
17
+ @document = document
18
+ case source
19
+ when ::FFI::Pointer
20
+ if source.null?
21
+ @result_ptr = nil
22
+ @array = []
23
+ else
24
+ # Wrap in AutoPointer for automatic GC-time cleanup. NodeSet has no
25
+ # explicit #free method (Nokogiri doesn't either), so the AutoPointer
26
+ # double-free risk that Document#free hit doesn't apply here.
27
+ @result_ptr = ::FFI::AutoPointer.new(source, Leptris::XML::FFI.method(:leptris_xpath_result_free))
28
+ @array = nil
29
+ end
30
+ when nil
31
+ @result_ptr = nil
32
+ @array = []
33
+ else
34
+ @result_ptr = nil
35
+ @array = source.to_a
36
+ end
37
+ end
38
+
39
+ def self.from_result(document, result_ptr)
40
+ new(document, result_ptr)
41
+ end
42
+
43
+ def length
44
+ @result_ptr ? Leptris::XML::FFI.leptris_xpath_result_count(@result_ptr) : @array.length
45
+ end
46
+ alias_method :size, :length
47
+
48
+ def empty?
49
+ length == 0
50
+ end
51
+
52
+ def [](idx)
53
+ if @result_ptr
54
+ return nil if idx < 0 || idx >= length
55
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, idx)
56
+ return nil if ptr.null?
57
+ Leptris::XML::Node.wrap(ptr, @document)
58
+ else
59
+ @array[idx]
60
+ end
61
+ end
62
+
63
+ def each
64
+ return enum_for(:each) unless block_given?
65
+ if @result_ptr
66
+ # Batch-fetch all node pointers in one FFI call (leptris_xpath_result_get_nodes,
67
+ # libleptris v0.11.4) and wrap each. Saves N-1 FFI calls vs the per-index
68
+ # leptris_xpath_result_get loop. Wrappers are still cached per-Document via
69
+ # Node.wrap, so a re-iteration of the same NodeSet hits the cache.
70
+ n = length
71
+ if n > 0
72
+ buf = ::FFI::MemoryPointer.new(:pointer, n)
73
+ begin
74
+ copied = Leptris::XML::FFI.leptris_xpath_result_get_nodes(@result_ptr, buf, n)
75
+ copied.times do |i|
76
+ ptr = buf.get_pointer(i * ::FFI.type_size(:pointer))
77
+ next if ptr.null?
78
+ yield Leptris::XML::Node.wrap(ptr, @document)
79
+ end
80
+ # The batch accessor under-copies mixed-kind results (upstream
81
+ # leptris#477); fall back to per-index fetch for the remainder.
82
+ (copied...n).each do |i|
83
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
84
+ next if ptr.null?
85
+ yield Leptris::XML::Node.wrap(ptr, @document)
86
+ end
87
+ ensure
88
+ buf.free
89
+ end
90
+ end
91
+ else
92
+ @array.each { |n| yield n }
93
+ end
94
+ self
95
+ end
96
+
97
+ def to_a
98
+ return @array if @array
99
+ return [] if @result_ptr.nil?
100
+ out = []
101
+ each { |n| out << n }
102
+ @array = out # memoize so subsequent to_a / each avoids re-fetch
103
+ out
104
+ end
105
+ alias_method :to_ary, :to_a
106
+
107
+ def first(n = nil)
108
+ return self[0] if n.nil?
109
+ n.times.map { |i| self[i] }.take_while { |x| !x.nil? }
110
+ end
111
+
112
+ def last
113
+ if @result_ptr
114
+ self[length - 1]
115
+ else
116
+ @array.last
117
+ end
118
+ end
119
+
120
+ def inner_text
121
+ map(&:content).join
122
+ end
123
+ alias_method :text, :inner_text
124
+
125
+ def xpath(*paths)
126
+ handler, _ns, _vars = parse_search_args(paths)
127
+ raise ArgumentError, "custom XPath handlers not supported" if handler
128
+ expr = paths.join(" | ")
129
+ accumulated = Leptris::XML::NodeSet.new(@document)
130
+ each do |node|
131
+ next unless node.is_a?(Leptris::XML::Element)
132
+ result_ptr = Leptris::XML::FFI.leptris_xpath_eval(
133
+ @document.c_ptr, node.c_ptr, expr)
134
+ next if result_ptr.null?
135
+ sub = Leptris::XML::NodeSet.send(:from_result, @document, result_ptr)
136
+ accumulated = merge_node_sets(accumulated, sub)
137
+ end
138
+ accumulated
139
+ end
140
+
141
+ def inspect
142
+ to_a.inspect
143
+ end
144
+
145
+ private
146
+
147
+ def merge_node_sets(a, b)
148
+ Leptris::XML::NodeSet.new(@document, a.to_a + b.to_a)
149
+ end
150
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parser flags, mirroring LeptrisParseFlags in libleptris's public
4
+ # headers. Pass an instance as `Leptris::XML.parse(xml, options:)`.
5
+ class Leptris::XML::ParseOptions
6
+ # Discard whitespace-only text nodes (runs between tags that contain
7
+ # nothing but spaces/tabs/newlines). Matches libxml2's
8
+ # XML_PARSE_NOBLANKS and Nokogiri's noblanks. Documents parsed this
9
+ # way do not round-trip pretty-printed formatting byte-for-byte.
10
+ NOBLANKS = Leptris::XML::FFI::LEPTRIS_PARSE_DROP_WS_TEXT
11
+
12
+ attr_reader :flags
13
+
14
+ def initialize(flags = Leptris::XML::FFI::LEPTRIS_PARSE_DEFAULT)
15
+ @flags = flags.to_i
16
+ end
17
+
18
+ def self.noblanks
19
+ new(NOBLANKS)
20
+ end
21
+
22
+ def noblanks?
23
+ @flags & NOBLANKS != 0
24
+ end
25
+
26
+ def |(other)
27
+ self.class.new(@flags | other.flags)
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
4
+ def name
5
+ Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr)
6
+ end
7
+ alias_method :target, :name
8
+
9
+ def content
10
+ Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s
11
+ end
12
+
13
+ def target=(new_target)
14
+ Leptris::XML::FFI.check_status(
15
+ Leptris::XML::FFI.leptris_pi_node_set_target(@c_ptr, new_target.to_s))
16
+ new_target
17
+ end
18
+
19
+ def data=(new_data)
20
+ Leptris::XML::FFI.check_status(
21
+ Leptris::XML::FFI.leptris_pi_node_set_data(@c_ptr, new_data.to_s))
22
+ new_data
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::SAX::Document
4
+ # Called when an XML declaration is parsed.
5
+ def xmldecl(version, encoding, standalone)
6
+ end
7
+
8
+ def start_document
9
+ end
10
+
11
+ def end_document
12
+ end
13
+
14
+ # Called at the beginning of an element.
15
+ # +attrs+ is an Array of [name, value] pairs in source order.
16
+ def start_element(name, attrs = [])
17
+ end
18
+
19
+ def end_element(name)
20
+ end
21
+
22
+ def characters(string)
23
+ end
24
+
25
+ def comment(string)
26
+ end
27
+
28
+ def cdata_block(string)
29
+ end
30
+
31
+ def processing_instruction(name, content)
32
+ end
33
+
34
+ def start_prefix_mapping(prefix, uri)
35
+ end
36
+
37
+ def end_prefix_mapping(prefix)
38
+ end
39
+
40
+ def warning(string)
41
+ end
42
+
43
+ def error(message, line = 0, column = 0)
44
+ end
45
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ class Leptris::XML::SAX::Parser
6
+ CHUNK_SIZE = 4096
7
+ private_constant :CHUNK_SIZE
8
+
9
+ attr_accessor :document, :encoding
10
+
11
+ def initialize(handler = Leptris::XML::SAX::Document.new, encoding = nil)
12
+ @document = handler
13
+ @encoding = encoding
14
+ end
15
+
16
+ # Parse a string, IO, or file path. Dispatches to parse_memory /
17
+ # parse_io / parse_file based on the argument type.
18
+ def parse(input)
19
+ case input
20
+ when String then parse_memory(input)
21
+ when ->(x) { x.respond_to?(:read) } then parse_io(input)
22
+ else
23
+ raise ArgumentError, "SAX parser expects a String or IO, got #{input.class}"
24
+ end
25
+ end
26
+
27
+ def parse_memory(string)
28
+ string = string.dup.force_encoding("UTF-8")
29
+ handler_struct = build_handler_struct
30
+ rc = Leptris::XML::FFI.leptris_sax_parse(
31
+ string, string.bytesize, handler_struct.pointer, nil)
32
+ if rc != 0
33
+ raise Leptris::XML::ParseError,
34
+ "leptris_sax_parse failed (rc=#{rc})"
35
+ end
36
+ self
37
+ end
38
+
39
+ def parse_io(io)
40
+ handler_struct = build_handler_struct
41
+ parser_ptr = Leptris::XML::FFI.leptris_sax_parser_create(
42
+ handler_struct.pointer, nil)
43
+ if parser_ptr.null?
44
+ raise Leptris::XML::Error, "leptris_sax_parser_create failed"
45
+ end
46
+ begin
47
+ while (chunk = io.read(CHUNK_SIZE))
48
+ rc = Leptris::XML::FFI.leptris_sax_parser_feed(
49
+ parser_ptr, chunk, chunk.bytesize, 0)
50
+ if rc != 0
51
+ raise Leptris::XML::ParseError, "leptris_sax_parser_feed failed (rc=#{rc})"
52
+ end
53
+ end
54
+ # Final flush
55
+ rc = Leptris::XML::FFI.leptris_sax_parser_feed(parser_ptr, "", 0, 1)
56
+ if rc != 0
57
+ raise Leptris::XML::ParseError, "leptris_sax_parser_feed (final) failed (rc=#{rc})"
58
+ end
59
+ ensure
60
+ Leptris::XML::FFI.leptris_sax_parser_free(parser_ptr)
61
+ end
62
+ self
63
+ end
64
+
65
+ def parse_file(path)
66
+ File.open(path, "r") { |f| parse_io(f) }
67
+ end
68
+
69
+ private
70
+
71
+ # Build a LeptrisSAXHandler struct populated with FFI::Function callbacks
72
+ # that dispatch to the Ruby handler. The struct (and its callbacks) are
73
+ # anchored against GC via the local variable for the duration of the
74
+ # synchronous parse call.
75
+ def build_handler_struct
76
+ s = Leptris::XML::FFI::SAXHandler.new
77
+ handler = @document # capture in closures
78
+
79
+ s[:start_document] = callback(:void, [:pointer]) do |_|
80
+ handler.start_document
81
+ end
82
+
83
+ s[:end_document] = callback(:void, [:pointer]) do |_|
84
+ handler.end_document
85
+ end
86
+
87
+ s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, attrs_ptr|
88
+ attrs = walk_attr_array(attrs_ptr)
89
+ handler.start_element(name, attrs)
90
+ end
91
+
92
+ s[:end_element] = callback(:void, [:pointer, :string]) do |_, name|
93
+ handler.end_element(name)
94
+ end
95
+
96
+ s[:characters] = callback(:void, [:pointer, :pointer, :size_t]) do |_, text_ptr, len|
97
+ handler.characters(text_ptr.read_bytes(len).force_encoding("UTF-8"))
98
+ end
99
+
100
+ s[:comment] = callback(:void, [:pointer, :string]) do |_, comment|
101
+ handler.comment(comment)
102
+ end
103
+
104
+ s[:cdata] = callback(:void, [:pointer, :string]) do |_, cdata|
105
+ handler.cdata_block(cdata)
106
+ end
107
+
108
+ s[:processing_instruction] = callback(:void, [:pointer, :string, :string]) do |_, target, data|
109
+ handler.processing_instruction(target, data)
110
+ end
111
+
112
+ s[:start_prefix_mapping] = callback(:void, [:pointer, :string, :string]) do |_, prefix, uri|
113
+ handler.start_prefix_mapping(prefix, uri)
114
+ end
115
+
116
+ s[:end_prefix_mapping] = callback(:void, [:pointer, :string]) do |_, prefix|
117
+ handler.end_prefix_mapping(prefix)
118
+ end
119
+
120
+ s[:error] = callback(:void, [:pointer, :string, :int, :int]) do |_, msg, line, col|
121
+ handler.error(msg, line, col)
122
+ end
123
+
124
+ s
125
+ end
126
+
127
+ def callback(return_type, params, blocking: true, &block)
128
+ ::FFI::Function.new(return_type, params, blocking: blocking, &block)
129
+ end
130
+
131
+ # The C `const char** attrs` is a NULL-terminated flat array of
132
+ # name/value pairs; CStringArray owns the format, we slice into pairs.
133
+ def walk_attr_array(attrs_ptr)
134
+ Leptris::XML::CStringArray.to_ruby(attrs_ptr).each_slice(2).to_a
135
+ end
136
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Leptris
6
+ module XML
7
+ module SAX
8
+ autoload :Document, "leptris/xml/sax/document"
9
+ autoload :Parser, "leptris/xml/sax/parser"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leptris::XML::Searchable
4
+ def xpath(*paths)
5
+ handler, _ns, _vars = parse_search_args(paths)
6
+ raise ArgumentError, "custom XPath handlers not supported" if handler
7
+ expr = paths.join(" | ")
8
+
9
+ doc_ptr = is_a?(Leptris::XML::Document) ? c_ptr : document.c_ptr
10
+ context_ptr = is_a?(Leptris::XML::Document) ? nil : c_ptr
11
+
12
+ result_ptr = Leptris::XML::FFI.leptris_xpath_eval(doc_ptr, context_ptr, expr)
13
+ if result_ptr.null?
14
+ raise Leptris::XML::XPathError,
15
+ Leptris::XML::FFI.leptris_status_string(Leptris::XML::FFI::LEPTRIS_ERROR_XPATH)
16
+ end
17
+
18
+ wrap_xpath_result(result_ptr)
19
+ end
20
+
21
+ def at_xpath(*paths)
22
+ result = xpath(*paths)
23
+ result.is_a?(Leptris::XML::NodeSet) ? result.first : result
24
+ end
25
+
26
+ def search(*args)
27
+ paths = args.first.is_a?(Array) ? args.first : [args.first]
28
+ paths.map(&:to_s).all? { |p| looks_like_xpath?(p) } ? xpath(*paths) : css(*paths)
29
+ end
30
+ alias_method :/, :search
31
+
32
+ def at(*args)
33
+ result = search(*args)
34
+ result.is_a?(Leptris::XML::NodeSet) ? result.first : result
35
+ end
36
+ alias_method :%, :at
37
+
38
+ def css(*args)
39
+ handler, ns, _ = parse_search_args(args)
40
+ raise ArgumentError, "namespace bindings not supported in css" if ns && !ns.empty?
41
+ raise ArgumentError, "custom CSS handlers not supported" if handler
42
+ expr = args.map { |r| Leptris::XML::CssToXPath.convert(r) }.join(" | ")
43
+ xpath(expr)
44
+ end
45
+
46
+ def at_css(*args)
47
+ result = css(*args)
48
+ result.is_a?(Leptris::XML::NodeSet) ? result.first : result
49
+ end
50
+
51
+ protected
52
+
53
+ def parse_search_args(args)
54
+ handler = args.find { |a| !a.is_a?(String) && !a.is_a?(Hash) && !a.is_a?(Symbol) }
55
+ args = args - [handler] if handler
56
+ hashes = []
57
+ while args.last.is_a?(Hash) || args.last.nil?
58
+ hashes << args.pop
59
+ break if args.empty?
60
+ end
61
+ ns, vars = hashes.reverse
62
+ [handler, ns, vars]
63
+ end
64
+
65
+ def looks_like_xpath?(str)
66
+ %r{\A(\./|/|\.\.|\.)}.match?(str)
67
+ end
68
+
69
+ def wrap_xpath_result(result_ptr)
70
+ type = Leptris::XML::FFI.leptris_xpath_result_type(result_ptr)
71
+ case type
72
+ when Leptris::XML::FFI::XPATH_NODESET
73
+ Leptris::XML::NodeSet.send(:from_result, document, result_ptr)
74
+ when Leptris::XML::FFI::XPATH_BOOLEAN
75
+ v = Leptris::XML::FFI.leptris_xpath_result_boolean(result_ptr) != 0
76
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
77
+ v
78
+ when Leptris::XML::FFI::XPATH_NUMBER
79
+ v = Leptris::XML::FFI.leptris_xpath_result_number(result_ptr)
80
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
81
+ v
82
+ when Leptris::XML::FFI::XPATH_STRING
83
+ str_ptr = Leptris::XML::FFI.leptris_xpath_result_string(result_ptr)
84
+ v = Leptris::XML::FFI.read_owned_string(str_ptr)
85
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
86
+ v
87
+ else
88
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
89
+ raise Leptris::XML::XPathError, "unknown xpath result type #{type}"
90
+ end
91
+ end
92
+ end