leptris 1.1.2-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/CHANGELOG.md +619 -0
  5. data/CLAUDE.md +104 -0
  6. data/LICENSE.md +33 -0
  7. data/README.adoc +405 -0
  8. data/Rakefile +98 -0
  9. data/TODO.impl/01-architecture.md +217 -0
  10. data/TODO.impl/02-ffi-declarations.md +236 -0
  11. data/TODO.impl/03-document-node-element-nodeset.md +382 -0
  12. data/TODO.impl/04-sax-parser.md +203 -0
  13. data/TODO.impl/05-serialize-c14n-memory-specs-css.md +276 -0
  14. data/benchmark/README.md +168 -0
  15. data/benchmark/leptris_vs_nokogiri.rb +105 -0
  16. data/docs/ARCHITECTURE.adoc +559 -0
  17. data/docs/BUILD.md +395 -0
  18. data/docs/ERROR_MESSAGES.md +458 -0
  19. data/docs/FFI_ARCHITECTURE.md +439 -0
  20. data/docs/FUTURE_VISION.md +303 -0
  21. data/docs/GITHUB_ACTIONS.md +293 -0
  22. data/docs/OPTIMIZATIONS_IMPLEMENTED.adoc +459 -0
  23. data/docs/PERFORMANCE.adoc +668 -0
  24. data/docs/PERFORMANCE.md +448 -0
  25. data/docs/RELEASE_NOTES_v1.0.0.md +515 -0
  26. data/docs/XPATH_SPEC_COMPLIANCE.md +298 -0
  27. data/docs/completion/leptris.bash +86 -0
  28. data/docs/completion/leptris.zsh +74 -0
  29. data/docs/man/leptris-format.1 +227 -0
  30. data/docs/man/leptris-parse.1 +178 -0
  31. data/docs/man/leptris-xpath.1 +312 -0
  32. data/docs/man/leptris.1 +160 -0
  33. data/docs/v0.9.0_PERFORMANCE_IMPROVEMENTS.md +217 -0
  34. data/docs/v0.9.0_RELEASE_SUMMARY.md +281 -0
  35. data/docs/v1.0.0_CONTINUATION_PLAN.md +172 -0
  36. data/docs/v1.0.0_CONTINUATION_PROMPT.md +382 -0
  37. data/docs/v1.0.0_SESSION_6_CONTINUATION.md +434 -0
  38. data/docs/v1.0.0_SESSION_6_PROMPT.md +231 -0
  39. data/docs/v1.0.0_STATUS_TRACKER.md +224 -0
  40. data/docs/v1.1.0_CONTINUATION_PLAN.md +299 -0
  41. data/docs/v1.1.0_FINAL_CONTINUATION_PLAN.md +201 -0
  42. data/docs/v1.1.0_SESSION_3_PROMPT.md +223 -0
  43. data/docs/v1.1.0_STATUS_TRACKER.md +355 -0
  44. data/docs/xml-performance.adoc +115 -0
  45. data/docs/xpath-performance.adoc +379 -0
  46. data/leptris.gemspec +42 -0
  47. data/lib/leptris/version.rb +5 -0
  48. data/lib/leptris/xml/attr.rb +41 -0
  49. data/lib/leptris/xml/c_string_array.rb +37 -0
  50. data/lib/leptris/xml/cdata.rb +15 -0
  51. data/lib/leptris/xml/comment.rb +15 -0
  52. data/lib/leptris/xml/css_to_xpath.rb +177 -0
  53. data/lib/leptris/xml/doc_type.rb +54 -0
  54. data/lib/leptris/xml/document.rb +212 -0
  55. data/lib/leptris/xml/document_fragment.rb +42 -0
  56. data/lib/leptris/xml/element.rb +259 -0
  57. data/lib/leptris/xml/ffi.rb +475 -0
  58. data/lib/leptris/xml/namespace.rb +43 -0
  59. data/lib/leptris/xml/node.rb +211 -0
  60. data/lib/leptris/xml/node_set.rb +150 -0
  61. data/lib/leptris/xml/parse_options.rb +29 -0
  62. data/lib/leptris/xml/processing_instruction.rb +24 -0
  63. data/lib/leptris/xml/sax/document.rb +45 -0
  64. data/lib/leptris/xml/sax/parser.rb +136 -0
  65. data/lib/leptris/xml/sax.rb +12 -0
  66. data/lib/leptris/xml/searchable.rb +92 -0
  67. data/lib/leptris/xml/serialization.rb +41 -0
  68. data/lib/leptris/xml/text.rb +15 -0
  69. data/lib/leptris/xml.rb +40 -0
  70. data/lib/leptris.rb +7 -0
  71. data/lib/libleptris.so +0 -0
  72. metadata +162 -0
