A plain configuration format. JSON5-shaped, but without the quotes, without the commas, with dotted keys for nesting. Native
serdeintegration.
Languages: English · Русский · 简体中文
Playground: convert JSON / YAML / TOML / INI ⇄ Ktav in your browser at ktav-lang.github.io.
Specification: this crate implements Ktav 0.8.0, the version
named by [package.metadata.ktav] spec-version in Cargo.toml. The
format is versioned and maintained independently of this crate — the two
numbers move apart on purpose, since a crate release that changes no
format behaviour leaves spec-version where it was. See
ktav-lang/spec for the formal
document, and CHANGELOG.md for this crate's history.
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.
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.
A Ktav document is an implicit top-level object. Inside any object you have pairs; inside any array you have items.
## 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).
Default for any scalar. Stored internally as Value::String. The value
is whatever follows : after trimming.
name: Russia
path: /etc/hosts
greeting: hello world
## `::` forces a literal string
pattern:: [a-z]+
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 +), 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"). A decimal whose digits open with a
redundant 0 is not a number at all: zip: 01234 is
String("01234") (§ 5.2), so a zero-padded identifier survives
verbatim. 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.
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.
Strict lowercase. Anything else is a string.
## 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
Strict lowercase. Matches Option::None on the Rust side, as well as
() for unit.
## 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.
The only inline compound values allowed — nothing to separate, no commas needed.
## empty object
meta: {}
## empty array
tags: []
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:
## 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
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.
## 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
]
Four rules the examples above never reach. Each one is settled by the specification rather than by this implementation, so every conforming parser behaves the same way.
A dot in a bare key means nesting: db.host: primary builds
db → host. Quoting the key turns off that reading, so the dot is
part of the name (spec § 5.3.3):
db.host: primary
"db.host": literal
"a b": spaces are fine too
The first line nests. The second is a single key literally named
db.host. This is why the error envelope reports path as an array
of segments rather than a joined string — a joined string could not tell
those two apart.
\uXXXX and the named escapes (spec § 3.7, § 3.7.1) are read where
a delimiter would otherwise be structural — inside { } and [ ],
and inside a quoted key. A bare block-level value has no delimiters to
escape, so a backslash there is ordinary text:
inline: {greek: \u03b1, csv: a\,b}
"\u00e9": 1
literal: \u0041
greek is α, csv is the single string a,b — the escaped
comma is not a separator — and the quoted key is é. But literal is
the eight characters \u0041, unchanged. A recognised escape also
forces String classification: a value written \u0031 is the string
1, not the integer.
A U+FEFF at the very start of the document is skipped before any other byte is examined (spec § 3.1). Anywhere else it is ordinary content — including the start of a later line, where it becomes part of that key's name. Editors that add a BOM on save therefore do not break a document, and a stray one further in does not silently disappear.
from_file validates the file's bytes as
UTF-8 before parsing and reports [Error::InvalidUtf8] with the byte
offset of the first bad sequence (spec § 6.15). A missing file or a
permission problem stays [Error::Io] — the two are worth
distinguishing, because one means "fix the file" and the other means
"fix the path". The bytes are never repaired or replaced before the
parser sees them.
Ktav is serde-native. Any type implementing Serialize / Deserialize
(including #[derive]-generated ones) round-trips through Ktav out of
the box.
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);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}");
}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 /
from_file for reading, to_string /
to_file for writing. A complete runnable
example lives in examples/basic.rs.
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.
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 — cargo run --example errors.
The accessors above are Rust-only. ErrorEnvelope is the wire
contract for everyone else: one JSON object, ten fields, always all
ten, in a fixed order — error, reason, line, line_text,
span, path, body, canonical, spec_section,
message.
use ktav::{parse, ErrorEnvelope};
let src = "a: 1.10\n";
if let Err(e) = ktav::parse_strict(src) {
println!("{}", ErrorEnvelope::from_error(&e, src).to_json());
}{"error":"LossyScalar","reason":null,"line":1,"line_text":"a: 1.10",
"span":{"start":0,"end":7},"path":null,"body":"1.10",
"canonical":"1.1","spec_section":"§3.6/§5.2",
"message":"Syntax error: Line 1: LossyScalar: '1.10' would be …"}message is the last field and the only one that is never
null: it carries this error's Display rendering verbatim. A
binding shows it to the user as-is rather than assembling prose from
the structured fields, so the same document produces the same error
text in every language. The nine older fields keep the positions they
shipped with — message was appended, not inserted.
Absent information is an explicit null, never an omitted key, so a
consumer can read every field positionally without negotiating a
schema first.
span is {"start":N,"end":M} in byte offsets into the UTF-8
source, not UTF-16 code units — the same unit Span
itself uses. An LSP consumer either converts, or negotiates
positionEncoding: "utf-8".
path is an array of exact decoded key segments, never a joined
string. A key literally named a.b is one segment and cannot be
confused with a two-segment path — there is no separator in the wire
contract to be ambiguous about.
Writer rejections use the same envelope: reason carries the § 5.9.0
reason code (NonFiniteFloat, EmptyKeyName, …) and the two
rejections are named apart — UnrepresentableAt when the writer can
say where the offending node is (it fills path too),
Unrepresentable when it cannot.
Rendering is to_json() (or push_json(&mut String) to append into
a buffer you own). It is valid JSON for any payload — every string is
escaped per RFC 8259 — and no serde dependency is involved.
Types are inferred from a scalar's lexical form, and inferred numbers
are canonicalised: version: 1.10 parses as Float(1.1). A decimal
with a redundant leading zero is the one exception — zip: 01234 is the
String "01234" (§ 5.2), because dropping that zero would destroy an
identifier. The default parse() does this
silently, so writing the document back out rewrites it.
parse_strict() rejects such lossy scalars instead:
use ktav::{parse, parse_strict, Error, ErrorKind};
let src = "version: 1.10\n";
assert!(parse(src).is_ok()); // Float(1.1) — trailing zero gone
assert!(parse("zip: 01234\n").is_ok()); // String("01234") — leading zero kept
match parse_strict(src) {
Err(Error::Structured(ErrorKind::LossyScalar { body, canonical, .. })) => {
assert_eq!((body.as_str(), canonical.as_str()), ("1.10", "1.1"));
}
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.
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.
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 — cargo run --example events.
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).
JSON5 is on the right because it reads like ordinary JavaScript, allows comments, and shows exactly what the parser produces.
name: Russia
port: 20082
{
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.
server.host: 127.0.0.1
server.port: 8080
app.debug: true
{
server: { host: "127.0.0.1", port: 8080 },
app: { debug: true }
}Any depth works. The full address is on every line.
server: {
host: 127.0.0.1
port: 8080
endpoints.api: /v1
endpoints.admin: /admin
}
{
server: {
host: "127.0.0.1",
port: 8080,
endpoints: { api: "/v1", admin: "/admin" }
}
}banned_patterns: [
.*\.onion:\d+
.*:25
]
{
banned_patterns: [".*\\.onion:\\d+", ".*:25"]
}upstreams: [
{
host: a.example
port: 1080
}
{
host: b.example
port: 1080
}
]
{
upstreams: [
{ host: "a.example", port: 1080 },
{ host: "b.example", port: 1080 }
]
}Every compound value spans multiple lines (single-line { ... } / [ ... ]
with contents is not accepted — only the empty forms {} / [] are
inline). Nest as deep as needed:
countries: [
{
name: Russia
cities: [
{
name: Moscow
buildings: [
{
name: Kremlin
}
{
name: Saint Basil's
}
]
}
{
name: Saint Petersburg
}
]
}
{
name: France
}
]
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."
pattern:: [a-z]+
ipv6:: [::1]:8080
template:: {issue.id}.tpl
hosts: [
ok.example
:: [::1]
:: [2001:db8::1]:53
]
{
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.
## 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.
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:
body: (
{
"qwe": 1
}
)
{ body: "{\n \"qwe\": 1\n}" }(( ... )) — verbatim: every character between the markers ends up in
the value, including leading whitespace:
sig: ((
-----BEGIN-----
QUJDRA==
-----END-----
))
{ 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:
{ password: "hunter2 " }password: ((
hunter2
))
Leading whitespace — and, since 0.7, trailing whitespace — is what forces the verbatim form: stripping would eat the leading indent:
{ indent: " padded" }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.
meta: {}
tags: []
Inline empty is allowed. Anything with contents must span multiple
lines, and the closing } / ] must sit on its own line.
Ktav uses serde's default externally tagged enum representation.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Mode { Fast, Slow }
#[derive(Serialize, Deserialize)]
enum Action {
Log(String),
Count(u32),
}## unit variant — just the name
mode: fast
## newtype variant — single-entry object
action: {
Log: hello
}
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::Objectis backed by anIndexMap, so the order is whatever serde emits (for structs: declaration order). - Literal strings — values starting with
{or[are emitted with the::marker. Nonefields — skipped on output; reappear asNoneon input (via serde'sOptionhandling).
ktav::format_str rewrites a document into the structural spelling
emit_canonical produces, but keeps the trivia the canonical writer
drops. Every comment survives verbatim.
let tidied = ktav::format_str("## the server\nserver: {host: a, port: 80}\n")?;
assert_eq!(tidied, "## the server\nserver: {\n host: a\n port: 80\n}\n");That is, the inline compound expands to the canonical multi-line form and the comment stays exactly where it was:
## the server
server: {
host: a
port: 80
}
Blank lines survive as a grouping hint, but a run of two or more collapses to exactly one, and blank padding just inside a bracket is dropped. That is what makes the transform a fixed point: formatting already-formatted output never changes it again.
Key order is never changed. Canonical form has no sorting rule (§ 5.9), and reordering keys would make review diffs worse, not better — this is a spelling normaliser, not a refactoring tool.
For a document with no comments and no blank lines,
format_str equals emit_canonical of its parse. The stronger
condition is deliberate: blank lines are no more part of the Value
model than comments are, so emit_canonical drops them and
format_str does not.
Behind the cli feature, off by default. In a project that already
has a toolchain the library call above plus a build hook is usually the
better answer, and editors format through ktav-lsp; the binary is
for the case where neither is at hand.
cargo install ktav --features cli
ktav-fmt <file>... format each file in place
ktav-fmt --stdout <file> print the result, leave the file alone
ktav-fmt --check <file>... exit non-zero if a file is not formatted
ktav-fmt - read one document from stdin
--check writes nothing and prints the path of every file that is
not already formatted, so it drops straight into CI next to
cargo fmt --check.
Six language bindings — Go, Java, PHP, C#, JS and Python — load a
small native library built on this crate. The shareable half of that
shim lives behind the off-by-default cabi feature: the
ktav::cabi module carries the wire decoding, the six document
operations and the error encoding, and one macro call in a binding's
cdylib expands the exported symbols.
// crates/cabi/src/lib.rs of a binding — the whole body:
ktav::declare_cabi!();
The expansion exports nine symbols: the six document functions
(ktav_loads, ktav_loads_strict, ktav_dumps,
ktav_dumps_force_strings, ktav_emit_canonical,
ktav_format), ktav_free, ktav_version and
ktav_abi_version. Every error leaves as the nine-field JSON
envelope, so a host never has to sniff plain text against JSON, and
ktav_abi_version() lets a host refuse a stale native library
instead of corrupting memory. The full contract — signatures,
ownership, the error encoding, the artifact naming convention
(ktav_cabi-windows-amd64.dll, libktav_cabi-darwin-arm64.dylib,
libktav_cabi-linux-amd64.so, the $KTAV_LIB_PATH override) — is
specified in docs/CABI.md.
ktav/
├── value/ — the Value enum, ObjectMap
├── parser/ — line-by-line parser (text → Value)
├── thin/ — arena-backed borrowed parse (parse_events)
├── render/ — pretty-printer, canonical writer, formatter
├── ser/ — serde::Serializer (T: Serialize → Value)
├── de/ — serde::Deserializer (Value → T: Deserialize)
├── error/ — Error, ErrorKind, ErrorEnvelope, serde::Error
├── bin/ktav-fmt.rs — the ktav-fmt command-line formatter
└── lib.rs — glue: from_str / to_string / format_str / …
Each file holds one exported item; implementation details are private to their parent module.
- 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.
[dependencies]
ktav = "0.8.0"
serde = { version = "1", features = ["derive"] }The formatter is also available as a binary, behind an off-by-default feature:
cargo install ktav --locked --features cli
ktav-fmt --check config.ktavThe 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.
Dual-licensed under MIT OR Apache-2.0 at your option. See LICENSE-MIT and LICENSE-APACHE.
spec— specification + conformance suitecsharp— C# / .NET (dotnet add package Ktav)golang— Go (go get github.com/ktav-lang/golang)java— Java / JVM (io.github.ktav-lang:ktavon Maven Central)js— JS / TS (npm install @ktav-lang/ktav)php— PHP (composer require ktav-lang/ktav)python— Python (pip install ktav)