dms+kdl — DMS dialect for KDL

Brand: dms+kdl File extension: .dms.kdl Spec version: 0.1 (draft) DMS tier required: 1 (_dms_tier: 1 in front matter) Parent spec: TIER1.md Tier-0 base: SPEC.md KDL spec: kdl.dev (KDL Document Language v2)

Pre-1.0: breaking changes are still possible. No version-bump rules apply yet.

What this is

dms+kdl is a tier-1 dialect that lets you write KDL-shaped documents in DMS. Each KDL node becomes a |<node_name>(...) decoration; positional args become a positional param group; named props become a named param group; child blocks become decorated list-item children.

Why a DMS shape for KDL? See comparison.md §KDL — the short version is that KDL's "node has name + args + props + children" model puts the data across four channels per line, and DMS tier 1 places those channels in distinct visual slots (sigil-prefixed call vs positional group vs named group vs child list) so a reader scanning for values lands on a stable column.

What dms+kdl is not. It is not a KDL evaluator. It is not a KDL ↔ JiK / XiK conversion tool. It is the DMS-shaped representation of KDL nodes. A separate runtime / render layer converts a decoded Document_t1 to and from native KDL when that's needed.

Quick comparison

package "my-app" version="1.0.0" {
    author "Ada Lovelace" email="ada@example.com"
    dependencies {
        log "0.4" features="std"
        serde "1.0"
    }
}

In dms+kdl:

+++
_dms_tier: 1
_dms_imports:
  + dialect: "kdl"
    version: "1.0.0"
+++

+ |package("my-app")(version: "1.0.0")
  + |author("Ada Lovelace")(email: "ada@example.com")
  + |dependencies
    + |log("0.4")(features: "std")
    + |serde("1.0")

Each KDL line is a node, and each dms+kdl line is a |<name>(args)(props) call with optional child block. The positional args group (...) carries KDL's positional values; the named props group (...) carries key=value pairs.

A node with only positional args and no props collapses to a single group: |serde("1.0"). A node with only props collapses similarly: |terraform(version: "1.0") (rare in KDL, common in HCL).

Realistic example

A KDL-based project manifest — the kind of file you'd use for a Rust-style project tool or a task runner. Covers package metadata, dependencies with feature flags, build scripts, and workspace members.

Native KDL

// Project manifest for "my-app"
package "my-app" version="0.3.1" edition="2024" {
    description "A CLI tool for processing DMS documents"
    license "MIT OR Apache-2.0"
    repository "https://github.com/example/my-app"

    authors {
        author "Ada Lovelace" email="ada@example.com"
        author "Grace Hopper" email="grace@example.com"
    }
}

// Runtime dependencies
dependencies {
    // Serialization  note: slashdash comments out the async feature for now
    serde version="^1.0" features="derive"
    serde_json version="^1.0"

    /-tokio version="^1.0" features="full"   // disabled pending async rewrite

    clap version="^4.0" features="derive" features="env"
    tracing version="^0.1"
    tracing-subscriber version="^0.3" features="env-filter"
    anyhow version="^1.0"
    thiserror version="^1.0"
}

dev-dependencies {
    assert_cmd version="^2.0"
    predicates version="^3.0"
    tempfile version="^3.0"
    insta version="^1.30" features="yaml"
}

// Build scripts
scripts {
    test "cargo test --all-features"
    lint "cargo clippy -- -D warnings"
    fmt  "cargo fmt --all -- --check"
    build "cargo build --release"
    ci {
        pre  "cargo fmt --all -- --check"
        run  "cargo test --all-features"
        post "cargo clippy -- -D warnings"
    }
}

// Workspace sub-crates
workspace {
    members "crates/my-app-core" "crates/my-app-cli" "crates/my-app-test-utils"
    exclude "scratch"
}

dms+kdl equivalent

+++
_dms_tier: 1
_dms_imports:
  + dialect: "kdl"
    version: "1.0.0"
+++

