Skip to content
ktav-langPublic

About

Native C#/.NET bindings for Ktav -- a plain configuration format with three rules, zero indentation, and zero quoting. P/Invoke-backed, ships for .NET Framework, .NET Core, and Unity from a single NuGet package.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

ktav — .NET bindings

NuGet CI License: MIT OR Apache-2.0 Playground

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

Install

dotnet add package Ktav

The package also works with no native binary at all — see Pure backend (Ktav.UsePureBackend() or KtavBackend.Pure per call).

Quick start

Parse — pull typed fields out of a document

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();

Walk — pattern-match on the sealed KtavValue hierarchy

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

Build & render — construct a document in code

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.

API

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.

Formatting — canonical spelling, comments kept

Format and EmitCanonical are different operations:

  • EmitCanonical takes a KtavValue and writes the canonical form. A value carries no comments, so none can survive.
  • Format takes 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 form

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

Editing source text

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.EditValue replaces an existing value only; it does not create, remove, or rename values. Missing keys, out-of-range array indices, or descending through a scalar produce EditNotFound; malformed paths produce EditInvalidPath (Body is the offender's UTF-8 byte offset). EditValue returns EditUnsupported when 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.

Source-edit cookbook

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;

1. Dotted deployment settings

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

2. Literal dots: textual path or segments

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}

3. A leaf inside a mixed-form array

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]

4. A numeric-looking String stays a String

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

5. Multiline replacement: expand only what is necessary

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

6. No-op preserves noncanonical spelling

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

7. BOM, CRLF and an unterminated final line

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.

8. Exact Integer replacement

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

Diagnostics and safe failures in 0.9

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.

Structured errors

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:

  • Span holds 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 expects string indices, or to an LSP client that has not negotiated positionEncoding: "utf-8".
  • Path is a list of segments, never a joined string. A key literally named a.b is one segment and cannot be confused with a two-segment path.

Type mapping

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.

Key escaping

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

The pure backend

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.

How the native library is resolved

On net8.0, NativeLoader registers a NativeLibrary.SetDllImportResolver callback. Resolution order:

  1. $KTAV_LIB_PATH — absolute path to a local build. Most useful for development and air-gapped CI.
  2. NuGet runtimes/<rid>/native/ layout — picked up automatically by .NET's default loader when consumed via Ktav.nupkg.
  3. User cache — <userCache>/ktav-dotnet/v<version>/…, downloaded on a previous call.
  4. 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.

Runtime support

  • net8.0 (AOT-friendly via LibraryImport) and netstandard2.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.

License

MIT OR Apache-2.0 — see LICENSE-MIT and LICENSE-APACHE.

Other Ktav implementations

  • spec — specification + conformance suite
  • rust — reference Rust crate (cargo add 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

Native C#/.NET bindings for Ktav -- a plain configuration format with three rules, zero indentation, and zero quoting. P/Invoke-backed, ships for .NET Framework, .NET Core, and Unity from a single NuGet package.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages