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
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.
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.
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.
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)
2 MB text-heavy DOM parse — vs pugixml, C harness
median of interleaved runs, identical Release flags, same machine
Write side — serialize, vs pugixml
2 MB text-heavy and attr-heavy documents
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.
| node | resident | why |
|---|---|---|
| element | 64 B | one cache line; self-relative int32 edges |
| attribute | 40 B | views into the buffer; entity flag packed in the hash word |
| text node | 64 B | borrowed content until first expansion |
| comment / CDATA / PI | 48 B | carved from the same arena block as elements |
| parse arena | 1 allocation | retained 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 */