// Project manifest for "my-app"
+ |package("my-app")(version: "0.3.1", edition: "2024")
  + |description "A CLI tool for processing DMS documents"
  + |license "MIT OR Apache-2.0"
  + |repository "https://github.com/example/my-app"
  + |authors
    + |author("Ada Lovelace")(email: "ada@example.com")
    + |author("Grace Hopper")(email: "grace@example.com")

// Runtime dependencies
+ |dependencies
  // Serialization — comment on the dep declaration, survives round-trip
  + |serde(version: "^1.0", features: "derive")
  + |serde_json(version: "^1.0")

  /* |tokio(version: "^1.0", features: "full") */  // disabled: DMS block comment

  // clap: two features props — variadic positional collects multiple values
  + |clap("^4.0")(features: "derive", env: true)
  + |tracing(version: "^0.1")
  + |tracing-subscriber(version: "^0.3")(features: "env-filter")
  + |anyhow(version: "^1.0")
  + |thiserror(version: "^1.0")

+ |dev-dependencies
  + |assert_cmd(version: "^2.0")
  + |predicates(version: "^3.0")
  + |tempfile(version: "^3.0")
  + |insta(version: "^1.30")(features: "yaml")

// Build scripts — each script node: name as positional arg, command as second
+ |scripts
  + |test  "cargo test --all-features"
  + |lint  "cargo clippy -- -D warnings"
  + |fmt   "cargo fmt --all -- --check"
  + |build "cargo build --release"
  + |ci
    + |pre  "cargo fmt --all -- --check"
    + |run  "cargo test --all-features"
    + |post "cargo clippy -- -D warnings"

// Workspace — variadic positional args collect all member paths into one slot
+ |workspace
  + |members("crates/my-app-core", "crates/my-app-cli", "crates/my-app-test-utils")
  + |exclude("scratch")

What's visible in DMS that's lost or obscured in KDL

Variadic positional args are first-class. |members("crates/my-app-core", "crates/my-app-cli", "crates/my-app-test-utils") collects all three paths into the variadic args slot as a typed list. In KDL, members "path1" "path2" "path3" is valid but the arity is unconstrained and the collected-list shape is implicit. dms+kdl makes the collection explicit in the positional group and the dialect spec documents the variadic slot.

Comments on dependency declarations round-trip. The // Serialization comment attached above |serde(...) and the inline comment on the disabled tokio entry are carried as comment AST nodes. KDL's slashdash /- maps to a DMS block comment (/* ... */) — this is a documented lossy translation (slashdash position not preserved, comment content is), but the intent ("this dep is disabled") survives in the DMS source and re-emits on round-trip.

Stable grep targets. grep '|serde' returns every serde-family dependency. grep '|dependencies' returns the top-level dependencies node. In KDL, grep 'serde' would also match strings inside description fields, repository URLs, or comments. DMS's sigil-prefixed node names create a grep-stable namespace separate from value text.

Dialect canonical spec

+++
_dms_tier: 0
+++

name:             "kdl"
version:          "1.0.0"
version_strategy: "caret"        # default; npm-style ^

families:
  # ── KDL node: name + positional args + named props + children ──
  + name:           "node"
    default_sigils: ["|"]
    empty_default:  []           # no child block → empty children list
    # No content_slot. KDL has no flow-form children syntax;
    # child blocks are always indent-block list-items in dms+kdl.
    params:
      mode: "positional"
      positional:
        # Variadic — KDL nodes accept arbitrary positional args.
        # The slot's type describes each *element* (KDL allows
        # heterogeneous values), so `type: "any"` is correct
        # rather than `list_of any`.
        - { name: "args", type: "any", variadic: true }
      typed:
        # Properties are wildcard — any name accepted, any value
        # type. KDL doesn't constrain prop key shapes beyond the
        # bareword/string rule.
        # (No typed entries; everything passes through wildcard.)

  # ── KDL type annotation: (u8)10, (date)"...", etc. ─────────
  + name:           "tagged"
    default_sigils: ["@"]
    empty_default:  ""
    content_slot:   "value"
    params:
      mode: "wildcard_with_typed"
      typed:
        value: { type: "any", required: true }

