Rust port of the ZMK layout tooling stack. The crate parses Devicetree keymaps, offers structured editing primitives, and bridges layouts to a stable JSON format that can be consumed by GUI editors and other automation.
See CLI_CODEBASE_REFACTOR_PLAN.md for the refactor roadmap; the crate is written without unsafe.
- Tokenizer & parser –
logos-powered lexer (tokenizer) and recursive descent parser (parser) that lift Devicetree syntax into a strongly typed AST (ast). - Serializer – deterministic formatting via
serialization, so documents you touch stay close to the original style. - Macro & template helpers –
macro_supportevaluates#defines, conditionals, and template blocks so downstream tooling can treat expansions as plain text. - Binding-aware editing –
bindingsunderstands ZMK behaviors (tap/hold, mod chains, etc.) and normalizes them for consistent round‑tripping. - Provider APIs –
providersexposes ergonomic methods for mutatingDtsDocuments (layers, combos, behavior metadata) with structured errors; modules are split intolayers,combos,behaviors, and sharedformat/utilhelpers. - CLI + shared IO –
clihosts clap definitions + per-command handlers and relies oniohelpers for layout/task loading, serialization, and diff rendering. - Standard adapter –
adapters::standardconverts between Devicetree and a JSON schema (layers,combos,behaviors,metadata) for use by other projects, with a unifiedadapters::pipelinethat loads JSON/DTS (paths or text) and optionally captures template metadata. - Firmware builds –
buildprovides manifest parsing, toolchain orchestration, cache/workspace management, and layout staging (including adapter pipelines) for thezmk-layout firmwarecommands. - Flash fakes – set
ZMK_FLASH_FAKE_BACKEND=1plusZMK_FLASH_FAKE_MOUNTPOINT,ZMK_FLASH_FAKE_NAME,ZMK_FLASH_FAKE_SERIAL,ZMK_FLASH_FAKE_VENDOR,ZMK_FLASH_FAKE_MODEL, andZMK_FLASH_FAKE_FSTYPEto run firmwaredevices/flashcommands without hardware.
src
├── adapters/ # Standard/MoErgo adapters + unified pipeline/bundles
│ ├── standard/ # JSON import/export/helpers + template renderers
│ └── pipeline.rs # JSON/DTS loader with optional template capture
├── ast/ # AST definitions + walkers
├── bindings/ # Binding parser & normalization rules
├── build/ # Firmware builder/toolchains/workspaces/progress
├── cli/ # clap app + per-command handlers + shared context
├── dts/ # High-level DtsDocument wrapper
├── flash/ # Flash core + platform backends (feature-gated)
├── io/ # Shared layout/task IO + diff helpers
├── layout_engine/ # Task execution plumbing over KeymapDocument
├── macro_support/ # Macro registry & expansion
├── parser/ # Devicetree parser
├── providers/ # Keymap/behavior/combo helpers
├── serialization/ # Serializer back to DTS text
└── tasks/ # Task config/targets/lua_engine + executor
See `docs/codebase_overview.md` for a contributor-oriented summary of module boundaries and feature flags.
Requirements:
- Rust toolchain with the 2024 edition enabled (
rustup default nightlyuntil the edition stabilizes).
Add the crate to your project (local path or git until published):
cargo add zmk-layout-rs --path rust/zmk-layout-rsuse zmk_layout_rs::{
dts::DtsDocument,
providers::{KeymapProvider, ProviderError},
};
fn add_escape_binding() -> Result<(), ProviderError> {
let document = DtsDocument::parse_file("config/keymap.dts")?;
let mut provider = KeymapProvider::new(document);
let layers = provider.layer_names();
println!("layers: {layers:?}");
provider.set_binding("base", 0, "&kp ESC")?;
let updated = provider.into_document();
updated.write_to_file("config/keymap.generated.dts")?;
Ok(())
}use zmk_layout_rs::{
adapters::{export_standard_file, import_standard_file_with_template},
dts::DtsDocument,
};
fn round_trip() -> Result<(), Box<dyn std::error::Error>> {
let document = DtsDocument::parse_file("keymap.dts")?;
export_standard_file(&document, "layout.json")?;
let hydrated = import_standard_file_with_template("layout.json", "template.dtsi")?;
hydrated.write_to_file("keymap.from_json.dts")?;
Ok(())
}The emitted JSON has this shape (all fields optional unless noted):
{
"title": "Corne-ish Zen",
"metadata": { "keyboard": "cradio" },
"layers": [{ "name": "base", "bindings": ["&kp Q", "&kp W"] }],
"combos": [{ "name": "copy", "key_positions": [0, 1], "bindings": ["&kp C"] }],
"behaviors": [{ "name": "caps_word", "bindings": ["&caps_word"] }]
}examples/standard_cli.rs demonstrates a minimal converter:
cargo run --example standard_cli -- export \
--dts tests/fixtures/keymap.dts \
--json layout.json
cargo run --example standard_cli -- import \
--json layout.json \
--template tests/fixtures/keymap_template.dts \
--output keymap.new.dts
cargo run --example standard_cli -- import \
--json layout.json \
--template examples/moergo_glove80.j2 \
--output keymap.moergo.dtsLoad layouts from JSON or DTS (paths or text):
use zmk_layout_rs::adapters::AdapterPipeline;
fn load_any_layout() -> Result<(), Box<dyn std::error::Error>> {
// From JSON path
let json_layout = AdapterPipeline::from_json_path("layout.json").load()?;
// From DTS
let rendered = std::fs::read_to_string("rendered.dts")?;
let layout = AdapterPipeline::from_dts_text(rendered).load()?;
println!("layers: {}", layout.layers.len());
Ok(())
}Import zmk_layout_rs::prelude::* to pull common types and aliases (DtsDocument,
KeymapProvider, task engine types, and both keymap document representations) from
a single module:
use zmk_layout_rs::prelude::*;
fn list_layers() -> Result<(), Box<dyn std::error::Error>> {
let doc = DtsDocument::parse_file("config/keymap.dts")?;
let provider = KeymapProvider::new(doc);
println!("layers: {:?}", provider.layer_names());
Ok(())
}Use the TOML keyboard profiles to hydrate layouts and resolve template metadata:
use std::path::PathBuf;
use zmk_layout_rs::{
adapters::import_standard_file_for_profile,
profiles::KeyboardProfileDoc,
};
fn hydrate_profile_layout() -> Result<(), Box<dyn std::error::Error>> {
let profile = KeyboardProfileDoc::from_file("profiles/keyboards/glove80.toml")?;
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let hydrated =
import_standard_file_for_profile("layout.json", &profile, &repo_root)?;
hydrated.write_to_file("glove80.generated.dts")?;
Ok(())
}Validate profiles directly from the CLI:
zmk-layout profiles check profiles/keyboards/glove80.toml
zmk-layout profiles check --allThe --all flag scans every *.toml inside profiles/keyboards/ (override with
--profiles-dir DIR).
The crate now ships with a dedicated task runner so you can replay layout tweaks whenever the base
template changes. Task files follow the schema described in docs/customization_tasks.md. Use the
zmk-layout binary to apply, validate, or diff your changes:
# Apply tasks and write the result
zmk-layout keymap apply --tasks layout_tasks.toml --base-layout config/keymap.dts --output config/keymap.generated.dts
# Dry-run to inspect conflicts without touching the file
zmk-layout keymap validate --tasks layout_tasks.toml --base-layout config/keymap.dts
# Review a unified diff in the terminal
zmk-layout keymap diff --tasks layout_tasks.toml --base-layout config/keymap.dtsHelpful flags:
--conflicts override|skip|prompt|scriptoverrides the task file's default conflict policy for a single run.--base-template NAME/--base-version VERSIONdocument which upstream template/version you expected and warn when the task file disagrees.--combo-conditionsprints a post-run summary of combo tasks that declareconditions = ["..."]for easier review.
See the docs for conflict policies, target naming guidance, and troubleshooting tips.
The same document covers the Lua scripting hooks that power script tasks and conflict handlers.
Build with --features ancpp-preprocessor to enable C-preprocessing before parsing DTS files (helps when keymaps use zmk-helpers macros). When the feature is on, CLI flags appear on DTS-consuming commands:
--preprocessopt-in flag; add--cpp-include DIRfor zmk-helpers and your config dir,--cpp-system-include DIRfor Zephyr/ZMK headers, and--cpp-define NAME[=VALUE]for things likeHOST_OS=2.- Tasks/Script:
zmk-layout keymap apply/validate/diffandzmk-layout keymap luaaccept the flags and preprocess--base-layout/--layoutfirst. - Keymap export:
zmk-layout keymap convert --preprocess ... --input config/keymap.dts --output layout.json --from dts --to jsonexpands macros before exporting JSON. You can supply--profile <name>to auto-apply profile-specific extraction (e.g., MoErgo regex capture) instead of plain DTS parsing. - Keymap render:
zmk-layout keymap convert --input layout.json --output templates/keymap.dtsi --from json --to dts --template templates/keymap.dtsi.j2writes DTS from JSON. You can pass--profile <name>instead of--templateto resolve the template from the keyboard profile. - MoErgo JSON:
--from moergo-json/--to moergo-jsonround-trip Keymap-Editor exports like the samples underexamples/samples/*.json. - Firmware build:
zmk-layout firmware build --preprocess ... --layout-dts config/keymap.dts ...preprocesses the DTS before feeding the build pipeline.
The ancpp crate is MPL-2.0 with additional terms; keep it feature-gated if your project requires MIT/Apache-only dependencies.
For one-off automation or debugging, run Lua scripts directly without creating a full task file:
zmk-layout keymap lua \
--script tasks/swap_layer_names.lua \
--layout config/keymap.dts \
--output config/keymap.generated.dts
# Preview without writing a file
zmk-layout keymap lua --script scratch/update_layers.lua --layout config/keymap.dts --diffScripts use the fluent layout global (1-based indices) shared with script tasks:
layout:layer("base"):bind(2, "&kp TAB"):apply()
layout:combo("copy"):keys({28, 29}):binding("&kp C"):apply()
log("patched base layer")Pass --diff to print a unified diff instead of writing files, or omit --output entirely to stream the
updated DTS to stdout. See docs/customization_tasks.md and docs/layer_api.md for the full scripting surface.
If your DTS template uses {{ … }} placeholders (similar to the Python implementation),
the adapter can expand the template directly. Provide metadata extras in the JSON
(includes, custom_devicetree, input_processors, etc.) and call
import_standard_file_with_template. Known placeholders such as {{combos}},
{{macros}}, {{behaviors}}, {{rendered_layers}}, and {{layer_names_defines}}
are populated from the layout data. See examples/moergo_glove80.j2 for a
full-featured template mirroring the Python generator output.
The project includes Docker-based toolchains for building ZMK firmware. The MoErgo toolchain
(formerly known as glove80-zmk-config) is located in toolchains/moergo/.
Keyboard profiles are single TOML documents that collect a keyboard’s metadata,
hardware facts, firmware catalog, and layout template hints. Firmware manifests
point at these profiles to understand what they are building. See
docs/keyboard_profiles.md for the full specification and authoring guidance.
zmk-layout firmware build \
--manifest profiles/firmwares/glove80.toml \
--keyboard glove80 \
--toolchain zmk \
--target left \
--layout-dts config/keymap.generated.dts \
--output dist/glove80-leftKey options: supply exactly one layout input (--layout-json, --layout-dts, or --keymap plus an optional --kconfig),
repeat --target to limit which manifest targets build, use --env KEY=VALUE for ad-hoc environment overrides, add
-D/--kconfig-def NAME=VALUE to append Kconfig options (also forwarded as west -D args), and enable
--disable-cache or --dry-run when you want clean workspaces or a printed request without touching Docker. For manifest
schema details, caching policies, and artifact/log expectations see docs/firmware_building.md.
Every zmk-layout firmware build
invocation emits:
build-<keyboard>-<toolchain>.logwith the combined Docker output.build-info-<keyboard>-<toolchain>.jsonsummarizing metadata, targets, and artifacts.
Build the MoErgo toolchain image using one of the provided Dockerfiles:
# Recommended: Debian-based image (smaller, faster build)
docker build -t moergo-zmk-config-docker:latest -f ./toolchains/moergo/Dockerfile.debian ./toolchains/moergo/
# Alternative: Pure Nix-based image
docker build -t moergo-zmk-config-nix:latest -f ./toolchains/moergo/Dockerfile.nix ./toolchains/moergo/
# Alternative: Alpine-based Nix image
docker build -t moergo-zmk-config-docker:latest -f ./toolchains/moergo/Dockerfile ./toolchains/moergo/The image name moergo-zmk-config-docker:latest is referenced in profiles/firmwares/glove80.toml
and is used by the build system to compile firmware for MoErgo keyboards.
- Run the full test suite:
cargo test - Lint/format (optional but recommended):
cargo fmt && cargo clippy - New contributors can explore behavior in
tests/and the fixtures undertests/fixtures.
Issues, ideas, and PRs are welcome—please include tests alongside functional changes so the parser/serializer guarantees stay solid.