Languages: English · Русский · 简体中文
Playground: convert JSON / YAML / TOML / INI ⇄ Ktav in your browser at ktav-lang.github.io.
.NET bindings for the Ktav configuration format.
Thin wrapper around the reference Rust parser, loaded at runtime through
P/Invoke — no native build on the consumer side, plain dotnet add package just works.
Targets net8.0 (with AOT-ready LibraryImport) and netstandard2.0
(DllImport, no NativeLibrary resolver — use NuGet's runtimes/
layout or system native-library search paths; KTAV_LIB_PATH is ignored).
dotnet add package KtavThe package also works with no native binary at all — see
Pure backend (Ktav.UsePureBackend() or
KtavBackend.Pure per call).
using Ktav;
const string src = """
service: web
port: 8080
ratio: 0.75
tls: true
tags: [
prod
eu-west-1
]
db.host: primary.internal
db.timeout: 30
""";
var top = (KtavObject)global::Ktav.Ktav.Loads(src);
string service = ((KtavString) top.TryGet("service")!).Value;
long port = ((KtavInteger) top.TryGet("port")!).ToInt64();
double ratio = ((KtavFloat) top.TryGet("ratio")!).ToDouble();
bool tls = ((KtavBool) top.TryGet("tls")!).Value;
var db = (KtavObject) top.TryGet("db")!;
string dbHost = ((KtavString) db.TryGet("host")!).Value;
long dbTimeout = ((KtavInteger) db.TryGet("timeout")!).ToInt64();foreach (var entry in top.Entries)
{
string kind = entry.Value switch
{
KtavNull => "null",
KtavBool b => $"bool={b.Value}",
KtavInteger i => $"int={i.Text}",
KtavFloat f => $"float={f.Text}",
KtavString s => $"str=\"{s.Value}\"",
KtavArray a => $"array({a.Items.Count})",
KtavObject o => $"object({o.Entries.Count})",
_ => throw new InvalidOperationException(),
};
Console.WriteLine($"{entry.Key} -> {kind}");
}using System.Collections.Generic;
KtavObject Upstream(string host, long port) => new(new[]
{
new KeyValuePair<string, KtavValue>("host", new KtavString(host)),
new KeyValuePair<string, KtavValue>("port", KtavInteger.Of(port)),
});
var doc = new KtavObject(new[]
{
new KeyValuePair<string, KtavValue>("name", new KtavString("frontend")),
new KeyValuePair<string, KtavValue>("port", KtavInteger.Of(8443)),
new KeyValuePair<string, KtavValue>("tls", KtavBool.True),
new KeyValuePair<string, KtavValue>("ratio", KtavFloat.Of(0.95)),
new KeyValuePair<string, KtavValue>("upstreams", new KtavArray(new KtavValue[]
{
Upstream("a.example", 1080),
Upstream("b.example", 1080),
})),
new KeyValuePair<string, KtavValue>("notes", KtavNull.Instance),
});
string text = global::Ktav.Ktav.Dumps(doc);A complete runnable version lives in examples/Basic.
| Member | Purpose |
|---|---|
Ktav.Loads(string) -> KtavValue |
Parse a Ktav document into the KtavValue tree. |
Ktav.LoadsStrict(string) -> KtavValue |
Parse with strict numeric spelling checks. |
Ktav.Dumps(KtavValue) -> string |
Render a KtavValue back as Ktav text. Top-level must be KtavObject or KtavArray. |
Ktav.DumpsForceStrings(KtavValue) -> string |
Like Dumps, but coerces every leaf scalar to a String via the raw :: marker. Compounds keep their structure. |
Ktav.EmitCanonical(KtavValue) -> string |
Render a KtavValue as canonical Ktav (spec § 5.9). |
Ktav.Format(string) -> string |
Format Ktav source into its normalised spelling, keeping every comment. See below. |
Ktav.EditValue(string, string, KtavValue) -> string |
Losslessly replace the value at a dotted textual path in the source text. See the Editing section. |
Ktav.CanonicalFromSource(string) -> string |
Parse and re-emit as canonical Ktav in one call — EmitCanonical(Loads(src)) with no intermediate KtavValue. Drops comments and blank lines like EmitCanonical does. |
Ktav.NativeVersion() |
Version string reported by the loaded ktav_cabi. |
Ktav.ExpectedNativeVersion |
Version this build was compiled against. |
Format and EmitCanonical are different operations:
EmitCanonicaltakes aKtavValueand writes the canonical form. A value carries no comments, so none can survive.Formattakes source text and rewrites its spelling while preserving every comment verbatim (spec § 3.4: a comment owns a whole line). Key order is never changed — spec § 5.9 has no sorting rule.
global::Ktav.Ktav.Format("## why\na: {x: 1}\n");
// "## why\na: {\n x: 1\n}\n"
// the comment survives; the inline compound becomes canonical
// multi-line formA run of two or more blank lines collapses to one, and blank padding
immediately inside a bracket is dropped, which makes formatting a fixed
point: Format(Format(x)) == Format(x). For a document with no comments
and no blank lines, the output equals EmitCanonical(Loads(src)).
Unreleased (0.9 preparation): These editing APIs and updated diagnostics are not included in published 0.8.1 packages. Native backends require the pinned 0.9 core (C ABI 2 for C-FFI); pure backends remain standalone.
Ktav.EditValue replaces the value at a path in the source text
itself — a lossless splice, not a parse→mutate→serialize round trip:
var out1 = Ktav.EditValue(src, "targets.storagehost.address", new KtavString("10.0.0.9"));
var out2 = Ktav.EditValueSegments(src, new[] { "targets", "storagehost", "address" }, new KtavString("10.0.0.9"));| Member | Purpose |
|---|---|
Ktav.EditValue(string src, string path, KtavValue replacement) -> string |
Replace the value at a dotted textual path (key1.key2.0.key4; \. is a literal dot, \\ a literal backslash). |
Ktav.EditValueSegments(string src, IReadOnlyList<string> segments, KtavValue replacement) -> string |
Same, with pre-decoded literal segments (no extra unescaping). |
Guarantees:
Ktav.EditValuereplaces an existing value only; it does not create, remove, or rename values. Missing keys, out-of-range array indices, or descending through a scalar produceEditNotFound; malformed paths produceEditInvalidPath(Bodyis the offender's UTF-8 byte offset).EditValuereturnsEditUnsupportedwhen no lossless patch is possible, such as for a noncontiguous dotted/reopen target or comments inside a structurally replaced container.- Bytes outside an edit are preserved exactly: comments, whitespace, key spelling and order, BOM, CR/LF/CRLF, and whether a final newline is present. A no-op replacement returns the input byte-identically, even with non-canonical spelling.
- Inline containers are expanded minimally (four-space indentation, source line endings) only when a replacement would otherwise hurt readability; untouched nested branches stay inline. Readable whole inline containers stay inline; emptied block containers keep their braces.
- After each patch the result is reparsed and semantically verified, so corruption is not silently returned.
Insert creates only a new final Object key under an existing Object parent: no overwriting, implicit intermediate containers or Array insertion. Remove accepts a leaf, subtree or Array element and never prunes empty ancestors. Rename takes one literal newKey, not a path, preserves member order and value, and returns the input byte-identically for the same key. Collisions return EditAlreadyExists; missing targets or parents return EditNotFound. Native and standalone Pure follow this contract and are checked independently in CI.
| Member | Purpose |
|---|---|
Ktav.InsertValue(string src, string path, KtavValue value) -> string |
Insert a new final object key; textual paths use the same escaping rules. Supported; Pure may return EditUnsupported when it cannot make a lossless splice. |
Ktav.InsertValueSegments(string src, IReadOnlyList<string> segments, KtavValue value) -> string |
Same insertion using pre-decoded literal segments. Supported; Pure may return EditUnsupported when it cannot make a lossless splice. |
Ktav.RemoveValue / Ktav.RemoveValueSegments |
Remove a value. Supported by Native and Pure; Pure may return EditUnsupported for targets with comments or noncontiguous dotted/reopen targets. |
Ktav.RenameKey / Ktav.RenameKeySegments |
Rename an object key. Supported by Native and Pure; Pure may return EditUnsupported for array elements or noncontiguous dotted/reopen targets. |
These examples describe the unreleased 0.9 API, not the published 0.8.1 package. They illustrate replacement of existing values; insertion, removal and key renaming are documented in the source-edit API section. Each snippet is independent; Rust ? assumes an enclosing Result-returning function. Go examples panic only for these fixed, known-valid inputs; applications should handle the returned error.
The binding examples use the standalone pure backend, without a native binary. Native entry points accept the same paths and values: ktav.EditValue (Go), Ktav.editValue (Java), KtavApi.EditValue(..., KtavBackend.Native) (C#), Ktav::editValue (PHP), the package's native/FFI exports (JS), and ktav.edit_value (Python). C-ABI backends require ABI 2; PyO3/N-API/WASM require the matching preparing-0.9 core. Bun/Deno FFI calls return promises: use await; pure/Node/WASM examples shown here are synchronous.
using Ktav;
using KtavApi = global::Ktav.Ktav;Only the address changes; the comment, dotted form and neighbouring user entry remain byte-for-byte unchanged.
var source = "## deployment\ntargets.storagehost.address: old\ntargets.storagehost.user: mcp-agent\n";
var updated = KtavApi.EditValue(source, "targets.storagehost.address", new KtavString("192.168.0.88:22"), KtavBackend.Pure);Result:
## deployment
targets.storagehost.address: 192.168.0.88:22
targets.storagehost.user: mcp-agent
The quoted source key is literally a.b. The textual path escapes its dot; the segments call passes the decoded key directly. Both calls return the same result.
var source = "\"a.b\": {port: 80}\n";
var updated = KtavApi.EditValue(source, "a\\.b.port", new KtavInteger("90"), KtavBackend.Pure);
var same = KtavApi.EditValueSegments(source, new[] {"a.b", "port"}, new KtavInteger("90"), KtavBackend.Pure);Result:
"a.b": {port: 90}
0 is an array index here. Inside an Object, the same segment would address the key named 0; untouched containers remain inline.
var source = "servers: [{host: old, keep: yes}, standby]\n";
var updated = KtavApi.EditValueSegments(source, new[] {"servers", "0", "host"}, new KtavString("new"), KtavBackend.Pure);Result:
servers: [{host: new, keep: yes}, standby]
The replacement is a String, not an Integer. The raw marker preserves that distinction. Supply a typed Integer when a numeric value is intended.
var source = "port: 8080\n";
var updated = KtavApi.EditValue(source, "port", new KtavString("9090"), KtavBackend.Pure);Result:
port:: 9090
The changed array expands with four-space indentation. The untouched object stays inline, and verbatim string contents are not reindented.
var source = "a: [old, {keep: yes}]\n";
var updated = KtavApi.EditValue(source, "a.0", new KtavString("x\ny"), KtavBackend.Pure);Result:
a: [
((
x
y
))
{keep: yes}
]
The Integer value is already 1. Equality returns the original bytes, including +1 and the original separator spacing; readability thresholds are bypassed.
var source = "n : +1\n";
var updated = KtavApi.EditValue(source, "n", new KtavInteger("1"), KtavBackend.Pure);
if (updated != source) throw new System.InvalidOperationException("no-op changed source");Result:
n : +1
The returned string keeps the BOM and CRLF, and does not gain a final newline. Its escaped representation is shown to make the bytes visible.
var source = "\ufeffport: 8080\r\nhost: db";
var updated = KtavApi.EditValue(source, "port", new KtavInteger("9090"), KtavBackend.Pure);Result:
"\uFEFFport: 9090\r\nhost: db"
Expansion is deterministic: a multiline replacement, growth beyond max(100, original line length), or at least four escapes with at least 25% encoding overhead for an individual replacement string. Width counts Unicode scalar values, not UTF-8 bytes or terminal columns. An already-long line without growth is kept. New levels use four spaces and the source EOL. If CR or a )) data line prevents lossless standalone encoding, a valid inline candidate is retained; these characters do not automatically make every inline edit unsupported.
The source Integer domain is signed 64-bit. This value exceeds JavaScript's safe-Number range but fits i64; use an exact host representation, not a rounded floating-point value. A typed Integer outside i64 cannot be preserved as an Integer in source and is refused instead of truncated. Use a String when a large identifier is text. Float identity distinguishes negative and positive zero; Object no-op equality ignores key order, while Array order remains significant.
var updated = KtavApi.EditValue("n: 1\n", "n", new KtavInteger("9007199254740993"), KtavBackend.Pure);n: 9007199254740993
Source coordinates are UTF-8 byte offsets, including a BOM; spans are half-open [start, end), and lines are 1-based. LF, lone CR and CRLF are handled, with CRLF counted as one terminator. A Unicode character may occupy several bytes.
An unfinished a: [\n reports UnclosedCompound, line 1 and span 3..5: the span begins at the innermost unmatched opener, not the start of its key or line. For raw bytes 61 3A 20 FF (a: followed by invalid UTF-8), validation reports InvalidUtf8, line 1 and a covering span 3..4; line_text is null rather than fabricated by lossy decoding. UTF-8 validation precedes grammar processing.
A malformed textual path, including an empty textual path or empty textual segment, is EditInvalidPath; its offset is measured in the path's UTF-8 bytes. An empty segments list, missing key, out-of-range index or scalar descent is EditNotFound. Object numeric segments remain keys; array indexes use ASCII digits, allow leading zeros and reject signs.
Whole dotted/reopened intermediate objects may occupy several physical ranges and are EditUnsupported; their existing leaves can still be edited. Replacing a container must not discard interior comments. No failed call returns a partial patch or changes the supplied source. The returned text is re-parsed and semantically checked: the target must equal the replacement and the rest of the value tree must remain equal.
For bindings, invalid source is reported with the ordinary parser envelope before path validation. Malformed wire payloads are Message errors without invented source coordinates. The wire envelope has error, reason, line, line_text, span, path, body, canonical, spec_section and message; host accessor names are documented in the errors section. body carries the decimal path-byte offset for EditInvalidPath, null for EditNotFound, and the refusal reason for EditUnsupported.
Native parse, format, and render failures are reported as
KtavException. Beyond the message it carries the native structured
error envelope, so a tool can act on the fields instead of parsing prose.
This does not make every failure a KtavException: null arguments use
ArgumentNullException, host-side argument validation uses standard .NET
argument exceptions, and native library loading can raise loader exceptions:
try { global::Ktav.Ktav.Loads("a: 1\na: 2\n"); }
catch (KtavException e)
{
Console.WriteLine(e.Error); // "DuplicateKey"
Console.WriteLine(e.Line); // 2
Console.WriteLine(e.SpecSection); // "§6.2"
}| Member | Meaning |
|---|---|
Message |
Human-readable rendering of the failure. |
Error |
Structured error class — DuplicateKey, Unrepresentable, Message. |
Reason |
Writer-time reason code (spec § 5.9.0) such as NonFiniteFloat; null for parse errors. |
Line |
1-based source line; null when not applicable. |
LineText |
Text of the offending line. |
Span |
KtavErrorSpan? — byte offsets into the UTF-8 source. |
Path |
Exact decoded key segments. |
Body |
The offending value as written. |
Canonical |
What the canonical form would have been. |
SpecSection |
The clause violated, e.g. §3.6/§5.2. |
Two details that are easy to get wrong:
Spanholds byte offsets into UTF-8, not UTF-16 code units, which is what .NET strings are indexed by. Convert before handing them to anything that expectsstringindices, or to an LSP client that has not negotiatedpositionEncoding: "utf-8".Pathis a list of segments, never a joined string. A key literally nameda.bis one segment and cannot be confused with a two-segment path.
Mirrors the Rust crate's Value enum — one record per Ktav primitive,
no lossy coercions:
| Ktav | KtavValue variant |
|---|---|
null |
KtavNull.Instance |
true / false |
KtavBool |
| bare integer in the core's signed 64-bit range | KtavInteger (text form — ToBigInteger() / ToInt64()) |
| bare decimal | KtavFloat (text form — ToDouble()) |
| other scalar | KtavString |
[ ... ] |
KtavArray (IReadOnlyList<KtavValue>) |
{ ... } |
KtavObject (key insertion order preserved) |
An integer outside the core's signed 64-bit range loads as KtavString,
not KtavInteger. KtavInteger and KtavFloat expose their stored text,
but this does not promise arbitrary-precision Ktav numbers or preservation
of the source's decimal spelling: for example, 1.10 loads as 1.1.
The public records can be constructed with other text, but native writing
still enforces the core spec domain.
Since spec 0.6.4 a literal . or : inside a key segment is written
with a backslash:
a\.b: v
a\:b: v
x.y\.z: v
These parse respectively to the single keys a.b and a:b, and to the
nested keys x then y.z. Keep explanations outside Ktav examples: inline
// text is value content, not a comment. Ktav comments use a whole line
starting with ##.
A literal backslash in a key is \\.
Since 0.8.1 the library ships a complete managed parser, writer, canonical emitter and comment-preserving formatter. It is intended to match native output and error envelopes; CI conformance checks parity. Pure does not load, download, or invoke native code.
Select globally with Ktav.UsePureBackend() or pass KtavBackend.Pure per call (which takes precedence).
var value = Ktav.Loads(src, KtavBackend.Pure);
var text = Ktav.Dumps(value, KtavBackend.Pure);
var canonical = Ktav.EmitCanonical(value, KtavBackend.Pure);
var formatted = Ktav.Format(src, KtavBackend.Pure);
var strict = Ktav.LoadsStrict(src, KtavBackend.Pure);KTAV_BACKEND=pure sets the initial default once at startup. Per-call selection takes precedence. Default changes are atomic. NativeVersion() still requires native. CI checks pure error parity; spans use original UTF-8 byte offsets, including BOM and line endings.
Run Pure with the ready net8.0 Ktav.dll and a compatible installed .NET 8 Microsoft.NETCore.App framework. A later major version alone does not guarantee a net8 consumer will run; that requires host-configured supported roll-forward. Framework APIs such as System.Text.Json and Memory are available on net8, but pure execution does not use native JSON wire/envelope helpers. For standalone use, copy the DLL and reference it via assembly Reference/HintPath, without a Ktav PackageReference. Pure does not load, download, invoke, or fall back to native, and needs no network.
The multi-target NuGet package is not dependency-free: netstandard2.0 retains System.Memory and System.Text.Json PackageReferences for compatibility and native wire/error-envelope support. Native netstandard support remains. SDK, NuGet restore/build, tests, and documentation tools are development tooling, not Pure runtime dependencies.
On net8.0, NativeLoader registers a
NativeLibrary.SetDllImportResolver callback. Resolution order:
$KTAV_LIB_PATH— absolute path to a local build. Most useful for development and air-gapped CI.- NuGet
runtimes/<rid>/native/layout — picked up automatically by .NET's default loader when consumed viaKtav.nupkg. - User cache —
<userCache>/ktav-dotnet/v<version>/…, downloaded on a previous call. - GitHub Release download — fetched once from
github.com/ktav-lang/csharp/releases/download/v<version>/<asset>and cached under (3). Requires network on first call after install.
<userCache> is %LOCALAPPDATA% on Windows, ~/Library/Caches on
macOS, $XDG_CACHE_HOME or ~/.cache on Linux.
On netstandard2.0, there is no custom resolver: the KTAV_LIB_PATH
environment variable and the cache/download fallback are not used. Rely
on NuGet's native asset layout or the platform's normal native-library
search paths.
All of the above applies to the native backend only. The pure
managed backend (Pure backend) never consults
$KTAV_LIB_PATH, the runtimes/ layout, the cache or the network.
Before the first native call the managed layer checks
ktav_abi_version() against the ABI shape this build requires
(2 since the 0.9 edit exports). An older binary is rejected with a
clear version-mismatch error — it is never silently replaced by the
pure backend.
net8.0(AOT-friendly viaLibraryImport) andnetstandard2.0(Mono / Unity / .NET Framework 4.7.2+).- Prebuilt binaries for:
linux-x64,linux-arm64,osx-x64,osx-arm64,win-x64,win-arm64. - Linux distros must use glibc 2.17+ (zigbuild baseline). Alpine (musl) support is planned.
MIT OR Apache-2.0 — see LICENSE-MIT and LICENSE-APACHE.
spec— specification + conformance suiterust— reference Rust crate (cargo add 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)