data/CLAUDE.md ADDED
@@ -0,0 +1,104 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ `leptris-ruby` is a Ruby gem that wraps the native C library `libleptris` (built via CMake) via the `ffi` gem. It exposes an XML parser with complete XPath 1.0 support and a CLI. Target users want Nokogiri-like ergonomics with native speed.
8
+
9
+ - Native dep: `libleptris` shared library, built by `ext/leptris/extconf.rb` (CMake) and copied to `lib/libleptris.{dylib,so,dll}`.
10
+ - Ruby entry point: `lib/leptris.rb` (uses `require_relative` — see "Conventions" below).
11
+ - CLI: `bin/leptris` (Thor-based), defined in `lib/leptris/cli.rb`.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ # Build the C library and install it into lib/ for FFI to load
17
+ bundle exec rake compile # runs ext/leptris/extconf.rb (CMake)
18
+
19
+ # Run the full test suite (builds first)
20
+ bundle exec rake test # = spec, depends on :compile
21
+
22
+ # Run RSpec directly (skips build dependency)
23
+ bundle exec rspec # full suite
24
+ bundle exec rspec spec/leptris/document_spec.rb # one file
25
+ bundle exec rspec spec/leptris/document_spec.rb:42 # one example by line
26
+
27
+ # Lint
28
+ bundle exec rubocop
29
+
30
+ # Clean build artifacts (lib/libleptris.*, ext/leptris/build, Makefile, tmp, pkg)
31
+ bundle exec rake clean
32
+ ```
33
+
34
+ CI (`.github/workflows/test.yml`) runs `bundle exec rspec` + `bundle exec rubocop` on Ubuntu + macOS across Ruby 3.0–3.3.
35
+
36
+ ## Architecture: current state (v1.1.0)
37
+
38
+ ```
39
+ User Ruby code
40
+
41
+ Leptris.parse / Leptris.parse_file (lib/leptris.rb)
42
+ ↓ FFI call (leptris_parse) — one-shot tree copy
43
+ C document → FFI::Bridge.document_from_ptr (lib/leptris/ffi/bridge.rb)
44
+ ↓ recursive hydration
45
+ Ruby Document → Element → Node → NodeSet (lib/leptris/{document,element,node,node_set}.rb)
46
+ ```
47
+
48
+ Key directories:
49
+
50
+ - `lib/leptris.rb` — top-level module, `parse`, `parse_file`, `xpath_evaluate`, error classes (`ParseError`, `XPathError`, `EvaluationError`).
51
+ - `lib/leptris/ffi/` — FFI plumbing: `library.rb` (bindings), `types.rb` (constants), `memory.rb` (AutoPointer wrappers), `errors.rb` (thread-local error check), `bridge.rb` (C ptr → Ruby object).
52
+ - `lib/leptris/{document,element,node,node_set}.rb` — pure-Ruby tree model (full hydration on parse).
53
+ - `lib/leptris/xpath/` — pure-Ruby XPath engine (lexer, parser, compiler, VM). XPath DOES NOT go through C currently; `lib/leptris.rb#xpath_evaluate` calls `FFI.leptris_xpath_eval` only as a wrapper, but the result materialization in `FFI::Bridge` recursively re-walks via the Ruby tree.
54
+ - `lib/leptris/adapter*` — third-party format adapters.
55
+ - `spec/leptris/` — 250+ RSpec examples covering parser, XPath, namespaces, errors, ox-compatibility.
56
+ - `ext/leptris/` — CMake-based build of `libleptris` (sources come from the separate `leptris/leptris` repo at build time).
57
+
58
+ ## Architecture: planned rewrite (see `TODO.impl/`)
59
+
60
+ The five files under `TODO.impl/` describe a planned rewrite that has NOT been implemented yet. Read them before touching the Ruby/COM layer. Summary:
61
+
62
+ 1. **`01-architecture.md`** — Rewrite `leptris-ruby` as a **thin FFI wrapper** around libleptris v0.4.2 with a **Nokogiri-compatible API**. Current code does a one-shot C→Ruby tree copy and runs XPath in Ruby; planned code keeps the C DOM as the single source of truth (Ruby objects = handles wrapping opaque pointers), so every Ruby method = one FFI call and XPath/SAX go through the C engine.
63
+
64
+ 2. **`02-ffi-declarations.md`** — Complete FFI attachment: every public function in libleptris v0.4.2 (document lifecycle, node access, element queries/mutation, creation, text/comment/CDATA/PI access, XPath + variable set, SAX, serialization, `leptris_free_string`). Opaque typedefs: `document`, `element`, `node_ref`, `xpath_result`, `sax_parser`, `attribute`. Structs: `SAXHandler`, `SerializeOptions`. Constants for status codes, node types, XPath result types.
65
+
66
+ 3. **`03-document-node-element-nodeset.md`** — Target file layout:
67
+ ```
68
+ lib/leptris.rb
69
+ lib/leptris/xml.rb
70
+ lib/leptris/xml/{ffi,document,node,element,text,comment,cdata,
71
+ processing_instruction,attr,node_set,searchable,
72
+ parse_options}.rb
73
+ ```
74
+ `Node.wrap(ptr, doc)` dispatches on the C node type. Document is the only memory-owning object; Node/Element/Text/Comment/CDATA/PI/Attr are non-owning handles valid until `Document#free`.
75
+
76
+ 4. **`04-sax-parser.md`** — `Leptris::XML::SAX::{Parser, Document}` wrapping `leptris_sax_parse` / `leptris_sax_parser_feed`. FFI::Function callbacks for each event; `start_element` walks the NULL-terminated `const char**` attribute array. Streaming via incremental `feed`.
77
+
78
+ 5. **`05-serialize-c14n-memory-specs-css.md`** — `SerializeOptions` struct for `leptris_serialize_document`; `Document#canonicalize` (modes `C14N_1_0`, `C14N_1_1`, `C14N_EXCLUSIVE`); `UseAfterFreeError` guard; minimal CSS-to-XPath converter (`.class`, `#id`, `[attr]`, `[attr=val]`, `:first-child`, `:last-child`); spec layout under `spec/xml/{parse,document,node,element,node_set,xpath,sax,serialize,c14n,memory}_spec.rb`.
79
+
80
+ ### Memory ownership rules (planned)
81
+
82
+ | Ruby class | Owns C memory? | Free function |
83
+ |---|---|---|
84
+ | `Document` | YES | `leptris_document_free` |
85
+ | `Node`/`Element`/text/CDATA/PI/Attr | NO (borrowed) | freed transitively by Document |
86
+ | `NodeSet` (XPath result) | YES | `leptris_xpath_result_free` |
87
+ | SAX handler closures | callback lifetime | `leptris_sax_parser_free` |
88
+
89
+ GC safety net: `ObjectSpace.define_finalizer` capturing the **raw pointer value**, not the Ruby wrapper. Finalizers must not double-free after explicit `#free`.
90
+
91
+ ## Reference material
92
+
93
+ - Nokogiri source (`~/src/external/nokogiri/`) — `lib/nokogiri/xml/{node,node_set,document,searchable}.rb` are the API shape targets.
94
+ - libleptris public headers (`src/include/leptris/{types,leptris}.h`, `src/include/leptris/{dom,xpath,sax}/*.h`) — the single source of truth for FFI declarations. Target tag: `v0.4.2`.
95
+ - `docs/FFI_ARCHITECTURE.md` — describes the v0.5.0 FFI design (AutoPointer, two-pointer strategy for XPath). The planned rewrite supersedes some of this (no recursive hydration, no two-pointer — the Document pointer alone suffices because Node objects stay as C handles).
96
+ - `docs/BUILD.md` — CMake build reference for libleptris itself.
97
+
98
+ ## Conventions (project-specific)
99
+
100
+ - **Autoload, not require_relative.** TODO 3 is explicit: `lib/leptris.rb` → `autoload :XML, 'leptris/xml'`; `lib/leptris/xml.rb` → `autoload :Document, 'leptris/xml/document'`. Autoload entries live in the **immediate parent namespace's file** (create that file if missing). The current `lib/leptris.rb` uses `require_relative` — when implementing TODO 3, do not retrofit require_relative into the new layout.
101
+ - **No `instance_variable_set`/`_get` cross-object.** TODO 3 is explicit. The current `lib/leptris.rb` and `lib/leptris/ffi/bridge.rb` use `instance_variable_get(:@_c_ptr)` heavily — that pattern is debt to migrate, not a model to copy. In the rewrite, expose `c_ptr`/`document` as public `attr_reader`s and access via those.
102
+ - **No `respond_to?` type checks.** Use `is_a?`. The current `lib/leptris.rb#xpath_evaluate` checks `context_node != doc` to disambiguate — fine. Don't add `respond_to?(:c_ptr)` style checks.
103
+ - **No doubles in specs.** The existing `spec/leptris/` specs use real model instances (XML strings → `Leptris.parse` → real `Document`/`Element`/`NodeSet`). Keep it that way.
104
+ - **Forward compatibility:** Keep `Leptris.parse` / `Leptris.parse_file` as the existing top-level API during the rewrite. The new `Leptris::XML.parse` may coexist.
data/LICENSE.md ADDED
@@ -0,0 +1,33 @@
1
+ Licenses & Copyright
2
+ ====================
3
+
4
+ This license file adheres to the formatting guidelines of
5
+ [readable-licenses](https://github.com/nevir/readable-licenses).
6
+
7
+
8
+ Ribose's BSD 2-Clause License
9
+ -----------------------------
10
+
11
+ Copyright (c) 2025, [Ribose Inc](https://www.ribose.com).
12
+ All rights reserved.
13
+
14
+ Redistribution and use in source and binary forms, with or without modification,
15
+ are permitted provided that the following conditions are met:
16
+
17
+ 1. Redistributions of source code must retain the above copyright notice,
18
+ this list of conditions and the following disclaimer.
19
+
20
+ 2. Redistributions in binary form must reproduce the above copyright notice,
21
+ this list of conditions and the following disclaimer in the documentation
22
+ and/or other materials provided with the distribution.
23
+
24
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
28
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
29
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
30
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
31
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
32
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
33
+ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.adoc ADDED
@@ -0,0 +1,405 @@
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).
12
+
13
+ The C DOM is the single source of truth — Ruby objects are thin FFI
14
+ handles over the C pointers, so every Ruby method maps to one FFI call.
15
+ No tree hydration, no parallel Ruby-side model.
16
+
17
+ == Installation
18
+
19
+ Add to your Gemfile:
20
+
21
+ [source,ruby]
22
+ ----
23
+ gem "leptris"
24
+ ----
25
+
26
+ Then `bundle install`.
27
+
28
+ === Runtime requirement: libleptris
29
+
30
+ `leptris` shells out to the native `libleptris` shared library via FFI.
31
+ You need `libleptris.{dylib,so,dll}` installed on the host. Options:
32
+
33
+ 1. **Homebrew (macOS, easiest):** `brew install lutaml/tap/libleptris`
34
+ (if packaged) or build from source (see below).
35
+ 2. **Build from source** (Linux/macOS/Windows):
36
+ +
37
+ [source,shell]
38
+ ----
39
+ git clone https://github.com/leptris/leptris.git
40
+ cd leptris
41
+ cmake -B build -S . \
42
+ -DCMAKE_BUILD_TYPE=Release \
43
+ -DLEPTRIS_BUILD_SHARED=ON \
44
+ -DLEPTRIS_BUILD_STATIC=OFF \
45
+ -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON
46
+ cmake --build build -j
47
+ sudo cmake --install build # optional, system-wide
48
+ ----
49
+
50
+ 3. **Point Leptris at a specific path** by setting `LEPTRIS_LIB_PATH`:
51
+ +
52
+ [source,shell]
53
+ ----
54
+ export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylib
55
+ ----
56
+
57
+ If Leptris can't find the library at startup, every `parse` call raises
58
+ `LoadError`.
59
+
60
+ == Parsing
61
+
62
+ The top-level entry point is `Leptris::XML`. Parse a string or an IO:
63
+
64
+ [source,ruby]
65
+ ----
66
+ require "leptris"
67
+
68
+ doc = Leptris::XML.parse(<<~XML)
69
+ <library xmlns="http://example.org/ns">
70
+ <book id="b1" lang="en">
71
+ <title>Refactoring</title>
72
+ <author>Martin Fowler</author>
73
+ </book>
74
+ <book id="b2" lang="fr">
75
+ <title>Programmer en Ruby</title>
76
+ </book>
77
+ </library>
78
+ XML
79
+
80
+ doc.root.name # => "library"
81
+ doc.root.children.size # => 5 (2 element children + 3 whitespace text nodes)
82
+ ----
83
+
84
+ Or a file:
85
+
86
+ [source,ruby]
87
+ ----
88
+ doc = Leptris::XML.parse_file("books.xml")
89
+ ----
90
+
91
+ This is the direct Nokogiri equivalent of `Nokogiri::XML(...)`. The
92
+ returned object is a `Leptris::XML::Document`.
93
+
94
+ == Reading nodes
95
+
96
+ [horizontal]
97
+ `Document#root` :: root `Element`, or `nil` for an empty document.
98
+ `Node#name` :: element name (e.g. `"book"`).
99
+ `Node#content` (alias `#text`, `#inner_text`) :: all descendant text concatenated.
100
+ `Node#[]` (alias `#attr`, `#get_attribute`) :: attribute value by name.
101
+ `Node#attributes` :: hash of `{name => Attr}`.
102
+ `Node#key?` (alias `#has_attribute?`) :: attribute presence.
103
+ `Node#children` :: `NodeSet` of all children (elements, text, comments, …).
104
+ `Node#element_children` :: `NodeSet` of element children only.
105
+ `Node#first_element_child`, `#last_element_child` :: first/last element child (skip text nodes).
106
+ `Node#next_element`, `#previous_element` :: next/prev sibling element.
107
+ `Node#parent`, `#next_sibling`, `#previous_sibling` :: tree navigation.
108
+ `Node#line` :: 1-based source line number.
109
+ `Node#type` (alias `#node_type`) :: integer type code. Element predicates: `#element?`, `#text?`, `#comment?`, `#cdata?`, `#processing_instruction?`.
110
+
111
+ Example — walk all book titles:
112
+
113
+ [source,ruby]
114
+ ----
115
+ doc.root.children.select(&:element?).each do |book|
116
+ title = book.children.find { |c| c.element? && c.name == "title" }
117
+ puts "#{book[:id]}: #{title&.content}"
118
+ end
119
+ # b1: Refactoring
120
+ # b2: Programmer en Ruby
121
+ ----
122
+
123
+ === Tree iteration
124
+
125
+ `Node#traverse` walks the subtree in document order via a single C-side
126
+ callback (one FFI call for the whole traversal, not one per node):
127
+
128
+ [source,ruby]
129
+ ----
130
+ doc.root.traverse do |node|
131
+ case node
132
+ when Leptris::XML::Element then puts "E #{node.name}"
133
+ when Leptris::XML::Text then puts "T #{node.content.inspect}"
134
+ when Leptris::XML::Comment then puts "C #{node.content.inspect}"
135
+ end
136
+ end
137
+ ----
138
+
139
+ == Searching: XPath and CSS
140
+
141
+ `Document` and `Element` (via `Leptris::XML::Searchable`) support:
142
+
143
+ [horizontal]
144
+ `#xpath(*exprs)` :: evaluate XPath; returns `NodeSet`, `true`/`false`, `Float`, or `String` depending on the expression.
145
+ `#at_xpath(*exprs)` :: first match (or scalar), like `xpath(*exprs).first`.
146
+ `#css(*selectors)` :: minimal CSS-to-XPath translation, then `xpath`.
147
+ `#at_css(*selectors)` :: first match of `css`.
148
+ `#search(*exprs)` :: dispatches on syntax — `/`-prefixed or `,`-separated → `xpath`, otherwise `css`.
149
+ `#at(*exprs)` :: first match of `search`.
150
+
151
+ [source,ruby]
152
+ ----
153
+ doc.xpath("//book") # => NodeSet of both <book>
154
+ doc.xpath("count(//book)") # => 2.0
155
+ doc.xpath("//book[@lang='fr']/title") # => NodeSet[<title>Programmer en Ruby</title>]
156
+ doc.at_xpath("//book[@id='b1']") # => <book id="b1" ...>
157
+ doc.at_xpath("string(//book[1]/@id)") # => "b1"
158
+
159
+ doc.css("book[lang='en'] title") # => NodeSet[<title>Refactoring</title>]
160
+ doc.at_css("book#b1 title") # => <title>Refactoring</title> (id selector)
161
+ doc.css("book:first-child") # first <book>
162
+ ----
163
+
164
+ XPath result type follows XPath 1.0 semantics:
165
+ `count(...)` → `Float`, `boolean(...)` → `true`/`false`,
166
+ `string(...)` → `String`, otherwise a `Leptris::XML::NodeSet`.
167
+
168
+ === Supported CSS selectors
169
+
170
+ Minimal subset (translated to XPath via `Leptris::XML::CssToXPath`):
171
+
172
+ * Type/universal: `book`, `*`
173
+ * Class/ID: `.highlight`, `#b1`
174
+ * Attribute presence: `[lang]`
175
+ * Attribute value: `[lang='en']`, `[lang~='en']`, `[lang^='en']`, `[lang$='en']`, `[lang*='en']`
176
+ * Combinators: descendant (space), child (`>`), comma (multi-selector)
177
+ * Pseudo-classes: `:first-child`, `:last-child`, `:only-child`, `:empty`, `:root`, `:not(...)`
178
+
179
+ For anything more sophisticated, drop down to `xpath`.
180
+
181
+ == Building and mutating
182
+
183
+ Documents expose factory methods; elements expose mutation methods:
184
+
185
+ [source,ruby]
186
+ ----
187
+ doc = Leptris::XML.parse("<root/>")
188
+ book = doc.create_element("book")
189
+ book[:id] = "b3"
190
+ book.add_child(doc.create_element("title")).content = "New book"
191
+ doc.root.add_child(book)
192
+
193
+ puts doc.to_xml
194
+ # <?xml version="1.0"?>
195
+ # <root><book id="b3"><title>New book</title></book></root>
196
+ ----
197
+
198
+ [horizontal]
199
+ `Document#create_element(name)` :: detached element owned by the document.
200
+ `Document#create_text_node(str)`, `#create_comment(str)`, `#create_cdata(str)` :: text-class factories.
201
+ `Document#create_processing_instruction(target, data)` :: PI factory.
202
+ `Document#fragment(markup)` :: parse a markup fragment (multiple top-level children allowed).
203
+ `Element#name=`, `#content=` :: rename / replace inner text.
204
+ `Element#[]=` (alias `#set_attribute`) :: add/update an attribute.
205
+ `Element#remove_attribute` (alias `#delete`) :: drop an attribute.
206
+ `Element#add_child(node_or_markup)` (alias `#<<`) :: append a Node, or parse+append a markup String.
207
+ `Element#prepend_child(node)` :: insert as the first child.
208
+ `Element#add_next_sibling(node)`, `#add_previous_sibling(node)` :: sibling insertion.
209
+ `Element#remove_child(node)` :: detach (does not free).
210
+ `Element#children=` :: replace all children.
211
+ `Element#replace(node)` / `#swap(node)` :: replace in parent.
212
+ `Element#wrap(node_or_markup)` :: wrap this element in a new one.
213
+ `Node#unlink` :: detach from the tree.
214
+
215
+ === Building from scratch (no parse)
216
+
217
+ [source,ruby]
218
+ ----
219
+ # Create an empty Document by parsing a sentinel and replacing the root,
220
+ # or build incrementally on a one-element seed.
221
+ doc = Leptris::XML.parse("<root/>")
222
+ doc.root.name = "catalog"
223
+ # ... then create_element / add_child as above.
224
+ ----
225
+
226
+ == Namespaces
227
+
228
+ [horizontal]
229
+ `Element#namespace` :: the element's in-scope namespace as a `Namespace` (or `nil`).
230
+ `Element#namespaces` :: all in-scope namespaces (inherited from ancestors) as a `{prefix_or_xmlns => href}` hash.
231
+ `Element#namespace_definitions` :: only namespaces declared directly on this element.
232
+ `Element#add_namespace_definition(prefix, href)` (alias `#add_namespace`) :: declare `xmlns:prefix="href"` on this element.
233
+ `Element#default_namespace=(href)` :: declare/replace `xmlns="href"`.
234
+ `Element#remove_namespace_definition(prefix)` :: drop a declaration.
235
+
236
+ [source,ruby]
237
+ ----
238
+ root = doc.root
239
+ root.add_namespace_definition("t", "https://example.org/types")
240
+ puts root.namespaces
241
+ # {"xmlns"=>"http://example.org/ns", "xmlns:t"=>"https://example.org/types"}
242
+
243
+ # XPath with prefixes is dispatched straight to libleptris, which resolves
244
+ # prefixes using the in-scope namespace declarations.
245
+ doc.xpath("//t:title")
246
+ ----
247
+
248
+ == Serialization and canonicalization
249
+
250
+ [horizontal]
251
+ `Document#to_xml(indent: 0, no_decl: false, encoding: nil)` (aliases `#to_s`, `#serialize`) :: serialize the whole document.
252
+ `Element#to_xml(...)` :: serialize a subtree.
253
+ `Document#save(path, **opts)` :: serialize to a file.
254
+ `Document#canonicalize(version, inclusive_ns, with_comments:, exclusive:, mode:)` (alias `#c14n`) :: canonical XML.
255
+ `Element#canonicalize(...)` :: subtree canonicalization.
256
+
257
+ [source,ruby]
258
+ ----
259
+ doc.to_xml # one-line, no indent
260
+ doc.to_xml(indent: 2) # pretty-printed
261
+ doc.canonicalize # C14N 1.0
262
+ doc.canonicalize(Leptris::XML::FFI::C14N_1_1) # C14N 1.1
263
+ doc.canonicalize(exclusive: true) # Exclusive C14N
264
+ doc.canonicalize(with_comments: true) # keep comments
265
+ doc.canonicalize(exclusive: true, inclusive_namespaces: ["ds"]) # InclusiveNamespaces
266
+ ----
267
+
268
+ == SAX parsing
269
+
270
+ For very large documents, use the streaming SAX parser. Subclass
271
+ `Leptris::XML::SAX::Document` and override the events you care about:
272
+
273
+ [source,ruby]
274
+ ----
275
+ class Counter < Leptris::XML::SAX::Document
276
+ attr_reader :elements, :depth
277
+ def initialize
278
+ @elements = 0
279
+ @depth = 0
280
+ end
281
+
282
+ def start_element(name, attrs = [])
283
+ @elements += 1
284
+ @depth += 1
285
+ puts " " * (@depth - 1) + "<#{name}>"
286
+ end
287
+
288
+ def end_element(name)
289
+ @depth -= 1
290
+ end
291
+
292
+ def characters(str)
293
+ puts " " * @depth + "text: #{str.inspect}" unless str.strip.empty?
294
+ end
295
+ end
296
+
297
+ parser = Leptris::XML::SAX::Parser.new(Counter.new)
298
+ parser.parse(File.open("huge.xml")) # streams in 4 KB chunks
299
+ ----
300
+
301
+ `SAX::Parser#parse` accepts a `String`, an `IO`, or any object responding
302
+ to `#read`. The handler callbacks are:
303
+
304
+ [horizontal]
305
+ `start_document`, `end_document` :: document boundaries.
306
+ `xmldecl(version, encoding, standalone)` :: XML declaration.
307
+ `start_element(name, attrs)`, `end_element(name)` :: element events; `attrs` is an array of `[name, value]` pairs in source order.
308
+ `characters(str)`, `comment(str)`, `cdata_block(str)` :: text-class events.
309
+ `processing_instruction(name, content)` :: PI event.
310
+ `start_prefix_mapping(prefix, uri)`, `end_prefix_mapping(prefix)` :: namespace events.
311
+ `warning(str)`, `error(msg, line, col)` :: recoverable parser messages.
312
+
313
+ == Memory model
314
+
315
+ `Document` is the only object that owns C memory. Everything else
316
+ (`Element`, `Text`, `Attr`, `NodeSet`, …) is a *borrowed handle* that
317
+ is valid only while its Document is alive.
318
+
319
+ * Free a document explicitly with `Document#free`. After `#free`, any
320
+ further method call on the document or its nodes raises
321
+ `Leptris::XML::UseAfterFreeError`.
322
+ * If you don't call `#free`, GC will — a finalizer captures the raw
323
+ pointer address (not the Ruby wrapper) and calls
324
+ `leptris_document_free` exactly once.
325
+ * `NodeSet`s holding XPath results own their own
326
+ `LeptrisXPathResult` and free it on GC.
327
+ * Don't hold a `Node` reference past the lifetime of its `Document`.
328
+ The C memory is gone; using the wrapper is undefined behaviour.
329
+
330
+ == Errors
331
+
332
+ All Leptris errors descend from `Leptris::XML::Error`:
333
+
334
+ [horizontal]
335
+ `ParseError` :: raised by `parse` / `parse_file` / SAX on malformed input.
336
+ `XPathError` :: raised by `xpath` on malformed or unsupported expressions.
337
+ `UseAfterFreeError` :: raised when calling methods on a freed `Document`.
338
+ `Error` :: generic (mutation precondition failures, etc.).
339
+
340
+ [source,ruby]
341
+ ----
342
+ begin
343
+ Leptris::XML.parse("<unclosed>")
344
+ rescue Leptris::XML::ParseError => e
345
+ warn "parse failed: #{e.message}"
346
+ end
347
+ ----
348
+
349
+ == Migrating from Nokogiri
350
+
351
+ For most read-only XPath use cases the swap is mechanical:
352
+
353
+ [source,ruby]
354
+ ----
355
+ # Nokogiri
356
+ require "nokogiri"
357
+ doc = Nokogiri::XML(File.read("doc.xml"))
358
+ doc.xpath("//item[@id='1']").each { |n| puts n.text }
359
+
360
+ # Leptris
361
+ require "leptris"
362
+ doc = Leptris::XML.parse(File.read("doc.xml"))
363
+ doc.xpath("//item[@id='1']").each { |n| puts n.content }
364
+ ----
365
+
366
+ Notable differences:
367
+
368
+ * `Node#text` exists but the canonical name is `#content` (Nokogiri uses both).
369
+ * `Node#children` includes whitespace text nodes (same as Nokogiri); use
370
+ `#element_children` or `#first_element_child` to skip them.
371
+ * CSS support is intentionally minimal — for advanced selectors, drop to
372
+ `xpath`.
373
+ * No `Nokogiri::HTML` or `Nokogiri::CSS` parser. Leptris is XML-only.
374
+ * No XSLT, no RelaxNG / DTD validation, no schema caching.
375
+ * No built-in JRuby / TruffleRuby support — only CRuby via `ffi`.
376
+
377
+ == Performance
378
+
379
+ On the benchmark suite in `benchmark/leptris_vs_nokogiri.rb` (Ruby 3.3,
380
+ libleptris v0.13+, macOS arm64), Leptris matches or beats Nokogiri on
381
+ parse, XPath, serialize, and full-tree traverse for the small and
382
+ medium documents that dominate real-world XML workloads. Run the
383
+ benchmark locally for numbers on your hardware:
384
+
385
+ [source,shell]
386
+ ----
387
+ bundle exec ruby benchmark/leptris_vs_nokogiri.rb
388
+ ----
389
+
390
+ == Development
391
+
392
+ [source,shell]
393
+ ----
394
+ bundle install # install Ruby deps
395
+ bundle exec rspec # full test suite (176 specs)
396
+ bundle exec rspec spec/xml/xpath_spec.rb:42 # one example by line
397
+ bundle exec rubocop # lint
398
+ ----
399
+
400
+ CI pins libleptris to a released tag (currently v1.1.1) and builds it
401
+ from source on each runner; see `.github/workflows/build.yml`.
402
+
403
+ == License
404
+
405
+ MIT — see link:LICENSE[LICENSE].
data/Rakefile ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
+ # with .github/workflows/build.yml (which calls `rake compile`) and the
10
+ # CHANGELOG when libleptris releases.
11
+ LIBLEPTRIS_VERSION = "1.1.1"
12
+
13
+ CMAKE_FLAGS = %w[
14
+ -DCMAKE_BUILD_TYPE=Release
15
+ -DLEPTRIS_BUILD_SHARED=ON
16
+ -DLEPTRIS_BUILD_STATIC=OFF
17
+ -DBUILD_TESTING=OFF
18
+ -DLEPTRIS_BUILD_CLI=OFF
19
+ -DLEPTRIS_BUILD_BENCHMARKS=OFF
20
+ -DLEPTRIS_BUILD_MAN_PAGES=OFF
21
+ -DLEPTRIS_ENABLE_UTF8PROC=OFF
22
+ -DLEPTRIS_ENABLE_ICONV=OFF
23
+ ].freeze
24
+
25
+ desc "Build libleptris #{LIBLEPTRIS_VERSION} from its release tarball into lib/"
26
+ task :compile do
27
+ version = ENV.fetch("LIBLEPTRIS_VERSION", LIBLEPTRIS_VERSION)
28
+ build = File.expand_path("tmp/libleptris-#{version}", __dir__)
29
+ rm_rf(build)
30
+ mkdir_p(build)
31
+ url = "https://api.github.com/repos/leptris/leptris/tarball/v#{version}"
32
+ sh "curl -sL #{url} | tar xz -C #{build} --strip-components=1"
33
+ sh "cmake -B #{build}/build -S #{build} #{CMAKE_FLAGS.join(' ')}"
34
+ sh "cmake --build #{build}/build --config Release -j 4"
35
+ # Windows names the shared library leptris.dll (no "lib" prefix);
36
+ # vendoring under the uniform libleptris.* name keeps the FFI
37
+ # search order simple.
38
+ lib = Dir.glob("#{build}/build/**/libleptris.{dylib,so}").first ||
39
+ Dir.glob("#{build}/build/**/leptris.dll").first
40
+ raise "libleptris shared library not found after build" unless lib
41
+ ext = File.extname(lib)
42
+ cp(lib, "lib/libleptris#{ext}")
43
+ puts "Vendored #{File.basename(lib)} as lib/libleptris#{ext}"
44
+ end
45
+
46
+ task spec: :compile unless ENV.key?("LEPTRIS_LIB_PATH")
47
+
48
+ task default: :spec
49
+
50
+ require "rubygems/package_task"
51
+
52
+ desc "Build the pure-Ruby gem"
53
+ task "gem:native:any" do
54
+ sh "rake platform:any gem"
55
+ end
56
+
57
+ desc "Define the gem task to build the pure-Ruby gem"
58
+ task "platform:any" do
59
+ spec = Gem::Specification::load("leptris.gemspec").dup
60
+ task = Gem::PackageTask.new(spec)
61
+ task.define
62
+ end
63
+
64
+ platforms = [
65
+ "x64-mingw32",
66
+ "x64-mingw-ucrt",
67
+ "aarch64-mingw-ucrt",
68
+ "x86_64-linux",
69
+ "x86_64-linux-musl",
70
+ "aarch64-linux",
71
+ "aarch64-linux-musl",
72
+ "x86_64-darwin",
73
+ "arm64-darwin",
74
+ ]
75
+
76
+ platforms.each do |platform|
77
+ desc "Build pre-compiled gem for the #{platform} platform"
78
+ task "gem:native:#{platform}" do
79
+ sh "rake compile platform:#{platform} gem"
80
+ end
81
+
82
+ desc "Define the gem task to build on the #{platform} platform (binary gem)"
83
+ task "platform:#{platform}" do
84
+ spec = Gem::Specification::load("leptris.gemspec").dup
85
+ spec.platform = Gem::Platform.new(platform)
86
+ spec.files += Dir.glob("lib/libleptris.{dll,so,dylib}")
87
+ task = Gem::PackageTask.new(spec)
88
+ task.define
89
+ end
90
+ end
91
+
92
+ require "rake/clean"
93
+
94
+ CLOBBER.include("pkg")
95
+ CLEAN.include("tmp",
96
+ "lib/libleptris.dll",
97
+ "lib/libleptris.dylib",
98
+ "lib/libleptris.so")