RubyGems Version CI

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:

  1. Homebrew (macOS, easiest): brew install lutaml/tap/libleptris (if packaged) or build from source (see below).

  2. 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
  3. 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:

doc = Leptris::XML.parse("<broken", recover: true)
doc.root                                # => nil
Leptris::XML::FFI.leptris_last_error    # => "..."

Reading nodes

Document#root

root Element, or nil for an empty document.

Node#name

element name (e.g. "book").

Node#content (alias #text, #inner_text)

all descendant text concatenated.

Node#[] (alias #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 (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: #element?, #text?, #comment?, #cdata?, #processing_instruction?.

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

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

Readonly mode

For the dominant parse-query-serialize workload, parse with readonly: true (or call Document#readonly!, one-way):

doc = Leptris::XML.parse(xml, readonly: true)
doc.root.children.first["id"]        # reads are memoized
doc.root.name                        # plain ivar after first call
doc.root << doc.create_element("x")  # raises ReadOnlyError

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 NodeSet, true/false, Float, or String depending on the expression.

#at_xpath(*exprs)

first match (or scalar), like xpath(*exprs).first.

#css(*selectors)

minimal CSS-to-XPath translation, then xpath. Receiver-relative (Nokogiri semantics): scoped to the element or fragment, document-wide from a Document.

#at_css(*selectors)

first match of css.

#search(*exprs)

dispatches on syntax — path-prefixed expressions (/, ., ..) go to xpath; everything else translates as CSS (comma unions included).

#at(*exprs)

first match of search.

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:

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

Building from scratch (no parse)

# Create an empty Document by parsing a sentinel and replacing the root,
# or build incrementally on a one-element seed.
doc = Leptris::XML.parse("<root/>")
doc.root.name = "catalog"
# ... then create_element / add_child as above.

Namespaces

Element#namespace

the element’s in-scope namespace as a Namespace (or nil).

Element#namespaces

all in-scope namespaces (inherited from ancestors) as a {prefix_or_xmlns ⇒ href} hash.

Element#namespace_definitions

only namespaces declared directly on this element.

Element#add_namespace_definition(prefix, href) (alias #add_namespace)

declare xmlns:prefix="href" on this element.

Element#default_namespace=(href)

declare/replace xmlns="href".

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 (nil when none).

Attr#namespace_uri

resolved through the owning element’s in-scope declarations at read time (xml prebound; nil for undeclared prefixes).

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; attrs is an array of [name, value] pairs in source order.

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 raises Leptris::XML::UseAfterFreeError.

  • If you don’t call #free, GC will — a finalizer captures the raw pointer address (not the Ruby wrapper) and calls leptris_document_free exactly once.

  • NodeSet`s holding XPath results own their own `LeptrisXPathResult and free it on GC.

  • Don’t hold a Node reference past the lifetime of 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.).

begin
  Leptris::XML.parse("<unclosed>")
rescue Leptris::XML::ParseError => e
  warn "parse failed: #{e.message}"
end

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#text exists but the canonical name is #content (Nokogiri uses both).

  • Node#children includes whitespace text nodes (same as Nokogiri); use #element_children or #first_element_child to skip them.

  • CSS support is intentionally minimal — for advanced selectors, drop to xpath. css is receiver-relative (Nokogiri semantics): scoped to an element or fragment, document-wide from a Document.

  • DocumentFragment is 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 — libxml2 XML_PARSE_RECOVER semantics. The companion Document#last_error_position returns [line, column].

  • Leptris::XML.parse(xml, readonly: true) (or Document#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::HTML or Nokogiri::CSS parser. 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.