leptris 1.9.199.0-arm-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/CHANGELOG.md +3890 -0
- data/CLAUDE.md +115 -0
- data/CONTEXT.md +39 -0
- data/LICENSE.md +33 -0
- data/README.adoc +755 -0
- data/Rakefile +407 -0
- data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
- data/TODO.perf/02-auto-enable-native-layer.md +19 -0
- data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
- data/TODO.perf/04-ext-bound-serialization.md +15 -0
- data/TODO.perf/05-native-surface-completion.md +19 -0
- data/TODO.perf/06-perf-battery-native-legs.md +16 -0
- data/TODO.perf/07-native-read-floor.md +31 -0
- data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
- data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
- data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
- data/TODO.perf/11-moxml-gap-battery.md +18 -0
- data/TODO.perf/12-document-lifetime-in-c.md +47 -0
- data/TODO.perf/13-structural-memos.md +49 -0
- data/TODO.perf/14-insert-family-c-bound.md +31 -0
- data/TODO.perf/15-compiled-expression-cache.md +35 -0
- data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
- data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
- data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
- data/TODO.perf/19-lazy-node-pointer.md +39 -0
- data/TODO.perf/20-css-translation-cache.md +24 -0
- data/TODO.perf/21-key-memo-consult.md +20 -0
- data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
- data/TODO.perf/23-cbound-value-mutations.md +27 -0
- data/TODO.perf/24-immutable-read-lanes.md +22 -0
- data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
- data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
- data/TODO.perf/27-c-yield-traversal.md +38 -0
- data/TODO.perf/28-address-fills.md +25 -0
- data/TODO.perf/29-ns-xpath-compiled.md +23 -0
- data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
- data/TODO.perf/31-battery-rounds-4-8.md +23 -0
- data/TODO.perf/32-clean-host-floor-table.md +29 -0
- data/TODO.perf/33-cbound-root-set.md +29 -0
- data/TODO.perf/34-fragment-fast-lane.md +41 -0
- data/TODO.perf/35-parse-default-c-face.md +24 -0
- data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
- data/TODO.perf/37-document-lazy-pointer.md +28 -0
- data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
- data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
- data/TODO.restructure/02-deep-copy-seam.md +21 -0
- data/TODO.restructure/03-evaluation-context-seam.md +18 -0
- data/TODO.restructure/04-spec-mece-restructure.md +20 -0
- data/TODO.restructure/05-memory-documentation.md +13 -0
- data/TODO.restructure/06-architecture-map-refresh.md +12 -0
- data/TODO.restructure/07-external-gates.md +16 -0
- data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
- data/TODO.restructure/09-iteration-scope.md +29 -0
- data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
- data/TODO.restructure/11-construction-factory-diet.md +16 -0
- data/TODO.restructure/12-resultattr-merge.md +12 -0
- data/TODO.restructure/13-spec-mece-followup.md +9 -0
- data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
- data/TODO.restructure/15-readme-feature-documentation.md +10 -0
- data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
- data/TODO.restructure/17-perf-drift-record.md +12 -0
- data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
- data/TODO.restructure/19-gitignore-hygiene.md +10 -0
- data/TODO.restructure/20-utf8proc-enablement.md +34 -0
- data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
- data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
- data/docs/adr/0001-lockstep-mirror.md +17 -0
- data/docs/adr/0002-utf8-at-the-seam.md +19 -0
- data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
- data/docs/adr/0004-lifetime-guard.md +21 -0
- data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
- data/docs/adr/0006-ruby-variant-policy.md +25 -0
- data/ext/build_windows_native.rb +42 -0
- data/ext/leptris/native/extconf.rb +55 -0
- data/ext/leptris/native/native.c +2528 -0
- data/leptris.gemspec +43 -0
- data/lib/leptris/version.rb +5 -0
- data/lib/leptris/xml/attr.rb +76 -0
- data/lib/leptris/xml/c_string_array.rb +37 -0
- data/lib/leptris/xml/cdata.rb +27 -0
- data/lib/leptris/xml/comment.rb +27 -0
- data/lib/leptris/xml/css_to_xpath.rb +192 -0
- data/lib/leptris/xml/descriptor.rb +214 -0
- data/lib/leptris/xml/diff.rb +74 -0
- data/lib/leptris/xml/doc_type.rb +54 -0
- data/lib/leptris/xml/document.rb +716 -0
- data/lib/leptris/xml/document_fragment.rb +63 -0
- data/lib/leptris/xml/element.rb +771 -0
- data/lib/leptris/xml/entity_reference.rb +13 -0
- data/lib/leptris/xml/evaluation_context.rb +32 -0
- data/lib/leptris/xml/ffi.rb +1527 -0
- data/lib/leptris/xml/iteration_scope.rb +56 -0
- data/lib/leptris/xml/iterparse.rb +144 -0
- data/lib/leptris/xml/namespace.rb +43 -0
- data/lib/leptris/xml/native.so +0 -0
- data/lib/leptris/xml/native_layer.rb +92 -0
- data/lib/leptris/xml/node.rb +686 -0
- data/lib/leptris/xml/node_set.rb +212 -0
- data/lib/leptris/xml/parse_options.rb +97 -0
- data/lib/leptris/xml/plan_value.rb +121 -0
- data/lib/leptris/xml/processing_instruction.rb +90 -0
- data/lib/leptris/xml/pull.rb +212 -0
- data/lib/leptris/xml/relaxng.rb +126 -0
- data/lib/leptris/xml/result_attr.rb +39 -0
- data/lib/leptris/xml/result_text.rb +36 -0
- data/lib/leptris/xml/sax/document.rb +45 -0
- data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
- data/lib/leptris/xml/sax/parser.rb +326 -0
- data/lib/leptris/xml/sax/recorder.rb +374 -0
- data/lib/leptris/xml/sax.rb +14 -0
- data/lib/leptris/xml/schematron.rb +88 -0
- data/lib/leptris/xml/searchable.rb +361 -0
- data/lib/leptris/xml/serialization.rb +180 -0
- data/lib/leptris/xml/text.rb +32 -0
- data/lib/leptris/xml/xpath.rb +89 -0
- data/lib/leptris/xml/xquery.rb +77 -0
- data/lib/leptris/xml/xslt.rb +85 -0
- data/lib/leptris/xml.rb +108 -0
- data/lib/leptris.rb +53 -0
- data/lib/libleptris.so +0 -0
- data/lib/libutf8proc.so.3 +0 -0
- data/scripts/gem_smoke.rb +36 -0
- metadata +214 -0
data/README.adoc
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
= leptris-ruby
|
|
2
|
+
:toc:
|
|
3
|
+
:toclevels: 3
|
|
4
|
+
|
|
5
|
+
image:https://img.shields.io/gem/v/leptris.svg[RubyGems Version,link=https://rubygems.org/gems/leptris]
|
|
6
|
+
image:https://github.com/leptris/leptris-ruby/actions/workflows/build.yml/badge.svg[CI,link=https://github.com/leptris/leptris-ruby/actions/workflows/build.yml]
|
|
7
|
+
|
|
8
|
+
A Nokogiri-compatible Ruby binding for
|
|
9
|
+
https://github.com/leptris/leptris[libleptris], a pure-C99 XML 1.0 parser
|
|
10
|
+
with full https://www.w3.org/TR/1999/REC-xpath-19991116/[XPath 1.0],
|
|
11
|
+
XML Namespaces 1.0, SAX, and C14N (1.0 / 1.1 / Exclusive), plus
|
|
12
|
+
XSLT 1.0–3.0 transforms, an XQuery 1.0 core face, and a growing
|
|
13
|
+
XPath 2/3.1 expression subset available standalone from `#xpath`
|
|
14
|
+
itself.
|
|
15
|
+
|
|
16
|
+
The C DOM is the single source of truth — Ruby objects are thin FFI
|
|
17
|
+
handles over the C pointers, so every Ruby method maps to one FFI call.
|
|
18
|
+
No tree hydration, no parallel Ruby-side model.
|
|
19
|
+
|
|
20
|
+
== Installation
|
|
21
|
+
|
|
22
|
+
Add to your Gemfile:
|
|
23
|
+
|
|
24
|
+
[source,ruby]
|
|
25
|
+
----
|
|
26
|
+
gem "leptris"
|
|
27
|
+
----
|
|
28
|
+
|
|
29
|
+
Then `bundle install`.
|
|
30
|
+
|
|
31
|
+
=== Runtime requirement: libleptris
|
|
32
|
+
|
|
33
|
+
`leptris` shells out to the native `libleptris` shared library via FFI.
|
|
34
|
+
You need `libleptris.{dylib,so,dll}` installed on the host. Options:
|
|
35
|
+
|
|
36
|
+
1. **Homebrew (macOS, easiest):** `brew install lutaml/tap/libleptris`
|
|
37
|
+
(if packaged) or build from source (see below).
|
|
38
|
+
2. **Build from source** (Linux/macOS/Windows):
|
|
39
|
+
+
|
|
40
|
+
[source,shell]
|
|
41
|
+
----
|
|
42
|
+
git clone https://github.com/leptris/leptris.git
|
|
43
|
+
cd leptris
|
|
44
|
+
cmake -B build -S . \
|
|
45
|
+
-DCMAKE_BUILD_TYPE=Release \
|
|
46
|
+
-DLEPTRIS_BUILD_SHARED=ON \
|
|
47
|
+
-DLEPTRIS_BUILD_STATIC=OFF \
|
|
48
|
+
-DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON
|
|
49
|
+
cmake --build build -j
|
|
50
|
+
sudo cmake --install build # optional, system-wide
|
|
51
|
+
----
|
|
52
|
+
|
|
53
|
+
3. **Point Leptris at a specific path** by setting `LEPTRIS_LIB_PATH`:
|
|
54
|
+
+
|
|
55
|
+
[source,shell]
|
|
56
|
+
----
|
|
57
|
+
export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylib
|
|
58
|
+
----
|
|
59
|
+
|
|
60
|
+
If Leptris can't find the library at startup, every `parse` call raises
|
|
61
|
+
`LoadError`.
|
|
62
|
+
|
|
63
|
+
=== Alternative engines: TruffleRuby and JRuby (#160) — zero setup
|
|
64
|
+
|
|
65
|
+
The `ruby`-platform variant (what TruffleRuby and JRuby resolve)
|
|
66
|
+
vendors precompiled `libleptris` + `libutf8proc` binaries for the
|
|
67
|
+
common engine platforms under `lib/leptris/vendor/`:
|
|
68
|
+
|
|
69
|
+
- `arm64-darwin`, `x86_64-darwin`
|
|
70
|
+
- `x86_64-linux`, `aarch64-linux` (glibc; the musl variants are
|
|
71
|
+
tried as fallback for Alpine hosts)
|
|
72
|
+
|
|
73
|
+
`gem install leptris` just works on both engines — the FFI layer
|
|
74
|
+
selects the matching vendored binary at require time (the binding
|
|
75
|
+
is FFI-based; no MRI C extension anywhere). `LEPTRIS_LIB_PATH`
|
|
76
|
+
still overrides, and a system library remains the fallback for
|
|
77
|
+
OSes the variant does not carry.
|
|
78
|
+
|
|
79
|
+
== Parsing
|
|
80
|
+
|
|
81
|
+
The top-level entry point is `Leptris::XML`. Parse a string or an IO:
|
|
82
|
+
|
|
83
|
+
[source,ruby]
|
|
84
|
+
----
|
|
85
|
+
require "leptris"
|
|
86
|
+
|
|
87
|
+
doc = Leptris::XML.parse(<<~XML)
|
|
88
|
+
<library xmlns="http://example.org/ns">
|
|
89
|
+
<book id="b1" lang="en">
|
|
90
|
+
<title>Refactoring</title>
|
|
91
|
+
<author>Martin Fowler</author>
|
|
92
|
+
</book>
|
|
93
|
+
<book id="b2" lang="fr">
|
|
94
|
+
<title>Programmer en Ruby</title>
|
|
95
|
+
</book>
|
|
96
|
+
</library>
|
|
97
|
+
XML
|
|
98
|
+
|
|
99
|
+
doc.root.name # => "library"
|
|
100
|
+
doc.root.children.size # => 5 (2 element children + 3 whitespace text nodes)
|
|
101
|
+
----
|
|
102
|
+
|
|
103
|
+
Or a file:
|
|
104
|
+
|
|
105
|
+
[source,ruby]
|
|
106
|
+
----
|
|
107
|
+
doc = Leptris::XML.parse_file("books.xml")
|
|
108
|
+
----
|
|
109
|
+
|
|
110
|
+
This is the direct Nokogiri equivalent of `Nokogiri::XML(...)`. The
|
|
111
|
+
returned object is a `Leptris::XML::Document`.
|
|
112
|
+
|
|
113
|
+
Malformed input raises `Leptris::XML::ParseError`. Parse with
|
|
114
|
+
`recover: true` to get libxml2's `XML_PARSE_RECOVER` semantics
|
|
115
|
+
instead — an empty document back, with the failure recorded on the
|
|
116
|
+
thread-global last error:
|
|
117
|
+
|
|
118
|
+
[source,ruby]
|
|
119
|
+
----
|
|
120
|
+
doc = Leptris::XML.parse("<broken", recover: true)
|
|
121
|
+
doc.root # => nil
|
|
122
|
+
Leptris::XML::FFI.leptris_last_error # => "..."
|
|
123
|
+
----
|
|
124
|
+
|
|
125
|
+
== HTML parsing
|
|
126
|
+
|
|
127
|
+
`Leptris::XML.parse_html(html)` parses tolerant HTML4/5 into a
|
|
128
|
+
standard Document — the same nodes, pool, serializer, and
|
|
129
|
+
XPath/XSLT/XQuery machinery as XML (libleptris 1.9.75, the last
|
|
130
|
+
Nokogiri capability gap):
|
|
131
|
+
|
|
132
|
+
[source,ruby]
|
|
133
|
+
----
|
|
134
|
+
doc = Leptris::XML.parse_html(%(<ul><li>a<li>b</ul>))
|
|
135
|
+
doc.at_css("body").inner_html # => "<ul><li>a</li><li>b</li></ul>"
|
|
136
|
+
----
|
|
137
|
+
|
|
138
|
+
Implied end tags (`p`/`li`/`td`/`tr`/...), void elements, raw-text
|
|
139
|
+
`<script>`/`<style>`, case-insensitive lowercased names,
|
|
140
|
+
minimized/unquoted attributes, and the HTML named entities all
|
|
141
|
+
parse; `html`/`body` are synthesized (an empty `head` is not) and
|
|
142
|
+
`tbody` is never implied. Malformed markup degrades to text rather
|
|
143
|
+
than raising.
|
|
144
|
+
|
|
145
|
+
== Reading nodes
|
|
146
|
+
|
|
147
|
+
[horizontal]
|
|
148
|
+
`Document#root` :: root `Element`, or `nil` for an empty document.
|
|
149
|
+
`Node#name` :: element name (e.g. `"book"`).
|
|
150
|
+
`Node#content` (alias `#text`, `#inner_text`) :: all descendant text concatenated.
|
|
151
|
+
`Node#[]` (alias `#attr`, `#get_attribute`) :: attribute value by name.
|
|
152
|
+
`Node#attributes` :: hash of `{name => Attr}`.
|
|
153
|
+
`Node#key?` (alias `#has_attribute?`) :: attribute presence.
|
|
154
|
+
`Node#children` :: `NodeSet` of all children (elements, text, comments, …).
|
|
155
|
+
`Node#element_children` :: `NodeSet` of element children only.
|
|
156
|
+
`Node#first_element_child`, `#last_element_child` :: first/last element child (skip text nodes).
|
|
157
|
+
`Node#next_element`, `#previous_element` :: next/prev sibling element.
|
|
158
|
+
`Node#parent`, `#next_sibling`, `#previous_sibling` :: tree navigation.
|
|
159
|
+
`Node#line` :: 1-based source line number.
|
|
160
|
+
`Node#type` (alias `#node_type`) :: integer type code. Element predicates: `#element?`, `#text?`, `#comment?`, `#cdata?`, `#processing_instruction?`.
|
|
161
|
+
|
|
162
|
+
Example — walk all book titles:
|
|
163
|
+
|
|
164
|
+
[source,ruby]
|
|
165
|
+
----
|
|
166
|
+
doc.root.children.select(&:element?).each do |book|
|
|
167
|
+
title = book.children.find { |c| c.element? && c.name == "title" }
|
|
168
|
+
puts "#{book[:id]}: #{title&.content}"
|
|
169
|
+
end
|
|
170
|
+
# b1: Refactoring
|
|
171
|
+
# b2: Programmer en Ruby
|
|
172
|
+
----
|
|
173
|
+
|
|
174
|
+
=== Tree iteration
|
|
175
|
+
|
|
176
|
+
`Node#visit` walks the subtree with ONE C call and C-tracked depth —
|
|
177
|
+
elements yield `(node, entering, depth)` enter/leave pairs, every other
|
|
178
|
+
kind yields once; the leanest full-subtree iteration the binding offers:
|
|
179
|
+
|
|
180
|
+
[source,ruby]
|
|
181
|
+
----
|
|
182
|
+
doc.root.visit do |node, entering, depth|
|
|
183
|
+
puts "#{' ' * depth}#{node.name} #{entering ? 'enter' : 'leave'}"
|
|
184
|
+
end
|
|
185
|
+
----
|
|
186
|
+
|
|
187
|
+
`Node#traverse` walks the subtree in post-order via a single C-side
|
|
188
|
+
callback (one FFI call for the whole traversal, not one per node):
|
|
189
|
+
|
|
190
|
+
[source,ruby]
|
|
191
|
+
----
|
|
192
|
+
doc.root.traverse do |node|
|
|
193
|
+
case node
|
|
194
|
+
when Leptris::XML::Element then puts "E #{node.name}"
|
|
195
|
+
when Leptris::XML::Text then puts "T #{node.content.inspect}"
|
|
196
|
+
when Leptris::XML::Comment then puts "C #{node.content.inspect}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
----
|
|
200
|
+
|
|
201
|
+
=== The document chain
|
|
202
|
+
|
|
203
|
+
`Document#node` is the document-level navigation head (the libxml2
|
|
204
|
+
model): `Document#children` reads `[prolog comments/PIs, the root,
|
|
205
|
+
epilog comments/PIs]` in document order, with whitespace kept per
|
|
206
|
+
libxml2's exact rule. Document-level PIs are first-class:
|
|
207
|
+
`Document#add_pi(target, data)` appends one, `Document#remove_pi(target_or_index)`
|
|
208
|
+
removes by target or index, and `PI#target=`/`PI#data=`/`PI#unlink`
|
|
209
|
+
mutate and detach. `Document#processing_instructions` and
|
|
210
|
+
`Document#comments` remain the memoized readers.
|
|
211
|
+
|
|
212
|
+
== Readonly mode
|
|
213
|
+
|
|
214
|
+
For the dominant parse-query-serialize workload, parse with
|
|
215
|
+
`readonly: true` (or call `Document#readonly!`, one-way):
|
|
216
|
+
|
|
217
|
+
[source,ruby]
|
|
218
|
+
----
|
|
219
|
+
doc = Leptris::XML.parse(xml, readonly: true)
|
|
220
|
+
doc.root.children.first["id"] # reads are memoized
|
|
221
|
+
doc.root.name # plain ivar after first call
|
|
222
|
+
doc.root << doc.create_element("x") # raises ReadOnlyError
|
|
223
|
+
----
|
|
224
|
+
|
|
225
|
+
Reads (`name`, `content`, `children`, `attributes`) memoize
|
|
226
|
+
aggressively — they cannot go stale because mutation is forbidden.
|
|
227
|
+
Every mutator raises `Leptris::XML::ReadOnlyError`. Detached factories
|
|
228
|
+
(`create_element` and friends) still work: building a *new* tree
|
|
229
|
+
against a readonly document is legal; mutating the frozen one is not.
|
|
230
|
+
|
|
231
|
+
== Searching: XPath and CSS
|
|
232
|
+
|
|
233
|
+
`Document`, `Element`, and `DocumentFragment` (via
|
|
234
|
+
`Leptris::XML::Searchable`) support:
|
|
235
|
+
|
|
236
|
+
[horizontal]
|
|
237
|
+
`#xpath(*exprs)` :: evaluate XPath; returns `NodeSet`, `true`/`false`, `Float`, or `String` depending on the expression.
|
|
238
|
+
`#at_xpath(*exprs)` :: first match (or scalar), like `xpath(*exprs).first`.
|
|
239
|
+
`#css(*selectors)` :: minimal CSS-to-XPath translation, then `xpath`. Receiver-relative (Nokogiri semantics): scoped to the element or fragment, document-wide from a Document.
|
|
240
|
+
`#at_css(*selectors)` :: first match of `css`.
|
|
241
|
+
`#search(*exprs)` :: dispatches on syntax — path-prefixed expressions (`/`, `.`, `..`) go to `xpath`; everything else translates as CSS (comma unions included).
|
|
242
|
+
`#at(*exprs)` :: first match of `search`.
|
|
243
|
+
|
|
244
|
+
[source,ruby]
|
|
245
|
+
----
|
|
246
|
+
doc.xpath("//book") # => NodeSet of both <book>
|
|
247
|
+
doc.xpath("count(//book)") # => 2.0
|
|
248
|
+
doc.xpath("//book[@lang='fr']/title") # => NodeSet[<title>Programmer en Ruby</title>]
|
|
249
|
+
doc.at_xpath("//book[@id='b1']") # => <book id="b1" ...>
|
|
250
|
+
doc.at_xpath("string(//book[1]/@id)") # => "b1"
|
|
251
|
+
|
|
252
|
+
doc.css("book[lang='en'] title") # => NodeSet[<title>Refactoring</title>]
|
|
253
|
+
doc.at_css("book#b1 title") # => <title>Refactoring</title> (id selector)
|
|
254
|
+
doc.css("book:first-child") # first <book>
|
|
255
|
+
|
|
256
|
+
# css is receiver-relative: scoped to the receiver, not the document
|
|
257
|
+
doc.root.at_css("book").css("title") # titles under THAT book only
|
|
258
|
+
frag = doc.fragment("<a x='1'><n/></a>")
|
|
259
|
+
frag.css("a > n") # searches the fragment
|
|
260
|
+
----
|
|
261
|
+
|
|
262
|
+
XPath result type follows XPath 1.0 semantics:
|
|
263
|
+
`count(...)` → `Float`, `boolean(...)` → `true`/`false`,
|
|
264
|
+
`string(...)` → `String`, otherwise a `Leptris::XML::NodeSet`.
|
|
265
|
+
|
|
266
|
+
Beyond XPath 1.0, the engine accepts a growing XPath 2/3.1
|
|
267
|
+
expression subset standalone — no stylesheet required:
|
|
268
|
+
|
|
269
|
+
[source,ruby]
|
|
270
|
+
----
|
|
271
|
+
doc.xpath("let $n := count(//book) return $n + 1") # => 3.0
|
|
272
|
+
doc.xpath("for $b in //book return count($b/*)") # sequence
|
|
273
|
+
doc.xpath("if (count(//book) = 2) then 'two' else '?'")
|
|
274
|
+
doc.xpath("count(1 to 4)") # => 4.0
|
|
275
|
+
doc.xpath("//book => count()") # 3.1 arrow
|
|
276
|
+
doc.xpath("//title ! string(.)") # 3.1 simple map
|
|
277
|
+
doc.xpath("'v' || count(//book)") # 3.1 concat
|
|
278
|
+
doc.xpath("'42' castable as xs:integer") # 2.0 type ops
|
|
279
|
+
doc.xpath("1.9 cast as xs:integer") # => 1.0 (truncates)
|
|
280
|
+
doc.xpath("//title instance of node()+") # 2.0 sequence types
|
|
281
|
+
doc.xpath("map { 'b': 'beta' }?b") # 3.1 maps
|
|
282
|
+
doc.xpath("[10, 20, 30]?2") # 3.1 arrays
|
|
283
|
+
doc.xpath(%q{serialize(parse-json('{"b":"beta"}'), map { 'method': 'json' })})
|
|
284
|
+
doc.xpath("function($x) { $x + 1 }(41)") # 3.1 function items
|
|
285
|
+
doc.xpath("fold-left(1 to 4, 0, function($a, $b) { $a + $b })")
|
|
286
|
+
doc.xpath("count(//a) eq 2") # 2.0 value comparators
|
|
287
|
+
doc.xpath("some $a in //a satisfies $a/@v > 1") # 2.0 quantifiers
|
|
288
|
+
doc.xpath("count(//a except //a[@v = 1])") # 2.0 set algebra
|
|
289
|
+
----
|
|
290
|
+
|
|
291
|
+
Sequence and constructor items arrive as `Leptris::XML::ResultText`
|
|
292
|
+
objects — `#content` serves the value directly:
|
|
293
|
+
|
|
294
|
+
[source,ruby]
|
|
295
|
+
----
|
|
296
|
+
doc.xpath("for $w in //a return string($w/@v)").map(&:content) # => ["1", "2"]
|
|
297
|
+
----
|
|
298
|
+
|
|
299
|
+
Remaining grammar gaps (https://github.com/leptris/leptris/issues/683[tracked
|
|
300
|
+
upstream]): 3.1 string templates, XQuery beyond the 1.0 core.
|
|
301
|
+
|
|
302
|
+
=== Supported CSS selectors
|
|
303
|
+
|
|
304
|
+
Minimal subset (translated to XPath via `Leptris::XML::CssToXPath`):
|
|
305
|
+
|
|
306
|
+
* Type/universal: `book`, `*`
|
|
307
|
+
* Class/ID: `.highlight`, `#b1`
|
|
308
|
+
* Attribute presence: `[lang]`
|
|
309
|
+
* Attribute value: `[lang='en']`, `[lang~='en']`, `[lang^='en']`, `[lang$='en']`, `[lang*='en']`
|
|
310
|
+
* Combinators: descendant (space), child (`>`), comma (multi-selector)
|
|
311
|
+
* Pseudo-classes: `:first-child`, `:last-child`, `:only-child`, `:empty`, `:root`, `:not(...)`
|
|
312
|
+
|
|
313
|
+
For anything more sophisticated, drop down to `xpath`.
|
|
314
|
+
|
|
315
|
+
== Building and mutating
|
|
316
|
+
|
|
317
|
+
Documents expose factory methods; elements expose mutation methods:
|
|
318
|
+
|
|
319
|
+
[source,ruby]
|
|
320
|
+
----
|
|
321
|
+
doc = Leptris::XML.parse("<root/>")
|
|
322
|
+
book = doc.create_element("book")
|
|
323
|
+
book[:id] = "b3"
|
|
324
|
+
book.add_child(doc.create_element("title")).content = "New book"
|
|
325
|
+
doc.root.add_child(book)
|
|
326
|
+
|
|
327
|
+
puts doc.to_xml
|
|
328
|
+
# <?xml version="1.0"?>
|
|
329
|
+
# <root><book id="b3"><title>New book</title></book></root>
|
|
330
|
+
----
|
|
331
|
+
|
|
332
|
+
[horizontal]
|
|
333
|
+
`Document#create_element(name)` :: detached element owned by the document.
|
|
334
|
+
`Document#create_text_node(str)`, `#create_comment(str)`, `#create_cdata(str)` :: text-class factories.
|
|
335
|
+
`Document#create_processing_instruction(target, data)` :: PI factory.
|
|
336
|
+
`Document#fragment(markup)` :: parse a markup fragment (multiple top-level children allowed).
|
|
337
|
+
`Element#name=`, `#content=` :: rename / replace inner text.
|
|
338
|
+
`Element#[]=` (alias `#set_attribute`) :: add/update an attribute.
|
|
339
|
+
`Element#remove_attribute` (alias `#delete`) :: drop an attribute.
|
|
340
|
+
`Element#add_child(node_or_markup)` (alias `#<<`) :: append a Node, or parse+append a markup String.
|
|
341
|
+
`Element#prepend_child(node)` :: insert as the first child.
|
|
342
|
+
`Element#add_next_sibling(node)`, `#add_previous_sibling(node)` :: sibling insertion.
|
|
343
|
+
`Element#remove_child(node)` :: detach (does not free).
|
|
344
|
+
`Element#children=` :: replace all children.
|
|
345
|
+
`Element#replace(node)` / `#swap(node)` :: replace in parent.
|
|
346
|
+
`Element#wrap(node_or_markup)` :: wrap this element in a new one.
|
|
347
|
+
`Node#unlink` :: detach from the tree.
|
|
348
|
+
|
|
349
|
+
=== Building from scratch (no parse)
|
|
350
|
+
|
|
351
|
+
[source,ruby]
|
|
352
|
+
----
|
|
353
|
+
# Create an empty Document by parsing a sentinel and replacing the root,
|
|
354
|
+
# or build incrementally on a one-element seed.
|
|
355
|
+
doc = Leptris::XML.parse("<root/>")
|
|
356
|
+
doc.root.name = "catalog"
|
|
357
|
+
# ... then create_element / add_child as above.
|
|
358
|
+
----
|
|
359
|
+
|
|
360
|
+
== Namespaces
|
|
361
|
+
|
|
362
|
+
[horizontal]
|
|
363
|
+
`Element#namespace` :: the element's in-scope namespace as a `Namespace` (or `nil`).
|
|
364
|
+
`Element#namespaces` :: all in-scope namespaces (inherited from ancestors) as a `{prefix_or_xmlns => href}` hash.
|
|
365
|
+
`Element#namespace_definitions` :: only namespaces declared directly on this element.
|
|
366
|
+
`Element#add_namespace_definition(prefix, href)` (alias `#add_namespace`) :: declare `xmlns:prefix="href"` on this element.
|
|
367
|
+
`Element#default_namespace=(href)` :: declare/replace `xmlns="href"`.
|
|
368
|
+
`Element#namespace=(uri)` :: Nokogiri `node.namespace=` semantics (libleptris 1.9.76): `nil` detaches — prefix clears, `xmlns=""` blocks in-scope defaults; a URI rebinds to an in-scope declaration carrying it (adopting its prefix; raises when none is in scope — declare first).
|
|
369
|
+
`Element#remove_namespace_definition(prefix)` :: drop a declaration.
|
|
370
|
+
`Element#attribute_ns(uri, local)` :: attribute value by expanded name (URI + local); nil URI matches no-namespace attributes only.
|
|
371
|
+
`Element#has_attribute_ns?(uri, local)` :: presence by expanded name.
|
|
372
|
+
`Attr#prefix` :: the attribute's prefix as written (`nil` when none).
|
|
373
|
+
`Attr#namespace_uri` :: resolved through the owning element's in-scope declarations at read time (`xml` prebound; `nil` for undeclared prefixes).
|
|
374
|
+
|
|
375
|
+
[source,ruby]
|
|
376
|
+
----
|
|
377
|
+
root = doc.root
|
|
378
|
+
root.add_namespace_definition("t", "https://example.org/types")
|
|
379
|
+
puts root.namespaces
|
|
380
|
+
# {"xmlns"=>"http://example.org/ns", "xmlns:t"=>"https://example.org/types"}
|
|
381
|
+
|
|
382
|
+
# XPath with prefixes is dispatched straight to libleptris, which resolves
|
|
383
|
+
# prefixes using the in-scope namespace declarations.
|
|
384
|
+
doc.xpath("//t:title")
|
|
385
|
+
----
|
|
386
|
+
|
|
387
|
+
== Serialization and canonicalization
|
|
388
|
+
|
|
389
|
+
[horizontal]
|
|
390
|
+
`Document#to_xml(indent: 0, no_decl: false, encoding: nil, indent_text: false)` (aliases `#to_s`, `#serialize`) :: serialize the whole document.
|
|
391
|
+
`Element#to_xml(...)` :: serialize a subtree (also takes `indent_text:` — the unit string).
|
|
392
|
+
|
|
393
|
+
`indent_text` carries two meanings: a STRING is the indent unit with
|
|
394
|
+
Nokogiri's semantics (the unit replaces the default spaces, repeated
|
|
395
|
+
`indent` times per depth level — byte-identical to Nokogiri's output);
|
|
396
|
+
`true` selects the display form, which also indents text and mixed
|
|
397
|
+
content:
|
|
398
|
+
|
|
399
|
+
[source,ruby]
|
|
400
|
+
----
|
|
401
|
+
doc.to_xml(indent: 2, indent_text: "\t") # tab-indented
|
|
402
|
+
doc.to_xml(indent: 2, indent_text: true) # display form (documents only)
|
|
403
|
+
----
|
|
404
|
+
|
|
405
|
+
`Element#inner_html` serializes the children with correct escaping —
|
|
406
|
+
well-formed by construction (a re-parse spec pins it).
|
|
407
|
+
|
|
408
|
+
`Element#to_xml(expand_empty: true)` emits `<a></a>` instead of `<a/>`
|
|
409
|
+
for empty elements — libxml2's `XML_SAVE_NO_EMPTY_TAGS` parity.
|
|
410
|
+
|
|
411
|
+
`Node#digest(drop_ws: false)` answers a content-defined 64-bit Merkle
|
|
412
|
+
hash of the subtree: equal flags and equal digests imply structural
|
|
413
|
+
equivalence (names, namespaces, sorted attributes, document-order
|
|
414
|
+
children); inequality implies nothing — descend and decide. Stable
|
|
415
|
+
across processes; `drop_ws: true` skips whitespace-only text nodes.
|
|
416
|
+
|
|
417
|
+
XSLT 1.0–3.0 transforms run through `Leptris::XML::XSLT`:
|
|
418
|
+
|
|
419
|
+
[source,ruby]
|
|
420
|
+
----
|
|
421
|
+
style = Leptris::XML::XSLT.parse(stylesheet_xml) # or .parse_file
|
|
422
|
+
result = style.apply_to(doc) # => Document
|
|
423
|
+
style.serialize(doc) # => String
|
|
424
|
+
----
|
|
425
|
+
|
|
426
|
+
The engine dispatches on the stylesheet's declared version. XSLT
|
|
427
|
+
1.0 is complete; the 3.0 instruction set has grown through
|
|
428
|
+
libleptris 1.9.36 — grouping, `xsl:accumulator`,
|
|
429
|
+
`xsl:analyze-string`, `xsl:mode/@on-no-match` dispositions,
|
|
430
|
+
`xsl:sequence`, `xsl:perform-sort`, and `fn:format-integer` — with
|
|
431
|
+
the XPath 3.1 core expressions available inside transforms (see
|
|
432
|
+
the subset list under <<Searching: XPath and CSS>>; value
|
|
433
|
+
comparators and sequence types are still out,
|
|
434
|
+
https://github.com/leptris/leptris/issues/683[tracked upstream]).
|
|
435
|
+
Known engine bug: the `shallow-skip` and `text-only-copy`
|
|
436
|
+
dispositions drop unmatched subtrees in the built-in initial
|
|
437
|
+
descent (https://github.com/leptris/leptris/issues/705[#705]) —
|
|
438
|
+
the other four dispositions are spec-correct. XQuery has no
|
|
439
|
+
entry point yet.
|
|
440
|
+
== Descriptor materialization
|
|
441
|
+
|
|
442
|
+
Compile a schema descriptor once, then materialize a whole subtree
|
|
443
|
+
against it in ONE native pass — no per-element Ruby calls (frameworks
|
|
444
|
+
rebuilding typed models from XML; libleptris 1.9.162):
|
|
445
|
+
|
|
446
|
+
[source,ruby]
|
|
447
|
+
----
|
|
448
|
+
descriptor = Leptris::XML::Descriptor.build(
|
|
449
|
+
name: "catalog",
|
|
450
|
+
attributes: [{ name: "version", kind: :scalar }],
|
|
451
|
+
children: [
|
|
452
|
+
{ name: "item", kind: :nested, plan: {
|
|
453
|
+
name: "item",
|
|
454
|
+
attributes: [{ name: "id", kind: :scalar }],
|
|
455
|
+
children: [
|
|
456
|
+
{ name: "name", kind: :scalar },
|
|
457
|
+
{ name: "price", kind: :scalar },
|
|
458
|
+
{ name: "opt", kind: :collection },
|
|
459
|
+
] } },
|
|
460
|
+
])
|
|
461
|
+
|
|
462
|
+
tree = descriptor.walk(doc.root).to_ruby
|
|
463
|
+
# { kind: :element, attributes: { "version" => "2.0" }, children: [
|
|
464
|
+
# { kind: :element, name: "item", attributes: { "id" => "1" },
|
|
465
|
+
# children: ["first", "1.99", ["a", "b"]] }, ...] }
|
|
466
|
+
----
|
|
467
|
+
|
|
468
|
+
Row kinds: `:scalar`, `:collection`, `:nested` (via `plan:`), `:raw`
|
|
469
|
+
(serialized subtree), `:content` (mixed-content text runs — pair with
|
|
470
|
+
`flags: [:mixed_content]`), `:callback` (raw value + document byte
|
|
471
|
+
offset + `type_tag` echo, matching `Node#byte_offset`). Namespace
|
|
472
|
+
binding lives on plans: `ns:` is `:none` (default), `:any`, or
|
|
473
|
+
`{ exact: "urn:..." }`; `flags:` also accepts `:cdata`, `:ordered`,
|
|
474
|
+
`:ns_lenient` (#754 out-of-namespace adoption). Children the plan
|
|
475
|
+
does not describe are skipped; undescribed document order within a
|
|
476
|
+
row is preserved. `#walk` returns a lazy `PlanValue` tree (the
|
|
477
|
+
result outlives the document); `#to_ruby` materializes it.
|
|
478
|
+
|
|
479
|
+
== XQuery
|
|
480
|
+
|
|
481
|
+
XQuery 1.0 core through `Leptris::XML::XQuery` (compile once,
|
|
482
|
+
evaluate many):
|
|
483
|
+
|
|
484
|
+
[source,ruby]
|
|
485
|
+
----
|
|
486
|
+
query = Leptris::XML::XQuery.parse(<<~XQ)
|
|
487
|
+
declare variable $min := 3;
|
|
488
|
+
for $i in //item
|
|
489
|
+
where number($i/@qty) > $min
|
|
490
|
+
order by $i/@qty descending
|
|
491
|
+
return <big>{$i/name/text()}</big>
|
|
492
|
+
XQ
|
|
493
|
+
query.eval(doc) # plain expressions keep their XPath result type;
|
|
494
|
+
# FLWOR results arrive as the sequence channel —
|
|
495
|
+
# read them through an aggregate until the engine
|
|
496
|
+
# materializes readable sequence items
|
|
497
|
+
----
|
|
498
|
+
|
|
499
|
+
Supported: the prolog (`declare variable` / `declare namespace` /
|
|
500
|
+
`declare function local:*`), nested `for` with `at` positions,
|
|
501
|
+
`let`, `where`, stable multi-key `order by`, `group by`, direct and
|
|
502
|
+
computed constructors with attribute value templates, and plain
|
|
503
|
+
XPath expression bodies. Known grammar gaps are
|
|
504
|
+
https://github.com/leptris/leptris/issues/790[tracked upstream].
|
|
505
|
+
|
|
506
|
+
`Leptris::XML.buffer_has_nonstandard_entity?(string)` is a
|
|
507
|
+
one-pass C pre-scan (for adapter layers): true when the buffer
|
|
508
|
+
contains a named entity outside the five predefined ones (or
|
|
509
|
+
numeric) — ~3x faster than the equivalent Ruby regex and no
|
|
510
|
+
false positives on bare `&`.
|
|
511
|
+
|
|
512
|
+
`Document#save(path, **opts)` :: serialize to a file.
|
|
513
|
+
`Document#canonicalize(version, inclusive_ns, with_comments:, exclusive:, mode:)` (alias `#c14n`) :: canonical XML.
|
|
514
|
+
`Element#canonicalize(...)` :: subtree canonicalization.
|
|
515
|
+
|
|
516
|
+
[source,ruby]
|
|
517
|
+
----
|
|
518
|
+
doc.to_xml # one-line, no indent
|
|
519
|
+
doc.to_xml(indent: 2) # pretty-printed
|
|
520
|
+
doc.canonicalize # C14N 1.0
|
|
521
|
+
doc.canonicalize(Leptris::XML::FFI::C14N_1_1) # C14N 1.1
|
|
522
|
+
doc.canonicalize(exclusive: true) # Exclusive C14N
|
|
523
|
+
doc.canonicalize(with_comments: true) # keep comments
|
|
524
|
+
doc.canonicalize(exclusive: true, inclusive_namespaces: ["ds"]) # InclusiveNamespaces
|
|
525
|
+
----
|
|
526
|
+
|
|
527
|
+
== SAX parsing
|
|
528
|
+
|
|
529
|
+
For very large documents, use the streaming SAX parser. Subclass
|
|
530
|
+
`Leptris::XML::SAX::Document` and override the events you care about:
|
|
531
|
+
|
|
532
|
+
[source,ruby]
|
|
533
|
+
----
|
|
534
|
+
class Counter < Leptris::XML::SAX::Document
|
|
535
|
+
attr_reader :elements, :depth
|
|
536
|
+
def initialize
|
|
537
|
+
@elements = 0
|
|
538
|
+
@depth = 0
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def start_element(name, attrs = [])
|
|
542
|
+
@elements += 1
|
|
543
|
+
@depth += 1
|
|
544
|
+
puts " " * (@depth - 1) + "<#{name}>"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def end_element(name)
|
|
548
|
+
@depth -= 1
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def characters(str)
|
|
552
|
+
puts " " * @depth + "text: #{str.inspect}" unless str.strip.empty?
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
parser = Leptris::XML::SAX::Parser.new(Counter.new)
|
|
557
|
+
parser.parse(File.open("huge.xml")) # streams in 4 KB chunks
|
|
558
|
+
----
|
|
559
|
+
|
|
560
|
+
`SAX::Parser#parse` accepts a `String`, an `IO`, or any object responding
|
|
561
|
+
to `#read`. The handler callbacks are:
|
|
562
|
+
|
|
563
|
+
[horizontal]
|
|
564
|
+
`start_document`, `end_document` :: document boundaries.
|
|
565
|
+
`xmldecl(version, encoding, standalone)` :: XML declaration.
|
|
566
|
+
`start_element(name, attrs)`, `end_element(name)` :: element events; `attrs` is an array of `[name, value]` pairs in source order.
|
|
567
|
+
`characters(str)`, `comment(str)`, `cdata_block(str)` :: text-class events.
|
|
568
|
+
`processing_instruction(name, content)` :: PI event.
|
|
569
|
+
`start_prefix_mapping(prefix, uri)`, `end_prefix_mapping(prefix)` :: namespace events.
|
|
570
|
+
`warning(str)`, `error(msg, line, col)` :: recoverable parser messages.
|
|
571
|
+
|
|
572
|
+
=== Transports: interest-proportional delivery
|
|
573
|
+
|
|
574
|
+
The parser picks its transport by what your handler overrides. Overriding
|
|
575
|
+
one hot kind (say `characters`) attaches only that callback — the engine
|
|
576
|
+
skips C-side emission for the rest entirely. Overriding several rides the
|
|
577
|
+
bulk recorder (one C call stages the whole document, then a lean dispatch
|
|
578
|
+
loop) — measured on a 1.9 MB document: text-only 21 ms, all-events 119 ms
|
|
579
|
+
vs Nokogiri's 130/131 ms. The handler cannot tell the transports apart;
|
|
580
|
+
xmlns declarations ride the attribute pairs and prefix-mapping events
|
|
581
|
+
fire alongside.
|
|
582
|
+
|
|
583
|
+
For raw bulk event streams, `SAX::Recorder.parse(xml, kinds:)` drains
|
|
584
|
+
buffered events per chunk — unwanted kinds cost one array read — and
|
|
585
|
+
`Recorder#reset` reuses one recorder across documents.
|
|
586
|
+
|
|
587
|
+
=== Pull parsing and iterparse
|
|
588
|
+
|
|
589
|
+
`Leptris::XML::Pull` is the StAX-style cursor: `Parser#each` delivers
|
|
590
|
+
`Event` structs with types including `:start_prefix`/`:end_prefix` (the
|
|
591
|
+
default namespace's prefix is `""`); `Parser#each_batch(max)` delivers
|
|
592
|
+
events in bulk with a corruption guard that fails loudly rather than
|
|
593
|
+
delivering garbage. `Leptris::XML::Iterparse.parse(xml, mode:)` yields
|
|
594
|
+
completed subtrees top-level (`:top_level`) or every element post-order
|
|
595
|
+
(`:full_document`) with bounded memory, plus `#namespace_uri` on the last
|
|
596
|
+
yielded element and an `#error` channel for truncated input. Yielded
|
|
597
|
+
elements carry an internal lifetime scope: `#document` answers nil (the
|
|
598
|
+
documented contract) while memoization and liveness guards engage —
|
|
599
|
+
using an element after the iteration raises `UseAfterFreeError` instead
|
|
600
|
+
of crashing, and repeated reads within the block ride the same fast
|
|
601
|
+
paths as document-backed nodes.
|
|
602
|
+
|
|
603
|
+
== Memory model
|
|
604
|
+
|
|
605
|
+
`Document` is the only object that owns C memory. Everything else
|
|
606
|
+
(`Element`, `Text`, `Attr`, `NodeSet`, …) is a *borrowed handle* that
|
|
607
|
+
is valid only while its Document is alive.
|
|
608
|
+
|
|
609
|
+
* Free a document explicitly with `Document#free`. After `#free`, any
|
|
610
|
+
further method call on the document or its nodes raises
|
|
611
|
+
`Leptris::XML::UseAfterFreeError`.
|
|
612
|
+
* If you don't call `#free`, GC will — a finalizer captures the raw
|
|
613
|
+
pointer address (not the Ruby wrapper) and calls
|
|
614
|
+
`leptris_document_free` exactly once.
|
|
615
|
+
* `NodeSet`s holding XPath results own their own
|
|
616
|
+
`LeptrisXPathResult` and free it on GC.
|
|
617
|
+
* Don't hold a `Node` reference past the lifetime of its `Document`.
|
|
618
|
+
The C memory is gone; using the wrapper is undefined behaviour.
|
|
619
|
+
|
|
620
|
+
== Memory behavior
|
|
621
|
+
|
|
622
|
+
Two facts worth knowing for memory-sensitive workloads:
|
|
623
|
+
|
|
624
|
+
**Finalizers drain asynchronously.** When a Document becomes
|
|
625
|
+
unreachable, its C tree frees when Ruby runs the registered
|
|
626
|
+
finalizer — and MRI executes finalizers on its own scheduling, not
|
|
627
|
+
synchronously inside `GC.start`. A tight `GC.start` loop can leave
|
|
628
|
+
the last document's wrappers (flagged `uncollectible`) alive
|
|
629
|
+
indefinitely; a short wall-clock yield drains the queue:
|
|
630
|
+
|
|
631
|
+
[source,ruby]
|
|
632
|
+
----
|
|
633
|
+
doc = Leptris::XML::Document.parse(xml)
|
|
634
|
+
doc.root.children # ...
|
|
635
|
+
doc = nil
|
|
636
|
+
GC.start
|
|
637
|
+
sleep 0.05 # yield to the finalizer queue
|
|
638
|
+
GC.start # now fully collected, C tree freed
|
|
639
|
+
----
|
|
640
|
+
|
|
641
|
+
**Held documents carry the Ruby wrapper layer.** A fully walked,
|
|
642
|
+
held document costs roughly +552 kB of wrapper objects on top of
|
|
643
|
+
the C tree's +456 kB (about 1.57x Nokogiri for the same held
|
|
644
|
+
shape) — the measured cost of the FFI-only, no-compile-at-install
|
|
645
|
+
architecture: every wrapped node is a small Ruby object holding an
|
|
646
|
+
`FFI::Pointer`. Parse-and-discard workloads are unaffected (the
|
|
647
|
+
tree is the dominant cost and the wrappers die young). See
|
|
648
|
+
https://github.com/leptris/leptris-ruby/issues/147[#147] for the
|
|
649
|
+
analysis and the open TypedData options.
|
|
650
|
+
|
|
651
|
+
== Errors
|
|
652
|
+
|
|
653
|
+
All Leptris errors descend from `Leptris::XML::Error`:
|
|
654
|
+
|
|
655
|
+
[horizontal]
|
|
656
|
+
`ParseError` :: raised by `parse` / `parse_file` / SAX on malformed input.
|
|
657
|
+
`XPathError` :: raised by `xpath` on malformed or unsupported expressions.
|
|
658
|
+
`UseAfterFreeError` :: raised when calling methods on a freed `Document`.
|
|
659
|
+
`Error` :: generic (mutation precondition failures, etc.).
|
|
660
|
+
|
|
661
|
+
[source,ruby]
|
|
662
|
+
----
|
|
663
|
+
begin
|
|
664
|
+
Leptris::XML.parse("<unclosed>")
|
|
665
|
+
rescue Leptris::XML::ParseError => e
|
|
666
|
+
warn "parse failed: #{e.message}"
|
|
667
|
+
end
|
|
668
|
+
----
|
|
669
|
+
|
|
670
|
+
== Migrating from Nokogiri
|
|
671
|
+
|
|
672
|
+
For most read-only XPath use cases the swap is mechanical:
|
|
673
|
+
|
|
674
|
+
[source,ruby]
|
|
675
|
+
----
|
|
676
|
+
# Nokogiri
|
|
677
|
+
require "nokogiri"
|
|
678
|
+
doc = Nokogiri::XML(File.read("doc.xml"))
|
|
679
|
+
doc.xpath("//item[@id='1']").each { |n| puts n.text }
|
|
680
|
+
|
|
681
|
+
# Leptris
|
|
682
|
+
require "leptris"
|
|
683
|
+
doc = Leptris::XML.parse(File.read("doc.xml"))
|
|
684
|
+
doc.xpath("//item[@id='1']").each { |n| puts n.content }
|
|
685
|
+
----
|
|
686
|
+
|
|
687
|
+
Notable differences:
|
|
688
|
+
|
|
689
|
+
* `Node#text` exists but the canonical name is `#content` (Nokogiri uses both).
|
|
690
|
+
* `Node#children` includes whitespace text nodes (same as Nokogiri); use
|
|
691
|
+
`#element_children` or `#first_element_child` to skip them.
|
|
692
|
+
* CSS support is intentionally minimal — for advanced selectors, drop to
|
|
693
|
+
`xpath`. `css` is receiver-relative (Nokogiri semantics): scoped to
|
|
694
|
+
an element or fragment, document-wide from a Document.
|
|
695
|
+
* `DocumentFragment` is searchable (`fragment.xpath/at_xpath/css/at_css/search`)
|
|
696
|
+
— Nokogiri fragment parity.
|
|
697
|
+
* Expanded-name attribute access: `Element#attribute_ns(uri, local)` /
|
|
698
|
+
`#has_attribute_ns?(uri, local)` — XML Namespaces 1.0 semantics
|
|
699
|
+
(cross-prefix match, nil URI matches no-namespace, xmlns invisible).
|
|
700
|
+
* `Leptris::XML.parse(xml, recover: true)` returns an empty document
|
|
701
|
+
with the failure recorded on the thread-global last error instead of
|
|
702
|
+
raising ParseError — libxml2 `XML_PARSE_RECOVER` semantics. The
|
|
703
|
+
companion `Document#last_error_position` returns `[line, column]`.
|
|
704
|
+
* `Leptris::XML.parse(xml, readonly: true)` (or `Document#readonly!`)
|
|
705
|
+
freezes the document for reading: mutations raise ReadOnlyError, read
|
|
706
|
+
methods memoize aggressively. Faster steady state; no Nokogiri
|
|
707
|
+
equivalent.
|
|
708
|
+
* **Lifetime contract**: a borrowed handle used after the owning
|
|
709
|
+
document has been freed (or GC'd) raises `Leptris::XML::UseAfterFreeError`.
|
|
710
|
+
Nokogiri is silent on this — migrating code that holds Node references
|
|
711
|
+
past Document disposal will see the error; silence-replace-UAF patterns
|
|
712
|
+
from Nokogiri do not apply.
|
|
713
|
+
* No `Nokogiri::CSS` parser. HTML parsing is supported
|
|
714
|
+
(`Leptris::XML.parse_html`, libleptris 1.9.75); Nokogiri's
|
|
715
|
+
HTML-specific node subclasses have no equivalent.
|
|
716
|
+
* No RelaxNG / DTD validation, no schema caching (XSLT 1.0–3.0
|
|
717
|
+
and an XQuery 1.0 core are supported — see above).
|
|
718
|
+
* No built-in JRuby / TruffleRuby support — only CRuby via `ffi`.
|
|
719
|
+
|
|
720
|
+
== Performance
|
|
721
|
+
|
|
722
|
+
Head-to-head against published Nokogiri 1.19.4 on a 1.86 MB /
|
|
723
|
+
250k-event document (arm64-darwin, CPU totals, best-of-5):
|
|
724
|
+
|
|
725
|
+
[horizontal]
|
|
726
|
+
DOM parse :: **10–12x faster**
|
|
727
|
+
CSS search :: **4.0–4.8x**
|
|
728
|
+
XPath nodeset :: **2.4–3.0x**
|
|
729
|
+
XPath scalar (`string(//item[1])`) :: **4.6x**
|
|
730
|
+
Serialization :: **2.2–2.3x**
|
|
731
|
+
`at_css` :: **1.4x**
|
|
732
|
+
SAX text-only handler :: **6x** (119 ms all-events vs Nokogiri's 131)
|
|
733
|
+
Memory held :: **17.5 MB/doc vs Nokogiri's 31.3** (1.8x lighter)
|
|
734
|
+
|
|
735
|
+
Two rows sit at the Ruby allocation floor by documented choice: the
|
|
736
|
+
cold full-tree walk (children recursion) remains ~1.5x behind
|
|
737
|
+
Nokogiri's C-extension node creation — `Node#visit` is the wrap-free
|
|
738
|
+
lever when that matters.
|
|
739
|
+
|
|
740
|
+
== Development
|
|
741
|
+
|
|
742
|
+
[source,shell]
|
|
743
|
+
----
|
|
744
|
+
bundle install # install Ruby deps
|
|
745
|
+
bundle exec rspec # full test suite (229 specs)
|
|
746
|
+
bundle exec rspec spec/xml/xpath_spec.rb:42 # one example by line
|
|
747
|
+
bundle exec rubocop # lint
|
|
748
|
+
----
|
|
749
|
+
|
|
750
|
+
CI pins libleptris to a released tag (currently v1.1.1) and builds it
|
|
751
|
+
from source on each runner; see `.github/workflows/build.yml`.
|
|
752
|
+
|
|
753
|
+
== License
|
|
754
|
+
|
|
755
|
+
MIT — see link:LICENSE[LICENSE].
|