A Nokogiri-compatible Ruby binding for libtaurus, 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.
Add to your Gemfile:
gem "taurus"Then bundle install.
taurus shells out to the native libtaurus shared library via FFI.
You need libtaurus.{dylib,so,dll} installed on the host. Options:
-
Homebrew (macOS, easiest):
brew install lutaml/tap/libtaurus(if packaged) or build from source (see below). -
Build from source (Linux/macOS/Windows):
git clone https://github.com/lutaml/taurus.git cd taurus cmake -B build -S . \ -DCMAKE_BUILD_TYPE=Release \ -DTAURUS_BUILD_SHARED=ON \ -DTAURUS_BUILD_STATIC=OFF \ -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON cmake --build build -j sudo cmake --install build # optional, system-wide
-
Point Taurus at a specific path by setting
TAURUS_LIB_PATH:export TAURUS_LIB_PATH=/usr/local/lib/libtaurus.dylib
If Taurus can’t find the library at startup, every parse call raises
LoadError.
The top-level entry point is Taurus::XML. Parse a string or an IO:
require "taurus"
doc = Taurus::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 = Taurus::XML.parse_file("books.xml")This is the direct Nokogiri equivalent of Nokogiri::XML(…). The
returned object is a Taurus::XML::Document.
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 RubyNode#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 Taurus::XML::Element then puts "E #{node.name}"
when Taurus::XML::Text then puts "T #{node.content.inspect}"
when Taurus::XML::Comment then puts "C #{node.content.inspect}"
end
endDocument and Element (via Taurus::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 — |
#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>XPath result type follows XPath 1.0 semantics:
count(…) → Float, boolean(…) → true/false,
string(…) → String, otherwise a Taurus::XML::NodeSet.
Minimal subset (translated to XPath via Taurus::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.
Documents expose factory methods; elements expose mutation methods:
doc = Taurus::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. |
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. |
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 libtaurus, which resolves
# prefixes using the in-scope namespace declarations.
doc.xpath("//t:title")
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(Taurus::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"]) # InclusiveNamespacesFor very large documents, use the streaming SAX parser. Subclass
Taurus::XML::SAX::Document and override the events you care about:
class Counter < Taurus::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 = Taurus::XML::SAX::Parser.new(Counter.new)
parser.parse(File.open("huge.xml")) # streams in 4 KB chunksSAX::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. |
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 raisesTaurus::XML::UseAfterFreeError. -
If you don’t call
#free, GC will — a finalizer captures the raw pointer address (not the Ruby wrapper) and callstaurus_document_freeexactly once. -
NodeSet`s holding XPath results own their own `TaurusXPathResultand 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.
All Taurus errors descend from Taurus::XML::Error:
ParseError
|
raised by |
XPathError
|
raised by |
UseAfterFreeError
|
raised when calling methods on a freed |
Error
|
generic (mutation precondition failures, etc.). |
begin
Taurus::XML.parse("<unclosed>")
rescue Taurus::XML::ParseError => e
warn "parse failed: #{e.message}"
endFor 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 }
# Taurus
require "taurus"
doc = Taurus::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(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. -
No
Nokogiri::HTMLorNokogiri::CSSparser. Taurus is XML-only. -
No XSLT, no RelaxNG / DTD validation, no schema caching.
-
No built-in JRuby / TruffleRuby support — only CRuby via
ffi.
On the benchmark suite in benchmark/taurus_vs_nokogiri.rb (Ruby 3.3,
libtaurus v0.13+, macOS arm64), Taurus matches or beats Nokogiri on
parse, XPath, serialize, and full-tree traverse for the small and
medium documents that dominate real-world XML workloads. Run the
benchmark locally for numbers on your hardware:
bundle exec ruby benchmark/taurus_vs_nokogiri.rbbundle install # install Ruby deps
bundle exec rspec # full test suite (176 specs)
bundle exec rspec spec/xml/xpath_spec.rb:42 # one example by line
bundle exec rubocop # lintCI pins libtaurus to a released tag (currently v0.18.5) and builds it
from source on each runner; see .github/workflows/build.yml.
MIT — see LICENSE.