LEPTRIS

C99 · XML 1.0 · XPath 1.0 · SAX

Fast as hares.
Contained as a circle.

libleptris is an XML parser, W3C-conformant XPath engine and SAX feed in pure C99 — zero required dependencies, one arena per document, and a memory ceiling you can state before you parse.

three hares · three ears · dunhuang

faster text parse than pugixml
faster than nokogiri, real docs
0/438W3C XPath 1.0 conformance
zerorequired runtime dependencies

the motif, rotated 120°

The name

Three hares, three ears

On the ceilings of the Dunhuang caves, and centuries later on the beams of parish churches across Europe, the same image appears: three hares chasing in a circle, sharing three ears — each hare seems to have two, yet there are only three among them.

That is the contract of this library. Speed: hares in full chase — vectorized scans, one contiguous arena, indices where others walk. Containment: the circle — every allocation bounded at parse time, a hard memory ceiling, a self-sufficient core with nothing hidden behind the ring.

— from Dunhuang to your build, wide acceptance


What it is

A parser built like a system, not a script

Every design choice optimizes for the same pair: throughput you can measure, and resource behavior you can promise.

3.4× / text-heavy DOM

Speed, measured honestly

Interleaved A/B gates against pugixml and libxml2 on every shape — text-heavy, attr-heavy, deep, pretty, markup-rich. Median-of-runs, fresh build dirs, the harness published alongside the numbers. Serialize is 1.5–1.6× ahead of pugixml; the XPath engine serves repeated //x from a subtree-interval index at effectively no cost.

1 arena per parse

Hard resource bounds

The parser pre-sizes one contiguous arena from the document itself — element, attribute, text and markup blocks carved from a single allocation, then retained for reuse. No hidden per-node mallocs, no unbounded growth mid-parse, allocations visible to your allocator hooks.

Conformant where it counts

XPath 1.0: 438/438 on the W3C suite. XML 1.0 namespaces, DTD validation phases 1–7, XInclude, exclusive C14N — semantics pinned by 586 tests plus large-document and allocation-failure suites in CI.

64 B element · 40 B attr

Compact by construction

One cache line per element and per attribute; compact self-relative edges; zero-copy views into the document buffer. A 4 MB document parses into roughly 3 MB of tree.

DOM and SAX, one core

Streaming SAX with all eleven events beats libxml2’s SAX on every measured shape — same scanner, same bounds, no DOM required.

Ruby & Python today

gem leptris and pip leptris wrap the C surface with GC-safe handles. The 4.38 MB real-world document: 18 ms parse+serialize through Ruby vs Nokogiri’s 123 ms. Rust is next.

A CLI that respects your pipe

leptris parse | xpath | format — MECE output modes (XML / JSON / text), reads files or stdin, man pages included. Not a demo binary: the same public API, nothing private.

The race

Numbers from the open harness

Every figure below reproduces from benchmarks/matrix in the repository — min/median over interleaved runs, fresh Release builds, medians reported because library minimums wobble under CI load.

Real-world document, 4.38 MB — parse + serialize, through Ruby

10,000 records, pretty-printed, encoding-declared; same process, same harness (serialbench)

leptrisvia binding 18 ms
Ox 118 ms
Nokogirilibxml2 123 ms

2 MB text-heavy DOM parse — vs pugixml, C harness

median of interleaved runs, identical Release flags, same machine

leptris 193 µs
pugixml 663 µs

Write side — serialize, vs pugixml

2 MB text-heavy and attr-heavy documents

leptristext 2 MB 420 µs
pugixmltext 2 MB 650 µs
leptrisattr-heavy 449 µs
pugixmlattr-heavy 692 µs

Shapes where pugixml still leads (attr-heavy parse ~1.5×, its duplicate-allowing attribute API ~1.6× on raw append) are documented with their measured floors in the repository’s perf ledger — nothing is cherry-picked, and the harness that produced every number is in the tree.

The circle

Memory you can promise

The arena is sized from the document before the first node is carved; mutation storage grows in chained blocks owned by the document. What you allocate is what you hold — stated up front, released all at once.

noderesidentwhy
element64 Bone cache line; self-relative int32 edges
attribute40 Bviews into the buffer; entity flag packed in the hash word
text node64 Bborrowed content until first expansion
comment / CDATA / PI48 Bcarved from the same arena block as elements
parse arena1 allocationretained and reused across documents

The chase begins

Install, four ways

C99 — builds anywhere CMake does. Optional utf8proc and iconv; neither is required.

C — CMake

# vcpkg, or FetchContent:
include(FetchContent)
FetchContent_Declare(leptris
  GIT_REPOSITORY https://github.com/leptris/leptris)
FetchContent_MakeAvailable(leptris)

target_link_libraries(yours PRIVATE leptris::leptris)

C — vcpkg

# manifest mode: add to vcpkg.json
{ "dependencies": [ "leptris" ] }

# or classic:
vcpkg install leptris

Ruby

gem install leptris

require "leptris"
doc = Leptris::XML.parse("<greet>hello</greet>")
doc.to_xml        # ⇒ "<greet>hello</greet>"

Python

pip install leptris

import leptris
doc = leptris.parse("<greet>hello</greet>")
doc.to_xml()       # ⇒ '<greet>hello</greet>'

The taste of the C API

#include <leptris.h>

LeptrisStatus st;
LeptrisDocument* doc = leptris_parse_string(xml, len, &st);
for (LeptrisElement e = leptris_element_first_child(root, "record");
     e; e = leptris_element_next_sibling(e, "record")) {
    const char* id = leptris_element_attribute(e, "id");
}
char* out = leptris_document_serialize(doc, NULL);  /* caller frees */
leptris_free_string(out);
leptris_document_free(doc);                        /* arena, all at once */