A Nokogiri-compatible Ruby binding for libleptris, a pure-C99 XML 1.0 parser with full XPath 1.0, XML Namespaces 1.0, SAX, and C14N (1.0 / 1.1 / Exclusive).
The C DOM is the single source of truth — Ruby objects are thin FFI handles over the C pointers, so every Ruby method maps to one FFI call. No tree hydration, no parallel Ruby-side model.
Installation
Add to your Gemfile:
gem "leptris"
Then bundle install.
Runtime requirement: libleptris
leptris shells out to the native libleptris shared library via FFI.
You need libleptris.{dylib,so,dll} installed on the host. Options:
-
Homebrew (macOS, easiest):
brew install lutaml/tap/libleptris(if packaged) or build from source (see below). -
Build from source (Linux/macOS/Windows):
git clone https://github.com/leptris/leptris.git cd leptris cmake -B build -S . \ -DCMAKE_BUILD_TYPE=Release \ -DLEPTRIS_BUILD_SHARED=ON \ -DLEPTRIS_BUILD_STATIC=OFF \ -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON cmake --build build -j sudo cmake --install build # optional, system-wide -
Point Leptris at a specific path by setting
LEPTRIS_LIB_PATH:export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylib
If Leptris can’t find the library at startup, every parse call raises
LoadError.
Parsing
The top-level entry point is Leptris::XML. Parse a string or an IO:
require "leptris"
doc = Leptris::XML.parse(<<~XML)
<library xmlns="http://example.org/ns">
<book id="b1" lang="en">
<title>Refactoring</title>
<author>Martin Fowler</author>
</book>
<book id="b2" lang="fr">
<title>Programmer en Ruby</title>
</book>
</library>
XML
doc.root.name # => "library"
doc.root.children.size # => 5 (2 element children + 3 whitespace text nodes)
Or a file:
doc = Leptris::XML.parse_file("books.xml")
This is the direct Nokogiri equivalent of Nokogiri::XML(…). The
returned object is a Leptris::XML::Document.
Malformed input raises Leptris::XML::ParseError. Parse with
recover: true to get libxml2’s XML_PARSE_RECOVER semantics
instead — an empty document back, with the failure recorded on the
thread-global last error:
Reading nodes
Document#root
|
root |
Node#name
|
element name (e.g. |
Node#content (alias #text, #inner_text)
|
all descendant text concatenated. |
Node#[] (alias #attr, #get_attribute)
|
attribute value by name. |
Node#attributes
|
hash of |
Node#key? (alias #has_attribute?)
|
attribute presence. |
Node#children
|
|
Node#element_children
|
|
Node#first_element_child, #last_element_child
|
first/last element child (skip text nodes). |
Node#next_element, #previous_element
|
next/prev sibling element. |
Node#parent, #next_sibling, #previous_sibling
|
tree navigation. |
Node#line
|
1-based source line number. |
Node#type (alias #node_type)
|
integer type code. Element predicates: |
Example — walk all book titles:
doc.root.children.select(&:element?).each do |book|
title = book.children.find { |c| c.element? && c.name == "title" }
puts "#{book[:id]}: #{title&.content}"
end
# b1: Refactoring
# b2: Programmer en Ruby
Tree iteration
Node#traverse walks the subtree in document order via a single C-side
callback (one FFI call for the whole traversal, not one per node):
Readonly mode
For the dominant parse-query-serialize workload, parse with
readonly: true (or call Document#readonly!, one-way):
Reads (name, content, children, attributes) memoize
aggressively — they cannot go stale because mutation is forbidden.
Every mutator raises Leptris::XML::ReadOnlyError. Detached factories
(create_element and friends) still work: building a new tree
against a readonly document is legal; mutating the frozen one is not.
Searching: XPath and CSS
Document, Element, and DocumentFragment (via
Leptris::XML::Searchable) support:
#xpath(*exprs)
|
evaluate XPath; returns |
#at_xpath(*exprs)
|
first match (or scalar), like |
#css(*selectors)
|
minimal CSS-to-XPath translation, then |
#at_css(*selectors)
|
first match of |
#search(*exprs)
|
dispatches on syntax — path-prefixed expressions ( |
#at(*exprs)
|
first match of |
doc.xpath("//book") # => NodeSet of both <book>
doc.xpath("count(//book)") # => 2.0
doc.xpath("//book[@lang='fr']/title") # => NodeSet[<title>Programmer en Ruby</title>]
doc.at_xpath("//book[@id='b1']") # => <book id="b1" ...>
doc.at_xpath("string(//book[1]/@id)") # => "b1"
doc.css("book[lang='en'] title") # => NodeSet[<title>Refactoring</title>]
doc.at_css("book#b1 title") # => <title>Refactoring</title> (id selector)
doc.css("book:first-child") # first <book>
# css is receiver-relative: scoped to the receiver, not the document
doc.root.at_css("book").css("title") # titles under THAT book only
frag = doc.fragment("<a x='1'><n/></a>")
frag.css("a > n") # searches the fragment
XPath result type follows XPath 1.0 semantics:
count(…) → Float, boolean(…) → true/false,
string(…) → String, otherwise a Leptris::XML::NodeSet.
Supported CSS selectors
Minimal subset (translated to XPath via Leptris::XML::CssToXPath):
-
Type/universal:
book,* -
Class/ID:
.highlight,#b1 -
Attribute presence:
[lang] -
Attribute value:
[lang='en'],[lang~='en'],[lang^='en'],[lang$='en'],[lang*='en'] -
Combinators: descendant (space), child (
>), comma (multi-selector) -
Pseudo-classes:
:first-child,:last-child,:only-child,:empty,:root,:not(…)
For anything more sophisticated, drop down to xpath.
Building and mutating
Documents expose factory methods; elements expose mutation methods:
Document#create_element(name)
|
detached element owned by the document. |
Document#create_text_node(str), #create_comment(str), #create_cdata(str)
|
text-class factories. |
Document#create_processing_instruction(target, data)
|
PI factory. |
Document#fragment(markup)
|
parse a markup fragment (multiple top-level children allowed). |
Element#name=, #content=
|
rename / replace inner text. |
Element#[]= (alias #set_attribute)
|
add/update an attribute. |
Element#remove_attribute (alias #delete)
|
drop an attribute. |
Element#add_child(node_or_markup) (alias #<<)
|
append a Node, or parse+append a markup String. |
Element#prepend_child(node)
|
insert as the first child. |
Element#add_next_sibling(node), #add_previous_sibling(node)
|
sibling insertion. |
Element#remove_child(node)
|
detach (does not free). |
Element#children=
|
replace all children. |
Element#replace(node) / #swap(node)
|
replace in parent. |
Element#wrap(node_or_markup)
|
wrap this element in a new one. |
Node#unlink
|
detach from the tree. |
Namespaces
Element#namespace
|
the element’s in-scope namespace as a |
Element#namespaces
|
all in-scope namespaces (inherited from ancestors) as a |
Element#namespace_definitions
|
only namespaces declared directly on this element. |
Element#add_namespace_definition(prefix, href) (alias #add_namespace)
|
declare |
Element#default_namespace=(href)
|
declare/replace |
Element#remove_namespace_definition(prefix)
|
drop a declaration. |
Element#attribute_ns(uri, local)
|
attribute value by expanded name (URI + local); nil URI matches no-namespace attributes only. |
Element#has_attribute_ns?(uri, local)
|
presence by expanded name. |
Attr#prefix
|
the attribute’s prefix as written ( |
Attr#namespace_uri
|
resolved through the owning element’s in-scope declarations at read time ( |
root = doc.root
root.add_namespace_definition("t", "https://example.org/types")
puts root.namespaces
# {"xmlns"=>"http://example.org/ns", "xmlns:t"=>"https://example.org/types"}
# XPath with prefixes is dispatched straight to libleptris, which resolves
# prefixes using the in-scope namespace declarations.
doc.xpath("//t:title")
Serialization and canonicalization
Document#to_xml(indent: 0, no_decl: false, encoding: nil) (aliases #to_s, #serialize)
|
serialize the whole document. |
Element#to_xml(…)
|
serialize a subtree. |
Document#save(path, **opts)
|
serialize to a file. |
Document#canonicalize(version, inclusive_ns, with_comments:, exclusive:, mode:) (alias #c14n)
|
canonical XML. |
Element#canonicalize(…)
|
subtree canonicalization. |
doc.to_xml # one-line, no indent
doc.to_xml(indent: 2) # pretty-printed
doc.canonicalize # C14N 1.0
doc.canonicalize(Leptris::XML::FFI::C14N_1_1) # C14N 1.1
doc.canonicalize(exclusive: true) # Exclusive C14N
doc.canonicalize(with_comments: true) # keep comments
doc.canonicalize(exclusive: true, inclusive_namespaces: ["ds"]) # InclusiveNamespaces
SAX parsing
For very large documents, use the streaming SAX parser. Subclass
Leptris::XML::SAX::Document and override the events you care about:
class Counter < Leptris::XML::SAX::Document
attr_reader :elements, :depth
def initialize
@elements = 0
@depth = 0
end
def start_element(name, attrs = [])
@elements += 1
@depth += 1
puts " " * (@depth - 1) + "<#{name}>"
end
def end_element(name)
@depth -= 1
end
def characters(str)
puts " " * @depth + "text: #{str.inspect}" unless str.strip.empty?
end
end
parser = Leptris::XML::SAX::Parser.new(Counter.new)
parser.parse(File.open("huge.xml")) # streams in 4 KB chunks
SAX::Parser#parse accepts a String, an IO, or any object responding
to #read. The handler callbacks are:
start_document, end_document
|
document boundaries. |
xmldecl(version, encoding, standalone)
|
XML declaration. |
start_element(name, attrs), end_element(name)
|
element events; |
characters(str), comment(str), cdata_block(str)
|
text-class events. |
processing_instruction(name, content)
|
PI event. |
start_prefix_mapping(prefix, uri), end_prefix_mapping(prefix)
|
namespace events. |
warning(str), error(msg, line, col)
|
recoverable parser messages. |
Memory model
Document is the only object that owns C memory. Everything else
(Element, Text, Attr, NodeSet, …) is a borrowed handle that
is valid only while its Document is alive.
-
Free a document explicitly with
Document#free. After#free, any further method call on the document or its nodes raisesLeptris::XML::UseAfterFreeError. -
If you don’t call
#free, GC will — a finalizer captures the raw pointer address (not the Ruby wrapper) and callsleptris_document_freeexactly once. -
NodeSet`s holding XPath results own their own `LeptrisXPathResultand free it on GC. -
Don’t hold a
Nodereference past the lifetime of itsDocument. The C memory is gone; using the wrapper is undefined behaviour.
Errors
All Leptris errors descend from Leptris::XML::Error:
ParseError
|
raised by |
XPathError
|
raised by |
UseAfterFreeError
|
raised when calling methods on a freed |
Error
|
generic (mutation precondition failures, etc.). |
Migrating from Nokogiri
For most read-only XPath use cases the swap is mechanical:
Notable differences:
-
Node#textexists but the canonical name is#content(Nokogiri uses both). -
Node#childrenincludes whitespace text nodes (same as Nokogiri); use#element_childrenor#first_element_childto skip them. -
CSS support is intentionally minimal — for advanced selectors, drop to
xpath.cssis receiver-relative (Nokogiri semantics): scoped to an element or fragment, document-wide from a Document. -
DocumentFragmentis searchable (fragment.xpath/at_xpath/css/at_css/search) — Nokogiri fragment parity. -
Expanded-name attribute access:
Element#attribute_ns(uri, local)/#has_attribute_ns?(uri, local)— XML Namespaces 1.0 semantics (cross-prefix match, nil URI matches no-namespace, xmlns invisible). -
Leptris::XML.parse(xml, recover: true)returns an empty document with the failure recorded on the thread-global last error instead of raising ParseError — libxml2XML_PARSE_RECOVERsemantics. The companionDocument#last_error_positionreturns[line, column]. -
Leptris::XML.parse(xml, readonly: true)(orDocument#readonly!) freezes the document for reading: mutations raise ReadOnlyError, read methods memoize aggressively. Faster steady state; no Nokogiri equivalent. -
Lifetime contract: a borrowed handle used after the owning document has been freed (or GC’d) raises
Leptris::XML::UseAfterFreeError. Nokogiri is silent on this — migrating code that holds Node references past Document disposal will see the error; silence-replace-UAF patterns from Nokogiri do not apply. -
No
Nokogiri::HTMLorNokogiri::CSSparser. Leptris is XML-only. -
No XSLT, no RelaxNG / DTD validation, no schema caching.
-
No built-in JRuby / TruffleRuby support — only CRuby via
ffi.
Performance
Internal read-path harness
benchmark/read_paths.rb measures the binding’s own hot paths
(readonly read loops, SAX, NodeSet unions, css, parse-query-
serialize). Machine-relative — compare before/after runs on the same
machine:
bundle exec ruby benchmark/read_paths.rb
Full-field comparison
Measured with metanorma/serialbench (Ruby 3.4.8, leptris 1.6.0, libleptris 1.6.0, macOS arm64) — Leptris vs the full field:
| Operation | Leptris | Ox | Nokogiri | vs Ox |
|---|---|---|---|---|
parse medium (300 KB) |
0.97 ms |
3.00 ms |
8.57 ms |
3.1x faster |
parse large (4.4 MB) |
13.51 ms |
77.06 ms |
104.22 ms |
5.7x faster |
generate medium |
1.75 ms |
14.19 ms |
23.93 ms |
8.1x faster |
generate large |
74.57 ms |
80.91 ms |
117.16 ms |
1.1x faster |
streaming medium |
0.73 ms |
103.82 ms |
43.74 ms |
142x faster |
streaming large |
8.52 ms |
204.14 ms |
239.14 ms |
24x faster |
Ruby allocations (medium) |
0.01 MB |
18.43 MB |
39.05 MB |
~1800x less |
Leptris takes first place on every XML operation above small-document size, including against Ox (the C-extension speed champion). On small-document micro-operations (sub-5 µs), C extensions hold an inherent edge per call; Leptris’s readonly mode closes the steady-state gap by memoizing reads behind an immutability guarantee.
For XPath-heavy workloads, Leptris::XML::XPath.compile evaluates a
parsed expression repeatedly without re-parsing.
Run the local benchmark for numbers on your hardware:
bundle exec ruby benchmark/leptris_vs_nokogiri.rb
Development
bundle install # install Ruby deps
bundle exec rspec # full test suite (229 specs)
bundle exec rspec spec/xml/xpath_spec.rb:42 # one example by line
bundle exec rubocop # lint
CI pins libleptris to a released tag (currently v1.1.1) and builds it
from source on each runner; see .github/workflows/build.yml.
License
MIT — see LICENSE.