# Ktav (כְּתָב)
[](https://crates.io/crates/ktav)
[](https://docs.rs/ktav)
[](https://github.com/ktav-lang/rust/actions)

[](https://ktav-lang.github.io/)
> A plain configuration format. JSON5-shaped, but without the quotes,
> without the commas, with dotted keys for nesting. Native `serde`
> integration.
**Languages:** **English** · [Русский](README.ru.md) · [简体中文](README.zh.md)
**Playground:** convert JSON / YAML / TOML / INI ⇄ Ktav in your browser at **[ktav-lang.github.io](https://ktav-lang.github.io/)**.
**Specification:** this crate implements **Ktav 0.7.0**. The format is
versioned and maintained independently of this crate — see
[`ktav-lang/spec`](https://github.com/ktav-lang/spec) for the formal
document, and [`CHANGELOG.md`](CHANGELOG.md) for what 0.7.0 changed.
---
## Name
*Ktav* (Hebrew: **כְּתָב**) means "writing, that which is written" — a
thing recorded in a form fixed enough that its meaning does not depend
on who passes it along. The name fits literally: a config file *is*
ktav on disk, and the library reads it and hands you back a live
structure without making anything up along the way.
## Motto
> **Be the config's friend, not its examiner. The config isn't perfect —
> but it's the best one.**
Every rule is local. Every line either stands on its own or depends only
on visible brackets. No indentation pitfalls, no forgotten quotes, no
trailing-comma arithmetic.
## The rules
A Ktav document is an implicit top-level object. Inside any object you
have pairs; inside any array you have items.
```text
## comment — any line starting with '##'
key: value — scalar pair; key may be a dotted path (a.b.c)
key:: value — scalar pair; value is ALWAYS a literal string
key: { ... } — multi-line object; `}` closes on its own line
key: [ ... ] — multi-line array; `]` closes on its own line
key: {} / key: [] — empty compound, inline
key: ( ... ) — multi-line string; common indent stripped
key: (( ... )) — multi-line string; verbatim (no stripping)
:: value — inside an array: literal-string item
```
That's the whole language. No commas, no quotes, no escape inside the
value itself — the only "escape" is the `::` marker, and it lives in the
separator (for pairs) or as a line prefix (for array items).
## Values and special tokens
### Strings
Default for any scalar. Stored internally as `Value::String`. The value
is whatever follows `:` after trimming.
```text
name: Russia
path: /etc/hosts
greeting: hello world
## `::` forces a literal string
pattern:: [a-z]+
```
### Numbers
Numbers are written bare (no quotes) and typed by lexical form: a bare
integer body parses to `Value::Integer`, a bare decimal to
`Value::Float`. Each stores a *normalized* payload, not the original
spelling: `Integer` holds the canonical base-10 form (no underscores,
no leading zeros/`+`), `Float` holds the shortest decimal form that
round-trips the exact `f64` bits — `+1_000` becomes `Integer("1000")`,
`1.0e+2` becomes `Float("100.0")`. `Value`-level `Integer` covers the
i64 range; a native Rust integer type wider than i64 (`u64`, `i128`,
`u128`) that doesn't fit is stored as `Value::String` instead when
going through `ser::to_value`, matching what parsing that same decimal
text back would produce. serde deserializes numbers into the target
Rust type (`u16`, `i64`, `i128`, `f64`, …) via direct parsing, and
formats them with the same canonicalization on serialization; a value
forced to a string with `::` is still accepted.
```text
port: 8080
ratio: 3.14159
offset: -42
huge: 1234567890123
```
A value like `port: abc` parses fine *at the Ktav level* (string
`"abc"`), but `serde::deserialize` into `u16` will return a clear
`ParseError`.
### Booleans: `true` / `false`
Strict lowercase. Anything else is a string.
```text
## Value::Bool(true)
on: true
## Value::Bool(false)
off: false
## Value::String("True")
capitalized: True
## Value::String("FALSE")
yelling: FALSE
## Value::String("true")
literal:: true
```
### Null: `null`
Strict lowercase. Matches `Option::None` on the Rust side, as well as
`()` for unit.
```text
## Value::Null
label: null
## Value::String("Null")
capitalized: Null
## Value::String("null")
literal:: null
```
When serializing, `Option::None` is emitted as `null`. Suppress with
`#[serde(skip_serializing_if = "Option::is_none")]` if you prefer the
field absent.
### Empty object / empty array
The **only** inline compound values allowed — nothing to separate, no
commas needed.
```text
## empty object
meta: {}
## empty array
tags: []
```
### Keyword-like strings need `::`
If a string's content happens to equal a keyword (`true`, `false`,
`null`) or begin with `{` or `[`, the **serializer emits `::`
automatically** so the round-trip is lossless. On the writing side you
do the same:
```text
## the string "true", not a bool
flag:: true
## the string "null", not a null
noun:: null
regex:: [a-z]+
ipv6:: [::1]:8080
template:: {issue.id}.tpl
```
## Compound values are multi-line
Non-empty `{ ... }` / `[ ... ]` **must** span multiple lines, with the
closing bracket on its own line. `x: { a: 1 }` and `x: [1, 2, 3]` are
rejected with a clear error — Ktav has no comma-separation rules and
no escape mechanism for them.
```text
## rejected — inline non-empty compound
server: { host: 127.0.0.1, port: 8080 }
tags: [primary, eu, prod]
## accepted — multi-line form
server: {
host: 127.0.0.1
port: 8080
}
tags: [
primary
eu
prod
]
```
## Using it from Rust
Ktav is serde-native. Any type implementing `Serialize` / `Deserialize`
(including `#[derive]`-generated ones) round-trips through Ktav out of
the box.
### Parse — decode straight into a typed struct
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Db { host: String, timeout: u32 }
#[derive(Debug, Deserialize, Serialize)]
struct Config {
service: String,
port: u16,
ratio: f64,
tls: bool,
tags: Vec<String>,
db: Db,
}
const SRC: &str = "\
service: web
port: 8080
ratio: 0.75
tls: true
tags: [
prod
eu-west-1
]
db.host: primary.internal
db.timeout: 30
";
let cfg: Config = ktav::from_str(SRC)?;
println!("port={} db.host={}", cfg.port, cfg.db.host);
```
### Walk — match on the dynamic `Value` enum
```rust
use ktav::value::Value;
let v = ktav::parse(SRC)?;
let Value::Object(top) = &v else { unreachable!("top is always an object") };
for (k, v) in top {
let kind = match v {
Value::Null => "null".into(),
Value::Bool(b) => format!("bool={b}"),
Value::Integer(s) => format!("int={s}"),
Value::Float(s) => format!("float={s}"),
Value::String(s) => format!("str={s:?}"),
Value::Array(a) => format!("array({})", a.len()),
Value::Object(o) => format!("object({})", o.len()),
};
println!("{k} -> {kind}");
}
```
### Build & render — construct a document in code
```rust
use ktav::value::{ObjectMap, Value};
let mut top = ObjectMap::default();
top.insert("name".into(), Value::String("frontend".into()));
top.insert("port".into(), Value::Integer("8443".into()));
top.insert("tls".into(), Value::Bool(true));
top.insert("ratio".into(), Value::Float("0.95".into()));
top.insert("notes".into(), Value::Null);
let text = ktav::render::render(&Value::Object(top))?;
```
For typical app use prefer the serde path — `ktav::to_string(&cfg)` —
and reach for `Value` only when the schema is dynamic.
Four public entry points: [`from_str`](https://docs.rs/ktav) /
[`from_file`](https://docs.rs/ktav) for reading, [`to_string`](https://docs.rs/ktav) /
[`to_file`](https://docs.rs/ktav) for writing. A complete runnable
example lives in [`examples/basic.rs`](examples/basic.rs).
### Inspect errors — structured variants for tooling
`Error::Structured(ErrorKind)` carries a typed category plus a
byte-offset `Span` for every parse failure, so editors and linters
can highlight the exact offending range instead of the whole line.
```rust
use ktav::{parse, Error, ErrorKind};
let src = "port: 80\nport: 443\n";
match parse(src) {
Ok(_) => unreachable!(),
Err(Error::Structured(ErrorKind::DuplicateKey { line, key, span, .. })) => {
println!("line {line}: duplicate key {key:?}");
println!("offending bytes: {:?}", span.slice(src)); // -> Some("port")
let (l, c) = span.line_col(src); // 1-based / 0-based byte col
println!("highlight at {l}:{c}");
}
Err(other) => panic!("{other}"),
}
```
Variants: `MissingSeparatorSpace`, `InvalidTypedScalar`, `DuplicateKey`,
`KeyPathConflict`, `EmptyKey`, `InvalidKey`, `UnclosedCompound`,
`UnbalancedBracket`, `InlineNonEmptyCompound`, `MissingSeparator`,
`LossyScalar` (strict mode only, see below), `Other`. The enum is
`#[non_exhaustive]` — always include a `_ =>` arm. `Error::line()` /
`Error::span()` are convenience accessors when the variant doesn't
matter. The `Display` impl produces the same human-readable string the
legacy `Error::Syntax(_)` did, so existing string-based callers keep
working.
A complete runnable example walks all variants:
[`examples/errors.rs`](examples/errors.rs) — `cargo run --example errors`.
### Strict mode — catch silently canonicalised numbers
Types are inferred from a scalar's lexical form, and inferred numbers
are canonicalised: `version: 1.10` parses as `Float(1.1)` and
`zip: 01234` as `Integer(1234)`. The default `parse()` does this
silently, so writing the document back out rewrites it.
`parse_strict()` rejects such **lossy scalars** instead:
```rust
use ktav::{parse, parse_strict, Error, ErrorKind};
let src = "zip: 01234\n";
assert!(parse(src).is_ok()); // Integer(1234) — leading zero gone
match parse_strict(src) {
Err(Error::Structured(ErrorKind::LossyScalar { body, canonical, .. })) => {
assert_eq!((body.as_str(), canonical.as_str()), ("01234", "1234"));
}
other => panic!("expected LossyScalar, got {other:?}"),
}
```
Fix either by appending `::` to keep the value a String
(`zip:: 01234`) or by writing the canonical number. Any document
`parse_strict()` accepts yields exactly the same `Value` tree as
`parse()`, so strict mode is a validation gate, not a different
dialect. The serde path (`from_str`) has no strict variant yet.
### Stream parse — events without an intermediate tree
`parse_events` invokes a callback for each parse event, with strings
borrowed directly into the input buffer — no allocation per event, no
intermediate `Value` tree. Useful when you don't need the full document:
counting keys, streaming to another format, building a custom shape.
```rust
use ktav::{parse_events, ParseEvent};
let src = "port: 8080\nhost: example.com\n";
let mut keys = Vec::new();
parse_events(src, |ev| {
if let ParseEvent::Key(k) = ev {
keys.push(k.to_string());
}
})?;
assert_eq!(keys, ["port", "host"]);
```
The root is `BeginObject`/`EndObject` or `BeginArray`/`EndArray`
depending on the document's first content line (an Object here, since
`port: 8080` is a pair); nested compounds bracket their contents the
same way. `ParseEvent` is `#[non_exhaustive]`. A complete runnable
example with depth tracking
and a pretty-printer:
[`examples/events.rs`](examples/events.rs) — `cargo run --example events`.
### Numbers
Rust numeric types (`u8`..`u128`, `i8`..`i128`, `usize`, `isize`, `f32`,
`f64`) serialize to Ktav as bare numbers: `port: 8080`, `ratio: 0.5`.
Coming back, a bare integer/decimal body deserializes straight into
the target numeric type; a value that arrived as a string (e.g. forced
with `::`) is still accepted via `FromStr`. `NaN` / `±Infinity` are
rejected by the serializer (Ktav does not represent them).
## Examples: Ktav → JSON5
JSON5 is on the right because it reads like ordinary JavaScript, allows
comments, and shows exactly what the parser produces.
### 1. Scalars
```text
name: Russia
port: 20082
```
```json5
{
name: "Russia",
port: 20082
}
```
Scalars are typed at the `Value` level from their lexical form
(`Integer`/`Float`/`Bool`/`Null`/`String`); force a literal string with
the `::` raw marker (e.g. `port:: 20082`) when a numeric-looking body
must stay a string.
### 2. Dotted keys = nested objects
```text
server.host: 127.0.0.1
server.port: 8080
app.debug: true
```
```json5
{
server: { host: "127.0.0.1", port: 8080 },
app: { debug: true }
}
```
Any depth works. The full address is on every line.
### 3. Nested object as a value
```text
server: {
host: 127.0.0.1
port: 8080
endpoints.api: /v1
endpoints.admin: /admin
}
```
```json5
{
server: {
host: "127.0.0.1",
port: 8080,
endpoints: { api: "/v1", admin: "/admin" }
}
}
```
### 4. Array of scalars
```text
banned_patterns: [
.*\.onion:\d+
.*:25
]
```
```json5
{
banned_patterns: [".*\\.onion:\\d+", ".*:25"]
}
```
### 5. Array of objects
```text
upstreams: [
{
host: a.example
port: 1080
}
{
host: b.example
port: 1080
}
]
```
```json5
{
upstreams: [
{ host: "a.example", port: 1080 },
{ host: "b.example", port: 1080 }
]
}
```
### 6. Arbitrary nesting
Every compound value spans multiple lines (single-line `{ ... }` / `[ ... ]`
with contents is not accepted — only the empty forms `{}` / `[]` are
inline). Nest as deep as needed:
```text
countries: [
{
name: Russia
cities: [
{
name: Moscow
buildings: [
{
name: Kremlin
}
{
name: Saint Basil's
}
]
}
{
name: Saint Petersburg
}
]
}
{
name: France
}
]
```
### 7. Literal strings: `::`
Some values would otherwise be parsed as compound (because they start
with `{` or `[`): regular expressions, IPv6 addresses, template
placeholders. The double-colon `::` flags them as "raw string, do not
parse further."
```text
pattern:: [a-z]+
ipv6:: [::1]:8080
template:: {issue.id}.tpl
hosts: [
ok.example
:: [::1]
:: [2001:db8::1]:53
]
```
```json5
{
pattern: "[a-z]+",
ipv6: "[::1]:8080",
template: "{issue.id}.tpl",
hosts: ["ok.example", "[::1]", "[2001:db8::1]:53"]
}
```
For pairs the marker sits between key and value; for array items it
stands at the start of the line. **Serialization emits `::`
automatically** when a string value begins with `{` or `[`, so
round-tripping regexes and IPv6 addresses just works.
### 8. Comments
```text
## top-level comment
port: 8080
items: [
## this comment does not break the array
a
b
]
```
Comments are full lines starting with `#`. Inline comments are not
supported — they get confused with the value too easily.
### 9. Multi-line strings: `( ... )` and `(( ... ))`
Values that span multiple lines go inside parentheses. The opening and
closing lines are NOT part of the value.
`(` ... `)` — common leading whitespace is stripped and, as of 0.7,
trailing whitespace is stripped from each line, so you can indent
the block to match its surroundings without contaminating the content:
```text
body: (
{
"qwe": 1
}
)
```
```json5
{ body: "{\n \"qwe\": 1\n}" }
```
`((` ... `))` — verbatim: every character between the markers ends up in
the value, including leading whitespace:
```text
sig: ((
-----BEGIN-----
QUJDRA==
-----END-----
))
```
```json5
{ sig: " -----BEGIN-----\n QUJDRA==\n -----END-----" }
```
Inside a block, `{` / `[` / `#` are just content — **no compound parsing,
no comment skipping**. The only special sequence is the terminator on
its own line.
Empty inline form: `key: ()` or `key: (())` — both yield the empty
string (same as `key:`).
Serialization: a string is emitted on a single line only when it has
no `\n`, no leading/trailing whitespace, and no control byte other
than `TAB`. Anything else — including a plain string with a stray edge
space — takes a multi-line form: verbatim `(( ... ))` when the content
has edge whitespace that stripped would alter, stripped `( ... )`
otherwise; whichever the
writer picks, the round-trip is byte-for-byte lossless.
Which block form you get depends on *where* the whitespace is. As of
0.7 the stripped form strips trailing whitespace from every content
line (§ 5.6), so a trailing space forces the verbatim form, which
preserves it byte-for-byte:
```json5
{ password: "hunter2 " }
```
```text
password: ((
hunter2
))
```
Leading whitespace — and, since 0.7, trailing whitespace — is what
forces the verbatim form: stripping would eat the leading indent:
```json5
{ indent: " padded" }
```
```text
indent: ((
padded
))
```
Either way, reading it back gives you the original bytes.
Limitation: a body containing a line whose trimmed content is exactly
`))` cannot use the verbatim form. It falls back to stripped instead —
unless the body also has a sole-`)` line, a whitespace-only line, a
line with trailing whitespace, or
every line indented (nothing to anchor the dedent at zero), in which
case no form can hold it and serialization returns an error rather
than emit a document that fails to round-trip.
### 10. Empty compounds
```text
meta: {}
tags: []
```
Inline empty is allowed. Anything with contents must span multiple
lines, and the closing `}` / `]` must sit on its own line.
### 11. Enums
Ktav uses serde's default *externally tagged* enum representation.
```rust
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Mode { Fast, Slow }
#[derive(Serialize, Deserialize)]
enum Action {
Log(String),
Count(u32),
}
```
```text
## unit variant — just the name
mode: fast
## newtype variant — single-entry object
action: {
Log: hello
}
```
## Round-trip
```rust
let cfg: MyConfig = ktav::from_str(text)?;
let back = ktav::to_string(&cfg)?;
let again: MyConfig = ktav::from_str(&back)?;
assert_eq!(cfg, again);
```
Serialization preserves:
- **Field order** — `Value::Object` is backed by an `IndexMap`, so the
order is whatever serde emits (for structs: declaration order).
- **Literal strings** — values starting with `{` or `[` are emitted
with the `::` marker.
- **`None` fields** — skipped on output; reappear as `None` on input
(via serde's `Option` handling).
## Architecture
```
ktav/
├── value/ — the Value enum, ObjectMap
├── parser/ — line-by-line parser (text → Value)
├── render/ — pretty-printer (Value → text)
├── ser/ — serde::Serializer (T: Serialize → Value)
├── de/ — serde::Deserializer (Value → T: Deserialize)
├── error/ — Error + serde::Error impls
└── lib.rs — glue: from_str / from_file / to_string / to_file
```
Each file holds one exported item; implementation details are private to
their parent module.
## What Ktav does NOT do — and never will
- **Inline non-empty compounds** like `x: { a: 1, b: 2 }`. They'd bring
commas, and commas would bring escaping. Compound values are
multiline.
- **Anchors / aliases / merge keys** (`&anchor`, `*ref`, `<<:`). Any
line whose meaning depends on a declaration far away stops being
self-sufficient. If you want DRY, compose defaults in code.
- **File includes** (`@include`, `!import`). Write a wrapper in code
for large configs.
- **Top-level arrays.** The document is always an object.
## Installation
```toml
[dependencies]
ktav = "0.6"
serde = { version = "1", features = ["derive"] }
```
## Support the project
The author has many ideas that could be broadly useful to IT worldwide —
not limited to Ktav. Realizing them requires funding. If you'd like to
help, please reach out at **phpcraftdream@gmail.com**.
## License
Dual-licensed under **MIT OR Apache-2.0** at your option. See
[LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE).
## Other Ktav implementations
- [`spec`](https://github.com/ktav-lang/spec) — specification + conformance suite
- [`csharp`](https://github.com/ktav-lang/csharp) — C# / .NET (`dotnet add package Ktav`)
- [`golang`](https://github.com/ktav-lang/golang) — Go (`go get github.com/ktav-lang/golang`)
- [`java`](https://github.com/ktav-lang/java) — Java / JVM (`io.github.ktav-lang:ktav` on Maven Central)
- [`js`](https://github.com/ktav-lang/js) — JS / TS (`npm install @ktav-lang/ktav`)
- [`php`](https://github.com/ktav-lang/php) — PHP (`composer require ktav-lang/ktav`)
- [`python`](https://github.com/ktav-lang/python) — Python (`pip install ktav`)