Skip to content

Repository files navigation

taurus-ruby

RubyGems Version CI

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.

Installation

Add to your Gemfile:

gem "taurus"

Then bundle install.

Runtime requirement: libtaurus

taurus shells out to the native libtaurus shared library via FFI. You need libtaurus.{dylib,so,dll} installed on the host. Options:

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

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

Parsing

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.

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

Searching: XPath and CSS

Document and Element (via Taurus::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.

#at_css(*selectors)

first match of css.

#search(*exprs)

dispatches on syntax — /-prefixed or ,-separated → xpath, otherwise css.

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

XPath result type follows XPath 1.0 semantics: count(…​)Float, boolean(…​)true/false, string(…​)String, otherwise a Taurus::XML::NodeSet.

Supported CSS selectors

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.

Building and mutating

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.

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 = Taurus::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.

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

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(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"])  # InclusiveNamespaces

SAX parsing

For 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 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 Taurus::XML::UseAfterFreeError.

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

  • NodeSet`s holding XPath results own their own `TaurusXPathResult 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 Taurus errors descend from Taurus::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
  Taurus::XML.parse("<unclosed>")
rescue Taurus::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 }

# Taurus
require "taurus"
doc = Taurus::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.

  • No Nokogiri::HTML or Nokogiri::CSS parser. Taurus is XML-only.

  • No XSLT, no RelaxNG / DTD validation, no schema caching.

  • No built-in JRuby / TruffleRuby support — only CRuby via ffi.

Performance

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

Development

bundle 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                  # lint

CI pins libtaurus to a released tag (currently v0.18.5) and builds it from source on each runner; see .github/workflows/build.yml.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages