Languages: English · Русский · 简体中文
Draft scope: the feature overview and examples in this README follow the unreleased Ktav 0.7.0 draft. Stable 0.6.4 implementations should use the 0.6.4 specification and its conformance suite; the draft's differences are scoped in Appendix D of the 0.7.0 specification.
Playground: convert JSON / YAML / TOML / INI ⇄ Ktav in your browser at ktav-lang.github.io.
A plain configuration format. JSON-shape — scalars, arrays, objects,
null,true,false— with none of JSON's punctuation in the common case: no quotes around strings, no commas outside one-line inline compounds, and a closed 14-entry escape table for literal bytes and explicit scalar classification. Dotted keys for nesting, visible opt-in markers for literal and multi-line strings.
This repository is the canonical specification of the Ktav format. Implementations in any programming language are expected to conform to the version they target.
One example that exercises every major form the format offers —
: pairs (a bare number is typed by its form, everything else is a
String), keyword Bool, :: (forced literal String), dotted keys,
nested compounds, and a multi-line string.
## A config for a SOCKS5 rotator.
port: 20082
log_level: info
debug: true
banned_patterns: [
.*\.onion:\d+
.*\.local
]
upstreams: [
{
host: a.example
port: 1080
weight: 0.7
timeouts: {
read: 30
write: 10
}
}
{
host: b.example
port: 1080
weight: 0.3
}
]
## Dotted keys — flat alternative to nesting.
node.host: a.example
node.port: 1080
## `::` forces a literal string — keeps the ':' inside the password.
node.auth:: p@ss:word
motd: (
Welcome to the node.
Please behave.
)
Parses to this value (shown as JSON5 — comments and unquoted keys for readability). Note how the values map:
:with a bare integer body (20082) — Integer; with a bare decimal body (0.7) — Float; any other body (info, a regex, a path) — String, verbatim, even for digit-ish content.: true/: false/: null— Bool / Null keywords.::— forced literal String, no classification applied.
{
port: 20082,
log_level: "info",
debug: true,
banned_patterns: [
".*\\.onion:\\d+",
".*\\.local",
],
upstreams: [
{
host: "a.example",
port: 1080,
weight: 0.7,
timeouts: { read: 30, write: 10 },
},
{
host: "b.example",
port: 1080,
weight: 0.3,
},
],
node: {
host: "a.example",
port: 1080,
auth: "p@ss:word",
},
motd: "Welcome to the node.\nPlease behave.",
}On the minimum required domain, the format types a scalar from the
shape of its body: a bare integer becomes an Integer, a bare decimal
becomes a Float, and everything else stays a String. No marker is
needed. Nothing that merely looks number-ish but isn't a bare number
(a version, a label) is coerced — and :: forces a genuine bare number
to stay a literal string when you need that. On this minimum domain, a
bare integer outside i64, or a bare decimal that overflows to non-finite
on binary64, is kept as a String; a wider implementation MAY retain the
literal as Integer or Float. A decimal that underflows in the tested
implementation's domain still becomes a Float, rounded to signed
0.0 in that domain, not a String; a wider domain in which it does
not underflow retains a non-zero Float. The abstract programmatic Float
carrier MUST distinguish NaN, +Infinity, and -Infinity so writer-conformance
tests can supply the three non-finite sentinels; those sentinels are outside
the parseable and canonical domains. Parsed Floats are finite members of the
declared Float domain. That domain includes its decimal-conversion and
rounding semantics and MUST admit only finite Float values. Every non-zero finite Float MUST have a finite decimal
representation that round-trips exactly; signed zeros are handled
separately and remain 0.0 / -0.0;
the minimum binary64 conversion uses roundTiesToEven. An unsupported
exact-rational value such as 1/3 is outside the declared Ktav Float domain
and is not a parseable or canonical Float.
retries: 3
version: 1.2
build:: 0007
label: v1.2
{
retries: 3, // bare integer — Integer
version: 1.2, // bare decimal — Float
build: "0007", // `::` — forced literal String
label: "v1.2", // not a bare number — String
}A body that would otherwise be classified as a keyword (null,
true, false), an empty compound ({}, []), or start a compound
({, [) needs the raw :: marker to come out as a plain String.
## Would be Bool true without `::` — here it's the string "true".
on_release:: true
## Starts with `[` — `::` prevents "open array" interpretation.
regex:: [a-z]+
## IPv6 address literal — same reason.
ipv6:: [::1]:8080
## `null` keyword used as a literal four-char string.
placeholder:: null
{
on_release: "true",
regex: "[a-z]+",
ipv6: "[::1]:8080",
placeholder: "null",
}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 explicit, visible brackets above it. No indentation pitfalls, no forgotten quotes, no trailing-comma arithmetic.
| Feature | JSON | YAML | TOML | Ktav |
|---|---|---|---|---|
| Bare strings (no mandatory quoting) | ✗ | ~ | ✗ | ✓ |
| Comma-free lists | ✗ | ✓ | ✓ | ✓ |
| Whitespace-insensitive (no indentation pitfalls) | ✓ | ✗ | ✓ | ✓ |
| Human-writable multi-line strings | ✗ | ~ | ~ | ✓ |
| Native comments | ✗ | ✓ | ✓ | ✓ |
| Dotted keys for flat edits | ✗ | ✗ | ✓ | ✓ |
| One parser, small spec | ✓ | ✗ | ~ | ✓ |
✓ = feature present · ✗ = absent · ~ = partial
Ktav keeps JSON's shape (you always know what a document means) but strips the syntax that makes JSON hostile to write by hand. It keeps TOML's dotted keys (handy for flat edits and CLI overrides) but drops TOML's two-dimensional table-vs-inline split.
A Ktav document's root is an Object or Array determined by its first content line. Inside any object you have pairs; inside any array you have items.
## comment — any line starting with '##'
key: value — scalar pair; bare number → Integer/Float,
any other body → String
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: { a: 1, b: 2 } — inline object, one line, comma-separated
key: [ 1, 2 ] — inline array, one line, comma-separated
key: {} / key: [] — empty compound, inline
key: ( ... ) — multi-line string; common indent stripped
key: (( ... )) — multi-line string; verbatim (no stripping)
value — inside an array: bare item (typed by form)
:: value — inside an array: literal-string item
That's the whole language. No commas or quotes are required for the
common case — commas appear only as separators inside one-line inline
compounds — and the closed 14-entry escape table (§ 3.7) provides
literal-byte escapes and explicit scalar classification. The :: marker
(in the separator
for pairs, or as a line prefix for array items) forces a literal
string.
Keys may be dotted paths. These two documents are identical:
server.host: 127.0.0.1
server.port: 8080
server: {
host: 127.0.0.1
port: 8080
}
Dotted keys mix freely with nested form. The parser builds the same tree either way. Useful for:
- CLI/env overrides (
--set server.port=9090). - Partial edits in small configs without restructuring.
- Flat-first files that grow nested sections as needed.
A key whose decoded text begins with ## may use the bare input form
\u0023#a\:b: 1; the \u0023 escape is accepted input. A canonical writer
MUST quote that key instead, for example "##a:b": 1, so the output cannot
be mistaken for a comment.
A non-compound scalar body is trimmed at both edges before it is classified. A non-empty body that is not a keyword or numeric literal is a String, so internal whitespace and punctuation remain part of the value. No quoting means no quoting rules — paths, URLs, regexes, tokens with punctuation all just work.
pattern: .*\.onion:\d+
url: https://example.com:8080/path?x=1
key: s3cret/with:colons and-dashes
padded: hello
Here padded has the String value hello: separator padding and body
edge whitespace are trimmed before classification. When a string would
collide with grammar (starts with { or [, equals a keyword like
true, or is exactly one of (, ((, (), (())), prefix the
separator with :::
literal_bracket:: [
keyword_as_string:: true
A bare number is typed directly on the minimum required domain —
port: 8080 gives you an Integer, ratio: 0.5 a Float. The body's
shape decides: digits only → Integer; digits with a decimal point or
exponent → Float; anything else → String. At the minimum numeric
boundaries, an integer outside i64 or a decimal that overflows to
non-finite on binary64 is kept as a String instead of wrapping or
raising an error; a wider domain MAY classify that same boundary literal
as Integer or Float. A decimal that underflows in the implementation's
domain still becomes a Float, rounded to signed 0.0 in that domain;
a wider domain in which it does not underflow retains a non-zero Float.
The abstract programmatic Float carrier MUST distinguish NaN, +Infinity,
and -Infinity for writer-conformance; these sentinels are outside the
parseable and canonical domains. Parsed Floats are finite members of the
declared Float domain, which includes decimal-conversion and rounding
semantics; every admitted non-zero finite Float MUST have a finite decimal
candidate that round-trips exactly. Signed zeros are handled separately
and remain 0.0 / -0.0; minimum binary64 uses roundTiesToEven. A
finite host value without such a candidate, such as exact-rational 1/3,
is outside the declared Ktav Float domain and is not a parseable or
canonical Float.
port: 8080
ratio: 0.5
offset: -100
eps: 1.5e-10
Numbers are Values carrying a numeric value, not the text they were
written as — the writer emits a normalised canonical form (spec
section 5.9.8), so 0.50 comes back as 0.5 and 1e2 as 100.0
(the decimal point stays even for a whole-number Float, so a
re-parse doesn't turn it into an Integer). A bare integer within the
guaranteed i64 range round-trips exactly as an Integer; i64 (Integer)
and binary64 (Float) are the portable minimum every implementation
guarantees — an implementation may support wider domains (arbitrary
precision / decimal), and a literal overflowing its own supported
domain falls back to a String. To keep a numeric-looking value as text
regardless of size, force it with ::
(zip:: 01007).
Two forms, different goals:
stripped: (
line 1
line 2
relative indent preserved
)
verbatim: ((
line 1
exact leading whitespace preserved
line 3
))
( strips the common leading indent — write code/text that reads
well in the file, the value comes out clean. (( preserves every
content-line byte after line-ending normalization; it does not preserve
the bytes of the whole document.
Lowercase only: null, true, false. Anything else — Null,
TRUE, yes, on — is a plain string. No magic type coercion,
no versioned gotcha list.
port: 8080
active: true
timeout: null
{
port: 8080, // bare integer → Integer
active: true, // keyword → native JSON bool
timeout: null, // keyword → native JSON null
}- Draft described here: Ktav 0.7.0 — unreleased draft; this README's feature overview follows it.
- Current stable: Ktav 0.6.4 — released 2026-08-23; use this version for stable semantics and fixtures.
- Machine-readable index of released/stable versions:
versions.ktav. - History across versions:
CHANGELOG.md.
Every version ships a language-agnostic test suite under
versions/<v>/tests/. The unreleased 0.7.0 draft
has four fixture categories (valid/, invalid/, unrepresentable/, and
parseable-unrepresentable/) plus one top-level metadata file. The stable
0.6.4 corpus has only valid/ and invalid/. A conformance runner MUST
walk every fixture category present in the version it targets — silently
skipping one it doesn't recognise reports false-green, which is worse than
having no fixtures for it at all.
boundary-fixtures.json(0.7+, not a fixture category) — a leaf-level list of individual Object fields, inside otherwise-normalvalid/fixtures, known to probe a numeric-domain boundary (spec § 5.2, § 8.1, § 8.2), e.g. an i64-overflow or Float-overflow literal, tagged with which axis it probes (integer_range,float_range,float_underflow,float_precision). It lives at thetests/root, not insidevalid/, specifically so a runner enumeratingvalid/**/*.jsonas fixtures never mistakes it for one. Listing a leaf there doesn't say what a wider-domain implementation's output must be at that field — only that an implementation is exempt from matching it byte-for-byte, and only if it genuinely supports a domain wider than the minimum along that specific axis; every other field of the same fixture, and every fixture or field not listed, carries no exemption for any implementation.valid/— parseable documents. Each case is a<name>.ktav+<name>.json+<name>.canonical.ktavtriple:.ktavis the input;.jsonis the expected parsedValue, mapped 1:1 (Null→null,Bool→bool,String→string,Array→array,Object→object). A JSON number token with no.,e, orEdenotes Integer; one containing any of them denotes Float, including-0.0. Every other scalar stays a string and::forces a literal string..canonical.ktavis the expected byte-exact writer output for that sameValue. Object field order is significant.invalid/— documents a conforming parser MUST reject. Each case is a<name>.ktav+<name>.jsonpair; the.jsonnames the expected error category in itsexpected_errorfield.unrepresentable/(0.7+) —Values a conforming writer MUST refuse to serialise rather than emit lossy or partial output. These programmatic-only cases have one<name>.jsoneach, with exactlyvalue,unrepresentable_reason, and non-emptynote; the Value mapping and exact$floatsentinel shape are defined by § 5.9.0. The$floatsentinel is contextual to this fixture encoding and does not reserve$floatas a parser Object key name. ForNonFiniteFloat, it denotes the abstract programmatic Float carrier, not a parsed or canonical Float, and is outside node-representability. OnlyScalarRoot,EmptyKeyName, andNonFiniteFloatare allowed. The reason code MUST have a recursive witness and MUST NOT be inferred from the filename.parseable-unrepresentable/(0.7+) — parser-produced Values which a conforming writer MUST refuse. Each case is a<name>.ktav+<name>.jsonpair; parsing the input MUST produce the JSONvalue, and writing it MUST fail with the named reason code. Only the String reasonsCRByte,BothFormsRequired,TrailingWhitespaceCollision, andLeadingWhitespaceCollisionare allowed. These are intentionally pairs, with no other files and no canonical-output file.
The versioned scripts/locks/corpus-inventory.0.6.lock.json maps every
corpus-relative file path in the stable 0.6.4 valid/ and invalid/
directories to its SHA-256 digest. The versioned
scripts/locks/corpus-inventory.0.7.lock.json maps every 0.7 path in
valid/, invalid/, unrepresentable/, and
parseable-unrepresentable/, plus boundary-fixtures.json. CI passes the
matching lock to validate_corpus.py --corpus-inventory-lock:
python scripts/validate_corpus.py versions/0.6/tests \
--corpus-inventory-lock scripts/locks/corpus-inventory.0.6.lock.json
python scripts/validate_corpus.py versions/0.7/tests \
--require-unrepresentable --require-boundary \
--boundary-manifest-lock scripts/locks/boundary-fixtures.0.7.lock.json \
--corpus-inventory-lock scripts/locks/corpus-inventory.0.7.lock.jsonEach lock rejects additions, deletions, content drift, and unknown top-level entries; it supplements rather than replaces semantic and schema validation.
Passing every test in every category present in that version's suite
is a necessary release gate, but not by itself sufficient proof of
conformance: boundary-fixtures.json (0.7+) tells the shared corpus
to skip an exact byte/Value check on specific leaves for an
implementation whose numeric domain is wider than the minimum along
that leaf's axis — spec § 8.1 / § 8.2 define what such an
implementation's correctness there actually depends on (§ 5, § 5.9),
and the shared corpus does not verify it. An implementation that
declares a wider numeric domain MUST additionally verify its own
behaviour against § 5 / § 5.9 for the domain it claims, beyond what
this language-agnostic suite checks. Consume the
directory as a git submodule (or copy it).
Spec versions use MAJOR.MINOR.PATCH:
| Bump | Means |
|---|---|
x.y → x.y.(z+1) |
Editorial — typo fixes, clarifications; conforming implementations are unaffected. |
x.y → x.(y+1) |
Backward-compatible extension (new keyword, new primitive form). |
x.y → (x+1).0 |
Breaking change in grammar or semantics. |
Pre-1.0 exception: while MAJOR is 0, a MINOR bump MAY carry
a breaking change instead of requiring a MAJOR bump (0.7.0 does
this over 0.6.x). Once the format reaches 1.0, breaking changes
strictly require a MAJOR bump as the table states.
Within any stable MAJOR, an implementation targeting x.0 MUST
parse every document valid under any later x.y.z identically up to
the subset it supports — except across a pre-1.0 breaking MINOR
bump per the exception above, where this guarantee does not hold.
Each version's directory is fully self-contained: spec.md, a
tests/ conformance suite, and per-version addenda. Implementations
pin to a version directory by path.
.
├── README.md this file
├── versions.ktav machine-readable index of released/stable versions
├── CHANGELOG.md summary across versions
├── CONTRIBUTING.md how to propose changes
├── LICENSE-MIT MIT License
├── LICENSE-APACHE Apache License 2.0
├── scripts/
│ ├── validate_corpus.py structural validation of the conformance corpus
│ ├── test_validate_corpus.py unit tests for validate_corpus.py
│ ├── check_translation_parity.py EN/RU/ZH translation-parity checker
│ ├── test_check_translation_parity.py unit tests for check_translation_parity.py
│ ├── build_spec.mjs (0.7+) generates spec.md/.ru.md/.zh.md from content/
│ ├── test_build_spec.mjs (0.7+) adversarial unit tests for build_spec.mjs
│ ├── archive/ (0.7+) archived one-time content-unit bootstrap
│ │ └── extract_content_units.py see content/README.md; refuses to overwrite content/
│ └── locks/ versioned corpus, boundary, and section-inventory lock files
│ ├── corpus-inventory.0.6.lock.json (0.6.4 valid/ and invalid/ paths + SHA-256)
│ ├── corpus-inventory.0.7.lock.json (0.7 corpus paths + SHA-256)
│ ├── boundary-fixtures.0.7.lock.json (0.7 boundary leaves: fixture, path, class)
│ └── section-inventory.0.7.lock.json (0.7 ordered sections + structural metadata)
├── .github/workflows/ CI: content/ byte-identity check (0.7+), corpus validation,
│ translation-parity check, and all three unit test suites
└── versions/
└── <version>/
├── spec.md the specification document
├── spec.ru.md Russian translation of the spec
├── spec.zh.md Chinese translation of the spec
├── content/ (0.7+) per-section source of truth — see content/README.md;
│ spec.md/.ru.md/.zh.md are generated from this, never hand-edited
└── tests/ language-agnostic conformance suite
├── valid/
├── invalid/
├── unrepresentable/ (0.7+)
├── parseable-unrepresentable/ (0.7+; pairs, no canonical output)
└── boundary-fixtures.json (0.7+) leaf-level numeric-
domain exemptions, not a fixture category
| Language | Repo | Install |
|---|---|---|
| Rust (reference) | ktav-lang/rust |
cargo add ktav |
| C# / .NET | ktav-lang/csharp |
dotnet add package Ktav |
| Go | ktav-lang/golang |
go get github.com/ktav-lang/golang |
| Java / JVM | ktav-lang/java |
GitHub Releases (Maven Central pending) |
| JS / TS | ktav-lang/js |
npm install @ktav-lang/ktav |
| PHP | ktav-lang/php |
composer require ktav-lang/ktav |
| Python | ktav-lang/python |
pip install ktav |
The Rust crate is the reference parser, and every binding embeds that
same core. Go, Java, PHP and C# consume it through a prebuilt
ktav_cabi (the C ABI wrapper), whose function surface has grown
additively across releases — 0.6.4 added ktav_loads_strict
alongside the existing functions. Python ships a dedicated PyO3
native extension rather than the C ABI, and JS ships several
runtime-specific artifacts — WASM for browsers, N-API for Node, plus
a C ABI path — instead of a single binding shape. All of them parse
whatever format version the underlying Rust core supports (currently
0.6.4 stable); the language-agnostic tests/ suite below runs
against all of them on every release.
Building a new implementation? Start with your target version's
spec.md (section 8 — Compliance) and run
the tests/ suite against your parser.
Editorial fixes inside an existing version — PR directly. Anything
else — open an issue first. See CONTRIBUTING.md.
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.
Dual-licensed under MIT OR Apache-2.0 at your option. See LICENSE-MIT and LICENSE-APACHE.