club-kdl
English | 日本語
A family of crates for working with KDL in Rust — derive-based serde, schema-driven multi-language code generation, and multi-file composition.
[]
= "0.11"
The crate family
club-kdl ships as four crates sharing a workspace version; reach for the one that matches the job.
| Crate | What it does | Reach for it when |
|---|---|---|
club-kdl |
Derive-based serialization / deserialization between Rust structs and KDL text | You have Rust types and want them to round-trip with KDL |
club-kdl-derive |
Proc-macro behind #[derive(KdlDeserialize, KdlSerialize)] |
(Internal — pulled in by club-kdl; rarely depended on directly) |
club-kdl-codegen |
KDL schema → Rust / TypeScript / Zod / SurrealQL code generator (CLI + library) | A single KDL schema should drive types in multiple languages |
club-kdl-compose |
Multi-file KDL composition via the (<)file / (<)glob include directive |
Your schemas / configs grow large and you want to split them across files |
The rest of this README is about club-kdl (the derive layer). For the schema generator and the composer, jump to Schema codegen and Multi-file schemas below.
Why club-kdl (the derive layer)?
The official Rust implementation of KDL, kdl-rs, focuses on AST-level manipulation — converting to and from Rust structs is left to you. club-kdl adds an attribute-based derive layer on top of kdl-rs, so #[derive(KdlDeserialize, KdlSerialize)] is all you need for a full struct ↔ KDL round trip.
| Library | Role | Best for |
|---|---|---|
kdl |
KDL parser / AST | Building and editing KDL dynamically / spec-compliant low-level work |
knuffel / knus |
derive-based parser | Spec-compliance focused / parsing-oriented |
club-kdl |
derive-based ser/de | Bidirectional struct ↔ KDL / automatic parent-child node name resolution / enum data variants |
club-kdl uses the kdl crate (v6) AST internally, so spec compliance is delegated to kdl-rs.
What the derive does
flowchart LR
A["Rust struct\n+ #[derive(KdlDeserialize)]"] --> B["from_str()"]
C["KDL text"] --> B
B --> D["Rust struct value"]
D --> E["to_string_pretty()"]
E --> F["KDL text"]
Struct fields are mapped to KDL node structure via #[kdl(...)] attributes.
use ;
This struct can read and write the following KDL:
service "api" image="myapp" {
port host=8080 container=80
port host=8443 container=443
}
// Deserialize (KDL → Rust)
let service: Service = from_str.unwrap;
// Serialize (Rust → KDL)
let kdl_text = to_string_pretty.unwrap;
Schema codegen with club-kdl-codegen
Define your types and protocols once in KDL, generate them in every language you need:
# schema.kdl
struct "User" {
field "id" type="string"
field "name" type="string"
}
enum "Role" {
variant "admin"
variant "member"
}
The schema dialect also supports the entity dialect (record / relation / link<T>) for relational/graph data and the protocol dialect (protocol / channel / request / event) for IPC schemas. Channels can opt-in to a discriminated-union envelope with envelope="t", generating #[serde(tag = "t")] Rust enums, TypeScript discriminated unions, and Zod discriminatedUnions.
See club-kdl-codegen on docs.rs for the full dialect reference.
Multi-file schemas with club-kdl-compose
When a schema gets large, split it across files and include with (<)file (or (<)glob for batch import):
# schema.kdl
(<)file "./types.kdl"
protocol "sidebar" version="1.0.0" {
channel "ipc" from="client" envelope="t" {
(<)file "./common-requests.kdl"
request "specific:save" { field "data" type="string" }
}
}
# types.kdl
struct "User" { field "id" type="string" }
The directive lives anywhere — top-level or inside any block — and resolves recursively with cycle detection. Selective import lives in a children block:
(<)file "./types.kdl" as="shared" {
only "User" "Memory"
rename "User" "Acct"
}
This renames the first string argument of each top-level included node to shared.Acct / shared.Memory. The club-kdl-codegen CLI uses club-kdl-compose internally, so (<) directives just work; library consumers can also call kdl_compose::compose(path) -> KdlDocument or kdl_compose::from_path::<T>(path) directly.
See club-kdl-compose on docs.rs for directive syntax and limits.
Attribute reference
Container attributes
| Attribute | Description |
|---|---|
#[kdl(name = "...")] |
KDL node name (defaults to the struct name in snake_case) |
#[kdl(alias = "...")] |
Alternative node name (multiple allowed; accepted during deserialization) |
#[kdl(document)] |
Treat as a whole KDL document (multiple top-level nodes) |
Field attributes
| Attribute | Description |
|---|---|
#[kdl(argument)] |
Map to a positional argument (auto-indexed) |
#[kdl(argument(index = N))] |
Map to the argument at a specific index |
#[kdl(arguments)] |
Collect all arguments into a Vec<T> |
#[kdl(property)] |
Named property (key=value) |
#[kdl(property(rename = "...")] |
Map to a property with a different name |
#[kdl(child)] |
Single child node (resolves the child type's #[kdl(name)]) |
#[kdl(child(name = "...")] |
Look up a child node by explicit name |
#[kdl(child, unwrap_arg)] |
Take the child node's first argument as the value |
#[kdl(child, unwrap_args)] |
Take all of the child node's arguments as a Vec<T> |
#[kdl(children)] |
Collect child nodes into a Vec<T> (resolves the child type's #[kdl(name)]) |
#[kdl(children(name = "...")] |
Filter and collect child nodes by explicit name |
#[kdl(child_map)] |
Collect child nodes into a HashMap<String, String> |
#[kdl(child_map(name = "...")] |
Collect children inside a wrapper node into a HashMap |
#[kdl(flatten)] |
Expand a child struct's fields into the parent node |
#[kdl(default)] |
Use Default::default() when missing |
#[kdl(skip)] |
Skip this field during serialization / deserialization |
Enum attributes
| Attribute | Applies to | Description |
|---|---|---|
#[kdl(rename = "...")] |
scalar / data | KDL representation of the variant name (defaults to snake_case) |
Enum support
Scalar enums (used as property / argument values)
An enum where all variants are unit (no data) is mapped to a string in a KDL argument or property.
channel "events" from="server"
Data enums (variant identified by node name)
An enum containing struct / newtype / unit variants identifies the variant by the KDL node name.
move x=10.0 y=20.0
configure key="debug" value="true"
quit
Collecting child nodes with Vec<DataEnum>
Combined with #[kdl(children)], a data enum can collect children with different node names in one go.
pipeline "deploy" {
move x=1.0 y=2.0
configure key="env" value="prod"
quit
}
Automatic child node name resolution
#[kdl(child)] / #[kdl(children)] automatically resolve the child struct's #[kdl(name = "...")]. Even when the field name differs from the KDL node name, the mapping is correct without an explicit name.
post-setup "bun install"
If the child struct has no #[kdl(name)], it falls back to the field name.
Aliases
Adding #[kdl(alias = "...")] to a struct makes deserialization accept the alternative name too.
Both database "pg://..." and db "pg://..." deserialize successfully. kdl_node_name() always returns the primary name ("database").
Usage examples
Parsing a whole document
When a KDL file has multiple top-level nodes, use #[kdl(document)]:
let config: Config = from_str.unwrap;
Collecting all arguments
depends_on "db" "redis" "cache"
Child node map
service "api" {
env {
DATABASE_URL "postgres://localhost/db"
API_KEY "secret"
}
}
unwrap_arg / unwrap_args
Take only a child node's arguments as the value:
app {
name "my-app"
tags "web" "api"
}
flatten
Expand a child struct's fields into the parent node:
service "api" interval=30 timeout=5
Supported types
- Integers:
i32,i64,i128,u16,u32,u64,usize - Floating point:
f64 - Boolean:
bool - Strings:
String,&str(zero-copy) - Path:
PathBuf - Collections:
Vec<T>,HashMap<String, String> - Optional:
Option<T> - Custom types: implement
FromKdlValue/ToKdlValue
Guides
For more detailed usage, see docs/guide/:
- Custom Types Guide — map your own types (chrono types, newtypes, etc.) to KDL values
- KDL Design Best Practices — choosing between argument / property / children, and anti-patterns
- Troubleshooting — common errors and their fixes
Benchmarks
benches/kdl_vs_json.rs contains a micro-benchmark that reads and writes equivalent docker-compose-like data in both KDL and JSON.
Measured values (Apple Silicon, Rust 1.95, criterion median):
| operation | KDL (club-kdl) | JSON (serde_json) | ratio |
|---|---|---|---|
| read | 486 µs | 4.2 µs | KDL is ~115x slower |
| write | 8.8 µs | 1.7 µs | KDL is ~5x slower |
Run it with:
Detailed results are available in the HTML report (target/criterion/report/index.html).
Usage guidance: KDL is a format optimized for human readability, and reads are clearly heavier than JSON. For frequent re-parsing on a hot path, choose JSON or a binary format (such as rkyv); use club-kdl for configuration files, declarative schemas, and human-edited DSLs.
MSRV (Minimum Supported Rust Version)
The current MSRV is Rust 1.94. It is managed via the rust-version field in Cargo.toml and continuously verified in CI.
MSRV bumps may happen in a patch release (following the semver convention).
Contributing
See CONTRIBUTING.md. Please report security issues following the procedure in SECURITY.md.
License
This project is licensed under either of the following, at your option:
- Apache License, Version 2.0, (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
📝 This English README is the canonical source going forward.
README.ja.mdis the Japanese translation; if the two ever disagree, this English version is authoritative.