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>XMLdoc.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?).eachdo |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.traversedo |node|
casenodewhenTaurus::XML::Elementthenputs"E #{node.name}"whenTaurus::XML::Textthenputs"T #{node.content.inspect}"whenTaurus::XML::Commentthenputs"C #{node.content.inspect}"endendDocument 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.0doc.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,#b1Attribute 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)putsdoc.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.rootroot.add_namespace_definition("t","https://example.org/types")putsroot.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 indentdoc.to_xml(indent: 2)# pretty-printeddoc.canonicalize# C14N 1.0doc.canonicalize(Taurus::XML::FFI::C14N_1_1)# C14N 1.1doc.canonicalize(exclusive: true)# Exclusive C14Ndoc.canonicalize(with_comments: true)# keep commentsdoc.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:
classCounter < Taurus::XML::SAX::Documentattr_reader:elements,:depthdefinitialize@elements=0@depth=0enddefstart_element(name,attrs=[])@elements += 1@depth += 1puts" " * (@depth - 1) + "<#{name}>"enddefend_element(name)@depth -= 1enddefcharacters(str)puts" " * @depth + "text: #{str.inspect}"unlessstr.strip.empty?endendparser=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.). |
beginTaurus::XML.parse("<unclosed>")rescueTaurus::XML::ParseError=>ewarn"parse failed: #{e.message}"endFor most read-only XPath use cases the swap is mechanical:
# Nokogirirequire"nokogiri"doc=Nokogiri::XML(File.read("doc.xml"))doc.xpath("//item[@id='1']").each{ |n| putsn.text}# Taurusrequire"taurus"doc=Taurus::XML.parse(File.read("doc.xml"))doc.xpath("//item[@id='1']").each{ |n| putsn.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.25.0) and builds it
from source on each runner; see .github/workflows/build.yml.
MIT — see LICENSE.