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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +619 -0
- data/CLAUDE.md +104 -0
- data/LICENSE.md +33 -0
- data/README.adoc +405 -0
- data/Rakefile +98 -0
- data/TODO.impl/01-architecture.md +217 -0
- data/TODO.impl/02-ffi-declarations.md +236 -0
- data/TODO.impl/03-document-node-element-nodeset.md +382 -0
- data/TODO.impl/04-sax-parser.md +203 -0
- data/TODO.impl/05-serialize-c14n-memory-specs-css.md +276 -0
- data/benchmark/README.md +168 -0
- data/benchmark/leptris_vs_nokogiri.rb +105 -0
- data/docs/ARCHITECTURE.adoc +559 -0
- data/docs/BUILD.md +395 -0
- data/docs/ERROR_MESSAGES.md +458 -0
- data/docs/FFI_ARCHITECTURE.md +439 -0
- data/docs/FUTURE_VISION.md +303 -0
- data/docs/GITHUB_ACTIONS.md +293 -0
- data/docs/OPTIMIZATIONS_IMPLEMENTED.adoc +459 -0
- data/docs/PERFORMANCE.adoc +668 -0
- data/docs/PERFORMANCE.md +448 -0
- data/docs/RELEASE_NOTES_v1.0.0.md +515 -0
- data/docs/XPATH_SPEC_COMPLIANCE.md +298 -0
- data/docs/completion/leptris.bash +86 -0
- data/docs/completion/leptris.zsh +74 -0
- data/docs/man/leptris-format.1 +227 -0
- data/docs/man/leptris-parse.1 +178 -0
- data/docs/man/leptris-xpath.1 +312 -0
- data/docs/man/leptris.1 +160 -0
- data/docs/v0.9.0_PERFORMANCE_IMPROVEMENTS.md +217 -0
- data/docs/v0.9.0_RELEASE_SUMMARY.md +281 -0
- data/docs/v1.0.0_CONTINUATION_PLAN.md +172 -0
- data/docs/v1.0.0_CONTINUATION_PROMPT.md +382 -0
- data/docs/v1.0.0_SESSION_6_CONTINUATION.md +434 -0
- data/docs/v1.0.0_SESSION_6_PROMPT.md +231 -0
- data/docs/v1.0.0_STATUS_TRACKER.md +224 -0
- data/docs/v1.1.0_CONTINUATION_PLAN.md +299 -0
- data/docs/v1.1.0_FINAL_CONTINUATION_PLAN.md +201 -0
- data/docs/v1.1.0_SESSION_3_PROMPT.md +223 -0
- data/docs/v1.1.0_STATUS_TRACKER.md +355 -0
- data/docs/xml-performance.adoc +115 -0
- data/docs/xpath-performance.adoc +379 -0
- data/leptris.gemspec +42 -0
- data/lib/leptris/version.rb +5 -0
- data/lib/leptris/xml/attr.rb +41 -0
- data/lib/leptris/xml/c_string_array.rb +37 -0
- data/lib/leptris/xml/cdata.rb +15 -0
- data/lib/leptris/xml/comment.rb +15 -0
- data/lib/leptris/xml/css_to_xpath.rb +177 -0
- data/lib/leptris/xml/doc_type.rb +54 -0
- data/lib/leptris/xml/document.rb +212 -0
- data/lib/leptris/xml/document_fragment.rb +42 -0
- data/lib/leptris/xml/element.rb +259 -0
- data/lib/leptris/xml/ffi.rb +475 -0
- data/lib/leptris/xml/namespace.rb +43 -0
- data/lib/leptris/xml/node.rb +211 -0
- data/lib/leptris/xml/node_set.rb +150 -0
- data/lib/leptris/xml/parse_options.rb +29 -0
- data/lib/leptris/xml/processing_instruction.rb +24 -0
- data/lib/leptris/xml/sax/document.rb +45 -0
- data/lib/leptris/xml/sax/parser.rb +136 -0
- data/lib/leptris/xml/sax.rb +12 -0
- data/lib/leptris/xml/searchable.rb +92 -0
- data/lib/leptris/xml/serialization.rb +41 -0
- data/lib/leptris/xml/text.rb +15 -0
- data/lib/leptris/xml.rb +40 -0
- data/lib/leptris.rb +7 -0
- data/lib/libleptris.so +0 -0
- 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,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
|