Two sigils. | for nodes (the universal KDL shape) and @ for type-tagged values (KDL's (type)value form). Two sigils avoid the multi-family-per-sigil resolution conflict that would otherwise force every |tagged to be qualified.

Variadic positional. The node family declares a single positional slot args with variadic: true. Every value in the positional group accumulates into that slot as a list element. See TIER1.md §"Variadic positional slot" for the rules (only the last slot may be variadic; element-level typing; zero-or-more semantics; no AST shape change).

Content semantics

KDL nodes → |<name>(args)(props) + child list

KDL nodes have four channels:

  1. Name — the node's identifier
  2. Positional args — zero or more values
  3. Named properties — zero or more key=value pairs
  4. Child block — optional { ... }

dms+kdl maps these as:

Encoder canonical form for the multi-group call:

KDL shape dms+kdl form
node |node
node arg1 |node("arg1")
node arg1 arg2 |node("arg1", "arg2")
node prop=val |node(prop: "val")
node arg1 prop=val |node("arg1")(prop: "val")
node arg1 arg2 prop1=v1 prop2=v2 { child } |node("arg1", "arg2")(prop1: "v1", prop2: "v2") + child block
node {} |node (empty child block elides)

The "two-group" pattern ((positional)(named)) is the same multi-group form dms+hcl uses for HCL block headers. dms+kdl applies it more often because most KDL nodes have at least one positional arg and one named prop.

KDL type annotations → @<type> value

KDL allows type annotations in parens before any value:

node (u8)10 (date)"2024-01-01" version=(semver)"1.0.0"

In dms+kdl, type annotations become decorated values inside positional and named groups:

|node(@u8 10, @date "2024-01-01")(version: @semver "1.0.0")

The @<type> value form uses the tagged family's content_slot hoisting — @u8 10 is equivalent to @u8(value: 10).

The tagged family is wildcard fn name — any type tag the KDL spec defines (u8, i32, f64, date, time, date-time, regex, base64, decimal, currency, email, hostname, irl, irl-reference, uuid, ...) plus user-defined tags pass through. The dialect doesn't validate the tag name — that's a render-layer / consumer concern.

KDL strings, raw strings, identifiers

KDL has three string-ish forms:

The dialect doesn't add a new string form; it reuses tier-0's.

KDL slashdash /- → DMS block comment

KDL's /- slashdash comments out the next node / value / property in place:

node arg1 /-arg2 arg3       // arg2 commented out
node /-prop=val             // prop commented out
node /-{                    // entire child block commented out
    inner-stuff
}

In dms+kdl, slashdash maps to DMS block comments:

|node("arg1", /* "arg2", */ "arg3")
|node /* (prop: "val") */
|node /* + |inner_stuff */

This is a lossy translation — the slashdash form is lost on round-trip; the decoder reconstructs DMS block comments. Round-tripping dms+kdl → KDL → dms+kdl may not preserve the exact slashdash placement. Documented limitation.

KDL #null, #true, #false

KDL v2 wraps boolean and null literals in # to disambiguate from bareword strings. DMS has no null; #null translates to key absence in property positions and is a parse error in positional positions (an explicit-null marker would be a future @null family-level decoration — not in scope for v0.1).

#true / #false map to DMS booleans true / false.

Comments

KDL has # line comments, // line comments, /* */ block comments, and /- slashdash. DMS has the same minus slashdash. Round-trip behavior:

Versioning

dms+kdl follows semver. The first published version is 1.0.0. The default match strategy is caret.

What counts as a version bump:

KDL itself made breaking changes from v1 → v2 (raw strings moved from r"..." to #"..."#; null/true/false got # prefixes). dms+kdl targets KDL v2; KDL v1 representation would be a separate dialect (dms+kdl-v1) if a consumer needs it.

File extension

dms+kdl documents use the .dms.kdl extension.

config/cargo.dms.kdl
config/manifest.dms.kdl

What's not in scope (this dialect)

Open questions for v0.2+