Skip to content

Repository files navigation

teptris-ruby — TOML for Ruby at libleptris speed

A native C-extension (no FFI, no fallback) Ruby TOML library over the libteptris — the TOML counterpart of leptris-ruby (XML) and yeptris-ruby (YAML+JSON). API-shape and value semantics mirror the tomlib gem: offset and local datetimes become Time, dates become Date, local times stay String.

Performance (end-to-end, Ruby tier)

Best-of-8 runs on a GitHub ubuntu-latest runner (ruby 3.3), 2026-09-21, over the generated bench corpus (~700 KB per shape). Full per-shape table regenerated on every main push by the lang-tier lane.

Shape teptris tomlib 0.7.3 tomlrb 2.0.4

array_heavy

3.0 ms / 237 MB/s

21.7 ms / 33 MB/s

532 ms / 1.4 MB/s

cargo_like

4.0 ms / 88 MB/s

66.7 ms / 5.3 MB/s

307 ms / 1.2 MB/s

datetime_heavy

31.4 ms / 34 MB/s

12,837 ms / 0.1 MB/s

792 ms / 1.4 MB/s

deep_tables

2.9 ms / 84 MB/s

7.0 ms / 35 MB/s

247 ms / 1.0 MB/s

mixed

6.2 ms / 70 MB/s

17.2 ms / 25 MB/s

427 ms / 1.0 MB/s

scalar_float

16.8 ms / 88 MB/s

18,761 ms / 0.1 MB/s

855 ms / 1.7 MB/s

scalar_int

14.8 ms / 87 MB/s

15,135 ms / 0.1 MB/s

868 ms / 1.5 MB/s

scalar_string

9.1 ms / 142 MB/s

3,587 ms / 0.4 MB/s

430 ms / 3.0 MB/s

teptris is 7-110x tomlib and 20-280x tomlrb per shape end to end (parse + materialize into Ruby objects); tomlib’s scalar-heavy cliffs are its datetime/number paths.

Install

gem install teptris

RubyGems resolves a prebuilt platform gem where one exists; every other platform (FreeBSD, OpenBSD, Solaris/illumos, armv7/riscv64/ ppc64le, any-glibc Linux, Windows Ruby 3.0) gets the source gem, which compiles the vendored engine and the extension with the installing Ruby’s own toolchain — a C compiler is required and the install fails loudly without one (the extension is the binding; there is no pure-Ruby fallback).

Supported platforms (prebuilt = PGO-trained where the runner allows,

static, self-contained)

Platform gem Ruby Notes

x86_64-linux, aarch64-linux (fat)

3.3–4.0

one ext per minor; GLIBC >= 2.14 (x86_64) / 2.17 (aarch64)

x86_64-linux-musl, aarch64-linux-musl (fat)

3.3–4.0

one ext per minor; built in Alpine containers

arm-linux, arm-linux-musl

3.3

32-bit ARM; qemu-emulated build (no PGO), single minor

powerpc64le-linux, s390x-linux (fat)

3.3–4.0

big-endian servers; qemu-emulated builds (no PGO)

s390x-linux-musl

3.3

Alpine on IBM Z; qemu-emulated (no PGO), single minor (alpine 3.4+/4.0 s390x images not published)

x86_64-darwin, arm64-darwin (fat)

3.3–4.0

one ext per minor; macOS >= 11.0

x64-mingw32 (fat)

3.0

msvcrt-era RubyInstaller

x64-mingw-ucrt (fat)

3.1–4.0

one ext per Ruby minor

aarch64-mingw-ucrt (fat)

3.4–4.0

RubyInstaller ARM64 builds

ruby (source)

3.0–4.0

everything else — including 3.0–3.2 on fat platforms and any minor on the emulated tier; compiles at install

A native extension binds the Ruby minor that built it, so the fat gems ship one extension per minor under lib/teptris/<minor>/ and the loader picks yours. On a minor a platform gem does not carry, the LoadError names the remedy: install the source gem (gem install teptris --platform ruby), which compiles the vendored engine at install.

Every prebuilt gem also carries the FULL ENGINE SOURCE at ext/teptris_ext/engine (beside the prebuilt extension): untar the gem, cd ext/teptris_ext && ruby extconf.rb && make to rebuild the extension against your own toolchain. The ruby-platform source gem vendors the same tree and compiles automatically at install.

The published-gem smoke lane installs each Windows fat gem from rubygems on every released Ruby and round-trips it; mingw and darwin gems ship byte-identical Ruby sources (EOLs aside).

Tip
Musl (Alpine) note

Some rubygems versions (verified on 3.5.22) resolve the glibc gem for musl rubies even with an explicit --platform, failing at load with a missing ld-linux-*.so. If that happens, install the musl gem file directly:

gem fetch teptris --platform aarch64-linux-musl   # or x86_64/arm-linux-musl
gem install ./teptris-*.gem --local

Development

