Skip to content

Latest commit

 

History

247 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Ktav (כְּתָב)

Crates.io docs.rs CI License: MIT OR Apache-2.0 Playground

A plain configuration format. JSON5-shaped, but without the quotes, without the commas, with dotted keys for nesting. Native serde integration.

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.


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.

## 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.

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 +), 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.

Booleans: true / false

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

Null: null

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.

Empty object / empty array

The only inline compound values allowed — nothing to separate, no commas needed.

## 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:

## 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.

## 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
]

Corners worth knowing

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.

Quoted keys — when a key contains a dot or a space

A dot in a bare key means nesting: db.host: primary builds dbhost. 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.

Escapes apply inside inline compounds and quoted keys

\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.

Exactly one leading byte-order mark is skipped

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.

Invalid UTF-8 is its own error, not an I/O failure

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.

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

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

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

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.

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.

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.rscargo run --example errors.

One JSON envelope for every structured error

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.

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). 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.

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.

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.rscargo 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

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.

2. Dotted keys = nested objects

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.

3. Nested object as a value

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" }
  }
}

4. Array of scalars

banned_patterns: [
    .*\.onion:\d+
    .*:25
]
{
  banned_patterns: [".*\\.onion:\\d+", ".*:25"]
}

5. Array of objects

upstreams: [
    {
        host: a.example
        port: 1080
    }
    {
        host: b.example
        port: 1080
    }
]
{
  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:

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."

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.

8. Comments

## 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:

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.

10. Empty compounds

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.

#[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
}

Round-trip

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 orderValue::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).

Formatting — canonical spelling, comments kept

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.

ktav-fmt — the optional command-line formatter

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.

The C ABI for other languages

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.

Architecture

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.

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

[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.ktav

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 and LICENSE-APACHE.

Other Ktav implementations

  • spec — specification + conformance suite
  • csharp — C# / .NET (dotnet add package Ktav)
  • golang — Go (go get github.com/ktav-lang/golang)
  • java — Java / JVM (io.github.ktav-lang:ktav on Maven Central)
  • js — JS / TS (npm install @ktav-lang/ktav)
  • php — PHP (composer require ktav-lang/ktav)
  • python — Python (pip install ktav)

About

Rust implementation of Ktav — a plain configuration format with three rules, zero indentation, and zero quoting. Zero unsafe, serde-native

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages