LEPTRIS

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

Gemfile
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:

bash
export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylib

Parsing

The top-level entry point is Leptris::XML — the direct equivalent of Nokogiri::XML(...). Parse a string, an IO, or a file:

ruby
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

methodreturns
Document#rootroot Element, or nil for an empty document.
Node#nameelement name.
Node#content aliases: text, inner_textall descendant text concatenated.
Node#[] aliases: attr, get_attributeattribute value by name.
Node#attributeshash of {name => Attr}.
Node#key? alias: has_attribute?attribute presence.
Node#childrenNodeSet of all children (elements, text, comments, …).
Node#element_childrenNodeSet of element children only.
Node#first_element_child / #last_element_childfirst/last element child (skips text nodes).
Node#next_element / #previous_elementnext/previous sibling element.
Node#parent, #next_sibling, #previous_siblingtree navigation.
Node#line1-based source line number.
Node#type alias: node_typeinteger type code; predicates: #element?, #text?, #comment?, #cdata?, #processing_instruction?.
ruby
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:

ruby
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
end

Searching — XPath and CSS

Document, Element, andDocumentFragment support (via Leptris::XML::Searchable):#xpath, #at_xpath, #css,#at_css, #search (dispatches on syntax), and#at.

ruby
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:

ruby
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

ruby
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>
methoddescription
Document#create_element(name)detached element owned by the document.
Document#create_text_node / #create_comment / #create_cdata / #create_processing_instructiontext-class and PI factories.
Document#fragment(markup)parse a fragment (multiple top-level children allowed).
Leptris::XML::Document.createempty 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_attributeadd/update an attribute (duplicate-rejecting, per XML 1.0).
Element#remove_attribute alias: deletedrop an attribute.
Element#add_child alias: <<append a Node, or parse+append a markup String.
Element#prepend_childinsert as the first child.
Element#add_next_sibling / #add_previous_siblingsibling insertion.
Element#remove_child, Node#unlinkdetach from the tree (does not free).
Element#children=replace all children.
Element#replace / #swapreplace in parent.
Element#wrapwrap 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:

ruby
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

ruby
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")
methoddescription
Element#namespacethe element’s in-scope namespace (or nil).
Element#namespacesall in-scope namespaces (inherited) as {prefix_or_xmlns => href}.
Element#namespace_definitionsonly namespaces declared directly on this element.
Element#add_namespace_definition alias: add_namespacedeclare xmlns:prefix="href".
Element#default_namespace=declare/replace xmlns="href".
Element#remove_namespace_definitiondrop 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_uriper-attribute prefix (as written) and URI (resolved through the owning element’s declarations at read time; xml prebound).

Serialization and canonicalization

ruby
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

Document#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.

ruby
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 raises Leptris::XML::UseAfterFreeError.
  • If you don’t call #free, GC will — a finalizer captures the raw pointer address and calls leptris_document_free exactly once.
  • NodeSets holding XPath results own their LeptrisXPathResult and free it on GC.
  • Never hold a Node past its Document. The C memory is gone; using the wrapper is undefined behaviour.

Errors

All Leptris errors descend from Leptris::XML::Error:

  • ParseError — raised by parse / parse_file / SAX on malformed input.
  • XPathError — raised by xpath on malformed or unsupported expressions.
  • UseAfterFreeError — raised when calling methods on a freed Document.
  • Error — generic (mutation precondition failures, etc.).

Migrating from Nokogiri

For most read-only XPath use cases the swap is mechanical:

before / after
# 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#text exists but the canonical name is #content.
  • Node#children includes whitespace text nodes (same as Nokogiri); use #element_children to skip them.
  • CSS support is intentionally minimal — for advanced selectors, drop to xpath.
  • No Nokogiri::HTML or Nokogiri::CSS parser — Leptris is XML-only.
  • No XSLT, no RelaxNG / DTD validation API, no schema caching.
  • CRuby only, via the ffi gem — no JRuby / TruffleRuby.

Where next

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.