Expand description
Multi-file composition for KDL documents — resolves (<)file and
(<)glob directives into a single composed KdlDocument.
club-kdl itself is a pure parser — from_str takes a string, returns
a value, no IO. This crate adds the composer layer on top: it reads
files, walks the parsed AST, and splices in the contents of any files
referenced by an include directive — including transitively, with cycle
detection.
§Why a separate crate
Putting include resolution in core would force every club-kdl user to
buy into filesystem IO. Keeping it in a companion crate lets users who
want pure-parser behavior keep it (parse a string, deserialize), and lets
users who want multi-file composition opt in with a single use.
§Directive syntax — (<)variant
KDL has no native include directive — the spec deliberately leaves
composition to the host language. KDL type annotations (tags) are by
convention used to type values ((date)"2026-05-19", (u8)42), so a word
tag like (include) would visually compete with type names. This crate
uses a symbol tag (<) instead — directional, single character,
unambiguously not a type name. The < mnemonic is “content flowing
into this position from another file”.
// MVP — inline a single file
(<)file "./types.kdl"
// Glob — inline every matching file
(<)glob "./protocols/*.kdl"
// Namespace prefix — top-level nodes' first string arg get `shared.`
(<)file "./types.kdl" as="shared"
// Children-block options — list-valued filters
(<)file "./types.kdl" as="shared" {
only "User" "Memory"
except "Internal"
rename "User" "Acct"
}- tag = directive marker (
<for include; future categories like overlay / template would pick their own symbol) - node name = variant (
file,glob; futureremote/module) as=property = single-value namespace prefix- children block = list-valued options (
only/except/rename)
KDL property values are scalars only — array-valued options would not
parse, so list options live in a children block instead. The order of
transformation is filter (only / except) → rename → as= prefix.
§What gets composed
For each (<)file/glob directive node anywhere in the document —
top-level or nested inside any block — the directive node is replaced by
the composed top-level nodes of the referenced file(s), spliced into the
same position. Non-directive nodes are recursively walked: their children
are composed too.
§Limits, by design
- Paths are resolved relative to the importing file. No search paths, no CWD, no environment lookups. A schema’s directory is the only base.
as=renames the first string argument of each top-level included node only. Cross-references inside an included file (type="Foo"in afield) are not rewritten —composeis schema-agnostic. Authors who useas=should keep included files flat (no cross-refs) or qualify refs themselves.- No duplicate-name detection. Two
struct "User"nodes after composition are not flagged here — consumers (e.g., codegen) catch that, since “what counts as a duplicate” is schema-specific.
§Example
Each example is an executable doc-test — cargo test --doc runs it
against a real filesystem (under tempfile::tempdir), so an API drift in
compose / from_path breaks the docs and the test suite at once.
// Lay out a two-file schema under a scratch directory.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("types.kdl"),
r#"struct "User" { field "id" type="string" }"#,
).unwrap();
std::fs::write(
dir.path().join("schema.kdl"),
"(<)file \"./types.kdl\"\nlocal \"trailing-node\"",
).unwrap();
// Resolve all (<) directives, returning a single composed KdlDocument.
let doc = kdl_compose::compose(dir.path().join("schema.kdl"))?;
assert_eq!(doc.nodes().len(), 2); // struct + local
assert_eq!(doc.nodes()[0].name().value(), "struct");
assert_eq!(doc.nodes()[1].name().value(), "local");Re-exports§
pub use error::ComposeError;pub use error::Result;
Modules§
- error
- Error type for
crate::composeandcrate::from_path.
Functions§
- compose
- Compose a KDL document from
path, resolving every(<)directive recursively. Returns the composedKdlDocument— equivalent to the file the user wrote, but with every include spliced in place. - from_
path - Compose then deserialize via
club_kdl::from_doc— a thin convenience for the common case of “I have a typed schema and a root KDL file”.