toml v0.1.0
= toml :toc: macro :toclevels: 2 :source-language: crystal
image:https://github.com/aloli-crystal/toml/actions/workflows/ci.yml/badge.svg[CI,link=https://github.com/aloli-crystal/toml/actions/workflows/ci.yml] image:https://img.shields.io/badge/TOML-v1.0.0-blue[TOML v1.0,link=https://toml.io/en/v1.0.0] image:https://img.shields.io/badge/toml--test-100%25-brightgreen[toml-test 100%,link=https://github.com/toml-lang/toml-test] image:https://img.shields.io/badge/license-MIT-blue[License MIT,link=LICENSE]
π«π· Lisez ce document en franΓ§ais : link:README.fr.adoc[README.fr.adoc]
TOML v1.0 parser and serializer for Crystal, with comment and formatting preservation across parse β modify β write round-trips. Pure Crystal, zero runtime dependencies.
100% conformance with the official https://github.com/toml-lang/toml-test[`toml-lang/toml-test`] v2.1.0 interop suite (205/205 valid tests, 473/473 invalid tests).
API documentation : https://aloli-crystal.github.io/toml/
toc::[]
== Why toml
The Crystal ecosystem already has one TOML library, https://github.com/crystal-community/TOML.cr[`crystal-community/TOML.cr`]. It works, but has three limitations that are blockers for the use cases this shard targets:
. Parsing only. No way to write TOML programmatically. . TOML v0.5.0 β the spec stabilized as v1.0.0 in 2021. The deltas (multi-line inline tables, dotted keys, inf/nan, modern datetime forms) are not handled. . Flat Hash output. Comments, blank lines and the order of declaration are lost. You can't read a config, modify one value, and write it back without diffing the entire file.
toml is built around three goals:
. TOML v1.0 conformance, validated against the official toml-lang/toml-test interop suite (the only authoritative cross-implementation harness). 100% on every release. . Byte-identical round-trip : TOML.parse(s).to_toml == s for every valid s. The AST keeps trivia (comments, whitespace, blank lines, original number/string formatting) attached to its nodes. . Targeted edits that only touch what you change. Modify one value in a 200-line config and the diff is one line, not the whole file reformatted.
The shard powers crystal-secrets for Aloli, where TOML vaults are versioned in git, annotated with comments (# rotated 2026-04-15), and edited programmatically without losing those annotations.
== Conformance
toml is validated on every CI run against the official https://github.com/toml-lang/toml-test[`toml-lang/toml-test`] suite v2.1.0:
[cols="2,1,1,1",options="header"] |=== | Category | Tests | Passed | Result
| valid/ | 205 | 205 | 100% | invalid/ | 473 | 473 | 100% | Total | 678 | 678 | 100% |===
The decoder binary bin/toml-decoder is built and run as part of CI on every push to the production branch. A regression fails the build.
== Installation
Add the dependency to your shard.yml:
[source,yaml]
dependencies: toml: github: aloli-crystal/toml version: "~> 0.1"
Then shards install.
Crystal β₯ 1.19.0. No runtime dependencies.
== Quickstart
There are two API surfaces depending on what you want to do.
=== Drop-in mode β TOML.parse_to_hash
For read-only consumers β fast, plain Hash, loses every comment and the original formatting. Drop-in compatible with crystal-community/TOML.cr.
[source,crystal]
require "toml"
source = File.read("config.toml") config = TOML.parse_to_hash(source)
puts config["title"] puts config["server"].as(Hash(String, TOML::Type))["host"]
TOML.parse_to_hash returns Hash(String, TOML::Type) where TOML::Type is the recursive union :
[source,crystal]
alias TOML::Type = Nil | String | Int64 | Float64 | Bool | Time | Time::Span | Array(TOML::Type) | Hash(String, TOML::Type)
=== Preservation mode β TOML.parse
For writers and round-trippers β returns a TOML::Document AST that keeps comments, blank lines, key order and the original byte-level formatting :
[source,crystal]
require "toml"
doc = TOML.parse(File.read("vault.toml"))
Read with typed accessors
db_url = doc.string("DATABASE_URL") port = doc.int?("server.port") || 8080
Modify
doc.set("DATABASE_URL", "postgres://new...") doc.set_with_comment("API_KEY", "ak_...", "rotated 2026-04-28") doc.delete("OLD_TOKEN")
Write back β comments, blanks and the formatting of every
untouched line are preserved byte-for-byte.
File.write("vault.toml", doc.to_toml)
== Reading values
Document exposes typed accessors for every TOML scalar type. Each comes in two forms:
#TYPE?(path)β returns the value if it exists and matches the requested type,nilotherwise.#TYPE(path)β same, but raisesKeyErrorif missing orTypeCastErroron a wrong type.
[source,crystal]
doc = TOML.parse(<<-TOML) title = "Example" port = 8080 pi = 3.14 on = true when = 1979-05-27T07:32:00Z dur = 07:32:00
[server] host = "0.0.0.0" TOML
doc.string("title") # => "Example" doc.int("port") # => 8080_i64 doc.float("pi") # => 3.14 doc.bool("on") # => true doc.datetime("when").year # => 1979 doc.time_of_day("dur") # => 07:32:00.0 (Time::Span) doc.string("server.host") # => "0.0.0.0"
doc.has_key?("server.host") # => true doc.has_key?("server.tls") # => false doc.string?("missing") # => nil doc.int?("title") # => nil (wrong type, no raise)
doc.string("title.x") # raises KeyError doc.string("port") # raises TypeCastError
The path is a dotted string. For keys that themselves contain a dot, use the Array(String) form :
[source,crystal]
doc = TOML.parse(%("a.b" = 1)) doc.get?(["a.b"]) # => 1_i64
#get?(path) and #get(path) return the raw TOML::Type value without a type filter when you want to handle the type yourself.
== Editing values
Top-level keys can be replaced, inserted, or deleted while preserving the formatting of every other line :
[source,crystal]
doc = TOML.parse(<<-TOML) name = "old" # original port = 8080
[server]
host = "0.0.0.0"
TOML
Replace : the surrounding whitespace and the trailing comment
are kept.
doc.set("name", "new")
β name = 'new' # original
Insert : a new line is added just after the last top-level KV,
i.e. before any [section]. The new line uses literal-string
quotes if possible ('...'), otherwise basic strings ("...")
with proper escaping.
doc.set("region", "eu-west")
β region = 'eu-west'
Insert with a comment :
doc.set_with_comment("API_KEY", "ak_xxx", "rotated 2026-04-28")
β API_KEY = 'ak_xxx' # rotated 2026-04-28
Delete : returns true if a line was removed, false otherwise.
doc.delete("port") # => true doc.delete("nope") # => false
#set is overloaded for String, Int, Float, Bool. Float literals automatically emit inf / -inf / nan for the corresponding non-finite values.
[NOTE]
v0.1 limitation: editing operates on top-level keys only. Modifying a value inside a [section] block, or inside a dotted path, is not yet supported. For those cases, manually rebuild the document or wait for v0.2.
== Round-trip preservation
The headline feature: a parsed document can be re-serialised byte-for-byte identically, even if the input is full of comments, blank lines, and unusual formatting :
[source,crystal]
src = <<-TOML
Production secrets vault
Rotated quarterly β last update 2026-04-15
DATABASE_URL = "postgres://prod/db" API_KEY = "ak_xxx" # never log this!
[stripe]
Live keys β handle with care
secret_key = "sk_live_..." webhook_secret = "whsec_..." TOML
doc = TOML.parse(src) doc.to_toml == src # => true (byte-identical)
After modification, only the touched lines change. Every other byte of the original is preserved :
[source,crystal]
doc.set("DATABASE_URL", "postgres://prod/new") new_src = doc.to_toml
Diff against src : exactly one line changed (DATABASE_URL),
every comment, blank line, and adjacent KV is byte-identical.
This makes toml suitable for tooling that edits human-authored TOML files (CI configs, secret vaults, project manifests) without introducing churn in version control diffs.
== TOML types β Crystal types
[cols="3,3",options="header"] |=== | TOML 1.0 type | Crystal type returned
| Basic / multi-line basic / literal / multi-line literal string | String (with escapes decoded)
| Decimal / hex / octal / binary integer | Int64
| Float (with exponent / inf / nan) | Float64
| Boolean | Bool
| Offset datetime (RFC 3339 with timezone) | Time (with the appropriate Time::Location)
| Local datetime (no timezone) | Time in UTC location
| Local date | Time at midnight UTC
| Local time | Time::Span from midnight
| Array | Array(TOML::Type) (recursive)
| Inline table or [section] | Hash(String, TOML::Type) (recursive)
| Array of tables [[β¦]] | Array(Hash(String, TOML::Type)) |===
In preservation mode (TOML.parse), each value is wrapped in a typed TOML::Value subclass that also carries the original raw source text. The four datetime variants are kept distinct (OffsetDateTimeValue, LocalDateTimeValue, LocalDateValue, LocalTimeValue) so a round-trip preserves the exact form the author wrote.
== Migration from crystal-community/TOML.cr
If your code uses crystal-community/TOML.cr :
[source,crystal]
Before
require "toml"
hash = TOML.parse(File.read("config.toml"))
The migration is a one-line dependency change in shard.yml:
[source,yaml]
dependencies: toml: github: aloli-crystal/toml version: "~> 0.1"
And one method-name change in your code:
[source,crystal]
require "toml"
hash = TOML.parse_to_hash(File.read("config.toml"))
The returned Hash(String, TOML::Type) is shape-equivalent. The type alias TOML::Type is broader (it adds Time::Span for local times) but otherwise behaves the same way.
If you need to read your config back into Crystal but also edit it later, switch to TOML.parse and use the AST API described above.
== Limitations (v0.1)
- Editing is top-level only.
Document#set/#delete/#set_with_commentoperate on top-level bare keys. Modifying a value inside a[section]or via a dotted path is not yet supported in this release. - No schema validation. The shard validates TOML syntax, not your business semantics.
- No streaming. The full document is read into memory and decoded to an AST. Files in the megabyte range are fine; files in the gigabyte range are not the target use case.
- No encoder for toml-test. The shard ships
bin/toml-decoder(TOML β JSON) but not the corresponding TOML encoder side. Round-trip parsing is fully validated, but the JSON-driven encoder side of the toml-test harness is left to a future release.
These items are tracked for v0.2+.
== Architecture
The pipeline is a classic three-phase one :
Lexer Parser Document
String βββββββΊ tokens ββββββββΊ AST nodes ββββββββΊ Hash<String, Type> + trivia + raw text (parse_to_hash) on every node β ββββΊ to_toml βββΊ byte-identical String
- Lexer (
src/toml/lexer.cr) : streaming, emits trivia tokens rather than skipping them. UTF-8 validated up-front. - Parser (
src/toml/parser.cr) : recursive-descent over the token stream. Folds trivia into surrounding nodes' raw fields so the serializer can re-emit byte-for-byte. - AST (
src/toml/node.cr,src/toml/value.cr) : flat list of top-level lines (KeyValueLine,TableHeaderLine,ArrayOfTablesLine,CommentLine,BlankLine). Each node stores enough verbatim source text to be re-emitted unchanged. - HashBuilder (
src/toml/hash_builder.cr) : walks the AST and produces the flatHash(String, Type)output. Also enforces all the spec's semantic rules (duplicate keys, table redefinitions, inline-table closure, dotted-key conflicts, etc.).
A separate TestFormat module (src/toml/test_format.cr) walks the AST and emits the JSON form expected by toml-test, used only by the decoder binary.
== Development
[source,shell]
Run the Crystal test suite
crystal spec
Lint
shards build bin/ameba
Build the toml-test decoder binary
shards build toml-decoder
Run the full toml-test interop suite (after downloading toml-test)
toml-test test -decoder ./bin/toml-decoder
Code style: crystal tool format src/ spec/ is enforced in CI.
== License
MIT β see link:LICENSE[LICENSE].
Copyright Β© 2026 ALOLI sas β Philippe NΓ©nert
toml
- 0
- 0
- 0
- 1
- 1
- about 3 hours ago
- April 28, 2026
MIT License
Wed, 29 Apr 2026 09:55:59 GMT