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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2528 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. 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].