Skip to main content

Crate kdl_compose

Crate kdl_compose 

Source
Expand description

Multi-file composition for KDL documents — resolves (<)file and (<)glob directives into a single composed KdlDocument.

club-kdl itself is a pure parserfrom_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; future remote / 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) → renameas= 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 a field) are not rewritten — compose is schema-agnostic. Authors who use as= 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::compose and crate::from_path.

Functions§

compose
Compose a KDL document from path, resolving every (<) directive recursively. Returns the composed KdlDocument — 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”.