rake compile          # two-stage PGO build of the pinned libteptris tag + the ext
bundle exec rspec

Performance

Both directions are native (C extensions over libteptris; no FFI, no fallback):

  • load: 1.6×–124× faster than tomlib on every corpus shape (mixed 3.4×, array-heavy 5.0×, scalar shapes 100×+ where tomlib degrades quadratically); 50–380× toml-rb; GC allocations at parity.

  • dump: 3×–10× faster than tomlib on every shape (through the C builder API — the engine emitter is the single formatting source).

The engine implements the TOML 1.1 draft grammar (a strict superset of 1.0: \e/\xNN escapes, optional seconds, inline-table newlines and trailing commas) with 100% toml-test conformance, and parses at ≥3× the best C/C++ competitor on every benchmark shape. Full matrix: teptris benchmarks/LEDGER.md.

Usage

require "teptris"

Teptris::TOML.load("title = \"teptris\"\n[owner]\nname = \"t\"\n")
# => {"title" => "teptris", "owner" => {"name" => "t"}}

Teptris::TOML.dump({"title" => "teptris", "owner" => {"name" => "t"}})

begin
  Teptris::TOML.load("a = [1,")
rescue Teptris::ParseError => e
  e.line   # 1
  e.column # 8
end

Descriptor plan path (lutaml-model recipe)

Compile a plan once, materialize only the planned keys in one native pass (Teptris::Descriptor / Teptris::TOML.load_schema, shipped since 0.2.29; C ABI in libteptris teptris/plan.h). The walk returns a nested Hash with String keys and already-cast Ruby values — the shape Serializable.instantiate wants after a wire→attr rename, not the column-array shape of Yeptris::Schema.

# MappingHash-shaped model: wire names match TOML keys.
DESC = Teptris::Descriptor.build(
  children: [
    { name: "id",    kind: :scalar },
    { name: "name",  kind: :scalar },
    { name: "tags",  kind: :collection },          # array of scalars
    { name: "items", kind: :nested, plan: {        # table or [[array-of-tables]]
      children: [
        { name: "sku",   kind: :scalar },
        { name: "qty",   kind: :scalar },
      ] } },
    { name: "extra", kind: :raw },                 # escape hatch: full subtree
  ])

# compile once per model class; reuse across documents
h = Teptris::TOML.load_schema(toml_string, DESC)
# => {
#   "id"=>1, "name"=>"widget", "tags"=>["a","b"],
#   "items"=>[{"sku"=>"x","qty"=>2}, ...],
#   "extra"=>{"whatever"=>true},   # or nil if absent
# }
# unplanned keys never appear

# lutaml-model adapter half (illustrative):
#   kwargs = mapping.wire_to_attrs(h)   # rename + symbolize
#   Model.instantiate(**kwargs)

Row kinds:

  • :scalar — any TOML value (String/Integer/Float/true/false/Time/Date)

  • :collection — array of scalars

  • :nested — recurse via plan:; also spans arrays of tables

  • :raw — the untouched native subtree (callback/escape hatch)

Absent planned keys read as nil. Type tags are not declared on the Ruby surface: TOML already carries the value kind, and the walk materializes the same datetime contract as TOML.load. Wire→attr renames, when_attribute, and polymorphic partitions stay on the framework side (interpretive), matching the yeptris-ruby#184 split.

load_lazy is the complementary path when the plan is not known up front: one parse, host objects only along touched paths.

Many-small batch (load_batch / load_files / load_lazy_batch)

When the corpus is N TOML documents rather than one (unitsml-style streams, config splits, log shards), accept an Array (or list of file paths) and ship one C call:

# Eager: one Array of Hashes, same datetime contract as TOML.load
docs = Teptris::TOML.load_batch(toml_strings,
                                safe_load: [Time, Date],
                                datetime_policy: :native)

# File paths
docs = Teptris::TOML.load_files(["a.toml", "b.toml", "c.toml"])

# Lazy twin: parse N eagerly, materialize only along touched paths
arr  = Teptris::TOML.load_lazy_batch(toml_strings)
arr.each { |doc| doc["x"].value }   # touches one path per doc

Perf shape (best-of-N on ubuntu-latest, ruby 3.3, 2026-09-21): eager load_batch is a wash or slower than per-doc load for tiny / medium docs (Ruby’s per-doc dispatch is amortized by JIT; the batch’s C-side scratch + tight Ruby allocation loses on those shapes) and a clear win for large docs. The shared-options parsing (one safe_load / datetime_policy check per call) and per-doc error report (a single ParseError carrying the failing doc’s line/column) make eager load_batch a useful API even where it isn’t a speedup. load_lazy_batch is the real many-small speedup when materialization is deferred — parse-only across the batch, no per-doc Hash work.

Gem publishing and version numbers are USER release decisions.

About

TOML for Ruby at libleptris speed — FFI-only gem over libteptris, tomlib API shape

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages