Ruby — the leptris gem
A Nokogiri-compatible binding for libleptris v1.9.156. The C DOM is the single source of truth — Ruby objects are thin FFI handles over C pointers. No tree hydration, no parallel Ruby-side model.
Installation
gem "leptris"Since gem v1.9.156.0, precompiled platform gemsvendor libleptris for Linux (glibc and musl), macOS (x86_64/arm64), and Windows (mingw/ucrt): gem install leptris resolves the platform gem and works with no system library and no environment variables. The pure-Ruby gem remains the fallback.
Without a platform gem you need libleptris installed (see getting started), and — if it is not on the default loader path — point at it:
export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylibParsing
The top-level entry point is Leptris::XML — the direct equivalent of Nokogiri::XML(...). Parse a string, an IO, or a file:
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)
doc = Leptris::XML.parse_file("books.xml")Recover mode
Malformed input raises Leptris::XML::ParseError. Parse with recover: true for libxml2’sXML_PARSE_RECOVER semantics instead: an empty document back, the failure recorded on the thread-global last error, andDocument#last_error_position answering[line, column].
Reading nodes
| method | returns |
|---|---|
Document#root | root Element, or nil for an empty document. |
Node#name | element name. |
Node#content aliases: text, inner_text | all descendant text concatenated. |
Node#[] aliases: attr, get_attribute | attribute value by name. |
Node#attributes | hash of {name => Attr}. |
Node#key? alias: has_attribute? | attribute presence. |
Node#children | NodeSet of all children (elements, text, comments, …). |
Node#element_children | NodeSet of element children only. |
Node#first_element_child / #last_element_child | first/last element child (skips text nodes). |
Node#next_element / #previous_element | next/previous 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; predicates: #element?, #text?, #comment?, #cdata?, #processing_instruction?. |
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 RubyTree 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:
doc.root.traverse do |node|
case node
when Leptris::XML::Element then puts "E #{node.name}"
when Leptris::XML::Text then puts "T #{node.content.inspect}"
when Leptris::XML::Comment then puts "C #{node.content.inspect}"
end
endSearching — XPath and CSS
Document, Element, andDocumentFragment support (via Leptris::XML::Searchable):#xpath, #at_xpath, #css,#at_css, #search (dispatches on syntax), and#at.
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>Result types follow XPath 1.0 semantics:count(...) → Float,boolean(...) → true/false,string(...) → String, otherwise aLeptris::XML::NodeSet. Prefix XPath (//t:title) resolves against in-scope namespace declarations, dispatched straight to libleptris.
The CSS subset (translated via CssToXPath):
- Type/universal:
book,*; class/ID:.highlight,#b1 - Attribute presence and value:
[lang],[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. CSS is receiver-relative (Nokogiri semantics): element.css(...) and fragment.css(...) scope to the receiver; Document#css is document-wide.
EXSLT extensions
Since gem v1.9.156.0, doc.exslt enables the first-party extension pack — fifteen native C handlers, no interpreted callbacks:
doc.exslt # enable on this document
doc.xpath("str:tokenize(//summary, ', ')")
doc.xpath("set:difference(//a, //b)")
doc.xpath("math:max(//price)")str: replace / tokenize / split / concat / padding ·set: distinct / intersection / difference / leading / trailing ·math: max / min / abs / sqrt / power. Per-document — enabling costs nothing until an extension function is called.
Building and mutating
doc = Leptris::XML.parse("<root/>")
book = doc.create_element("book")
book[:id] = "b3"
book.add_child(doc.create_element("title")).content = "New book"
doc.root.add_child(book)
puts doc.to_xml
# <?xml version="1.0"?>
# <root><book id="b3"><title>New book</title></book></root>| method | description |
|---|---|
Document#create_element(name) | detached element owned by the document. |
Document#create_text_node / #create_comment / #create_cdata / #create_processing_instruction | text-class and PI factories. |
Document#fragment(markup) | parse a fragment (multiple top-level children allowed). |
Leptris::XML::Document.create | empty document with its own memory pool — build without parsing (v1.9.156.0). |
Document#root= | attach an element as the document root. |
Element#name=, #content= | rename / replace inner text. |
Element#[]= alias: set_attribute | add/update an attribute (duplicate-rejecting, per XML 1.0). |
Element#remove_attribute alias: delete | drop an attribute. |
Element#add_child alias: << | append a Node, or parse+append a markup String. |
Element#prepend_child | insert as the first child. |
Element#add_next_sibling / #add_previous_sibling | sibling insertion. |
Element#remove_child, Node#unlink | detach from the tree (does not free). |
Element#children= | replace all children. |
Element#replace / #swap | replace in parent. |
Element#wrap | wrap this element in a new one. |
Building from scratch (no parse)
Since gem v1.9.156.0, documents can be created empty — no sentinel parse:
doc = Leptris::XML::Document.create # empty document, own pool
root = doc.create_element("catalog")
root[:id] = "c1"
doc.root = root # attach the programmatic root
root.add_child(doc.create_element("title")).content = "Spring"Namespaces
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"}
doc.xpath("//t:title")| method | description |
|---|---|
Element#namespace | the element’s in-scope namespace (or nil). |
Element#namespaces | all in-scope namespaces (inherited) as {prefix_or_xmlns => href}. |
Element#namespace_definitions | only namespaces declared directly on this element. |
Element#add_namespace_definition alias: add_namespace | declare xmlns:prefix="href". |
Element#default_namespace= | declare/replace xmlns="href". |
Element#remove_namespace_definition | drop a declaration. |
Element#attribute_ns(uri, local) / #has_attribute_ns? | expanded-name lookup (URI + local) with XML Namespaces 1.0 semantics — cross-prefix match, nil URI = no-namespace only, xmlns invisible. |
Attr#prefix / Attr#namespace_uri | per-attribute prefix (as written) and URI (resolved through the owning element’s declarations at read time; xml prebound). |
Serialization and 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"]) # InclusiveNamespacesDocument#to_xml(indent:, no_decl:, encoding:)aliases: to_s, serialize;Element#to_xml(...) serializes a subtree;Document#save(path, **opts) writes to a file;#canonicalize alias: c14ncovers 1.0 / 1.1 / Exclusive for signatures and hashing, andAttr#to_xml returns the serializedname="value" form with the five XML special characters escaped.
Read performance: readonly and the versioned cache
Every read path memoizes. Under readonly: true the document is frozen for reading (mutations raiseReadOnlyError) and reads can never go stale — they memoize unconditionally. Writable documents memoize too, through a per-document mutation version: any mutation (#[]=, #<<, #name=, structural changes, namespace edits) advances the version, and every memoized read recomputes after it. The DOM-editing workload — parse, query repeatedly, mutate occasionally — performs like the readonly one between mutations.
Single-node queries (#at_xpath / #at_css) take a dedicated seam — no NodeSet container, no result handle, one fewer dispatch than xpath().first. Bare-name#[] reads serve from a versioned attributes hash. All strings crossing the C boundary arrive UTF-8, on returns and SAX callbacks alike.
SAX — streaming for very large documents
Subclass Leptris::XML::SAX::Document and override the events you care about. The parser streams in 4 KB chunks; memory is bounded by nesting depth, not document size.
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"))SAX::Parser#parse accepts a String, anIO, or anything responding to #read. Handler callbacks: start_document, end_document,xmldecl(version, encoding, standalone),start_element(name, attrs), end_element(name),characters(str), comment(str),cdata_block(str),processing_instruction(name, content),start_prefix_mapping(prefix, uri),end_prefix_mapping(prefix), warning(str),error(msg, line, col).
Memory model
- Free explicitly with
Document#free. After#free, any further 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 and callsleptris_document_freeexactly once. - NodeSets holding XPath results own their
LeptrisXPathResultand free it on GC. - Never hold a
Nodepast itsDocument. The C memory is gone; using the wrapper is undefined behaviour.
Errors
All Leptris errors descend from Leptris::XML::Error:
ParseError— raised byparse/parse_file/ SAX on malformed input.XPathError— raised byxpathon malformed or unsupported expressions.UseAfterFreeError— raised when calling methods on a freedDocument.Error— generic (mutation precondition failures, etc.).
Migrating from Nokogiri
For most read-only XPath use cases the swap is mechanical:
# Nokogiri
require "nokogiri"
doc = Nokogiri::XML(File.read("doc.xml"))
doc.xpath("//item[@id='1']").each { |n| puts n.text }
# Leptris
require "leptris"
doc = Leptris::XML.parse(File.read("doc.xml"))
doc.xpath("//item[@id='1']").each { |n| puts n.content }Notable differences:
Node#textexists but the canonical name is#content.Node#childrenincludes whitespace text nodes (same as Nokogiri); use#element_childrento skip them.- CSS support is intentionally minimal — for advanced selectors, drop to
xpath. - No
Nokogiri::HTMLorNokogiri::CSSparser — Leptris is XML-only. - No XSLT, no RelaxNG / DTD validation API, no schema caching.
- CRuby only, via the
ffigem — no JRuby / TruffleRuby.
Where next
- The CLI — the same engine for your shell.
- XPath 1.0 — the conformance story behind
#xpath. - Canonical README in leptris-ruby.
This is the curated guide. The repositories are canonical: when this page and the repo disagree, the repo wins. Full documentation lives with the source.