Skip to content

Commit 358174b

Browse files
patrickomaticclaude
andcommitted
Add ZMK example fixture, round-trip tests, and fix two ZMK renderer bugs
- Fix layer_label doubling _layer suffix on re-render (base_layer → base_layer_layer), which broke any ZMK-sourced round-trip - Fix render_macros emitting bindings without semicolon for non-empty steps, and emitting an empty bindings line for empty steps (now consistently emits TODO comment + <&none> to match the stub branch, making QMK→ZMK→ZMK renders idempotent) - Add PartialEq/Eq derives to all IR types for key-level assertions - Add examples/zmk/keymap.zmk: 48-key Planck-style reference fixture - Add 20 integration tests: ZMK parse coverage, ZMK→QMK rendering, and three round-trip tests (ZMK→ZMK string equality, QMK C→ZMK→ZMK string equality, QMK C→ZMK key-level equality) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f23c8a8 commit 358174b

4 files changed

Lines changed: 287 additions & 16 deletions

File tree

examples/zmk/keymap.zmk

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// ZMK keymap example — Planck-style 48-key (4 rows × 12 cols)
2+
// Used as a reference fixture for integration tests.
3+
/ {
4+
conditional_layers {
5+
compatible = "zmk,conditional-layers";
6+
tri_layer {
7+
if-layers = <1 2>;
8+
then-layer = <3>;
9+
};
10+
};
11+
12+
keymap {
13+
compatible = "zmk,keymap";
14+
15+
base_layer {
16+
bindings = <
17+
&kp TAB &kp Q &kp W &kp E &kp R &kp T &kp Y &kp U &kp I &kp O &kp P &kp BSPC
18+
&caps_word &kp A &kp S &kp D &kp F &kp G &kp H &kp J &kp K &kp L &kp SEMI &kp SQT
19+
&kp LSHFT &mt LALT Z &mt LCTRL X &kp C &kp V &kp B &kp N &kp M &kp COMMA &kp DOT &kp FSLH &kp RSHFT
20+
&kp LCTRL &kp LGUI &kp LALT &kp LCTRL &mo 1 &kp SPACE &kp RET &mo 2 &kp LEFT &kp DOWN &kp UP &kp RIGHT
21+
>;
22+
};
23+
24+
lower_layer {
25+
bindings = <
26+
&kp TILDE &kp EXCL &kp AT &kp HASH &kp DLLR &kp PRCNT &kp CARET &kp AMPS &kp STAR &kp LPAR &kp RPAR &kp DEL
27+
&trans &trans &trans &trans &trans &trans &trans &kp UNDER &kp PLUS &kp LBRC &kp RBRC &kp PIPE
28+
&trans &trans &trans &trans &trans &trans &trans &trans &trans &trans &trans &trans
29+
&trans &trans &trans &trans &trans &trans &trans &trans &kp HOME &kp PG_DN &kp PG_UP &kp END
30+
>;
31+
};
32+
33+
raise_layer {
34+
bindings = <
35+
&kp GRAVE &kp N1 &kp N2 &kp N3 &kp N4 &kp N5 &kp N6 &kp N7 &kp N8 &kp N9 &kp N0 &kp DEL
36+
&trans &trans &trans &trans &trans &trans &trans &kp MINUS &kp EQUAL &kp LBKT &kp RBKT &kp BSLH
37+
&trans &trans &trans &trans &trans &trans &trans &trans &trans &trans &trans &trans
38+
&trans &trans &trans &trans &trans &trans &trans &trans &kp HOME &kp PG_DN &kp PG_UP &kp END
39+
>;
40+
};
41+
42+
adjust_layer {
43+
bindings = <
44+
&bootloader &none &none &none &none &none &none &none &none &none &none &sys_reset
45+
&none &none &none &none &none &none &none &none &none &none &none &none
46+
&none &none &none &none &none &none &none &none &none &none &none &none
47+
&none &none &none &none &none &none &none &none &rgb_ug RGB_TOG &rgb_ug RGB_HUI &rgb_ug RGB_SAI &rgb_ug RGB_EFF
48+
>;
49+
};
50+
};
51+
};

src/ir.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#[derive(Debug, Clone)]
1+
#[derive(Debug, Clone, PartialEq, Eq)]
22
pub struct Keymap {
33
pub keyboard: Option<String>,
44
pub layout: Option<String>,
@@ -7,14 +7,14 @@ pub struct Keymap {
77
pub tri_layer: Option<TriLayer>,
88
}
99

10-
#[derive(Debug, Clone)]
10+
#[derive(Debug, Clone, PartialEq, Eq)]
1111
pub struct Layer {
1212
pub name: String,
1313
pub index: usize,
1414
pub keys: Vec<Key>,
1515
}
1616

17-
#[derive(Debug, Clone)]
17+
#[derive(Debug, Clone, PartialEq, Eq)]
1818
pub enum Key {
1919
Kp(String),
2020
Mo(usize),
@@ -38,19 +38,19 @@ pub enum Key {
3838
Unknown(String),
3939
}
4040

41-
#[derive(Debug, Clone)]
41+
#[derive(Debug, Clone, PartialEq, Eq)]
4242
pub struct MacroDef {
4343
pub name: String,
4444
pub steps: Vec<MacroStep>,
4545
}
4646

47-
#[derive(Debug, Clone)]
47+
#[derive(Debug, Clone, PartialEq, Eq)]
4848
pub enum MacroStep {
4949
Tap(String),
5050
Wait(u32),
5151
}
5252

53-
#[derive(Debug, Clone)]
53+
#[derive(Debug, Clone, PartialEq, Eq)]
5454
pub struct TriLayer {
5555
pub lower: usize,
5656
pub upper: usize,

src/zmk/mod.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,20 @@ fn render_macros(out: &mut String, keymap: &Keymap) {
5656
let _ = writeln!(out, " {}: {} {{", m.name, m.name);
5757
out.push_str(" compatible = \"zmk,behavior-macro\";\n");
5858
out.push_str(" #binding-cells = <0>;\n");
59-
let steps: Vec<String> = m
60-
.steps
61-
.iter()
62-
.map(|s| match s {
63-
MacroStep::Tap(k) => format!("<&kp {k}>"),
64-
MacroStep::Wait(ms) => format!("<&macro_wait_time {ms}>"),
65-
})
66-
.collect();
67-
let _ = writeln!(out, " bindings = {}", steps.join(", "));
59+
if m.steps.is_empty() {
60+
out.push_str(" // TODO: fill in macro steps\n");
61+
out.push_str(" bindings = <&none>;\n");
62+
} else {
63+
let steps: Vec<String> = m
64+
.steps
65+
.iter()
66+
.map(|s| match s {
67+
MacroStep::Tap(k) => format!("<&kp {k}>"),
68+
MacroStep::Wait(ms) => format!("<&macro_wait_time {ms}>"),
69+
})
70+
.collect();
71+
let _ = writeln!(out, " bindings = {};", steps.join(", "));
72+
}
6873
out.push_str(" };\n");
6974
}
7075

@@ -152,7 +157,10 @@ fn render_key(key: &Key) -> String {
152157
}
153158

154159
fn layer_label(name: &str) -> String {
155-
let base = name.trim_start_matches('_').to_lowercase();
160+
let base = name
161+
.trim_start_matches('_')
162+
.trim_end_matches("_layer")
163+
.to_lowercase();
156164
if base.is_empty() { "default_layer".into() } else { format!("{base}_layer") }
157165
}
158166

@@ -264,6 +272,10 @@ mod tests {
264272
assert_eq!(layer_label("_ADJUST"), "adjust_layer");
265273
assert_eq!(layer_label("QWERTY"), "qwerty_layer");
266274
assert_eq!(layer_label(""), "default_layer");
275+
// Round-trip: names already ending in _layer should not double it
276+
assert_eq!(layer_label("base_layer"), "base_layer");
277+
assert_eq!(layer_label("lower_layer"), "lower_layer");
278+
assert_eq!(layer_label("adjust_layer"), "adjust_layer");
267279
}
268280

269281
#[test]

tests/integration.rs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use qmk2zmk::zmk;
55
const KEYMAP_C: &str =
66
include_str!("../examples/zsa-qmk/zsa_planck_ez_glow_planck_source/keymap.c");
77

8+
const ZMK_KEYMAP: &str = include_str!("../examples/zmk/keymap.zmk");
9+
810
#[test]
911
fn parses_four_layers() {
1012
let km = qmk2zmk::qmk::parse_c::parse(KEYMAP_C).unwrap();
@@ -138,3 +140,209 @@ fn output_is_valid_zmk_skeleton() {
138140
// Macro stub present
139141
assert!(out.contains("ST_MACRO_0: ST_MACRO_0"));
140142
}
143+
144+
// ── ZMK example: parsing ──────────────────────────────────────────────────────
145+
146+
#[test]
147+
fn zmk_parses_four_layers() {
148+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
149+
assert_eq!(km.layers.len(), 4);
150+
}
151+
152+
#[test]
153+
fn zmk_layers_named_correctly() {
154+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
155+
assert_eq!(km.layers[0].name, "base_layer");
156+
assert_eq!(km.layers[1].name, "lower_layer");
157+
assert_eq!(km.layers[2].name, "raise_layer");
158+
assert_eq!(km.layers[3].name, "adjust_layer");
159+
}
160+
161+
#[test]
162+
fn zmk_has_48_keys_per_layer() {
163+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
164+
for layer in &km.layers {
165+
assert_eq!(layer.keys.len(), 48, "layer {} has wrong key count", layer.name);
166+
}
167+
}
168+
169+
#[test]
170+
fn zmk_base_layer_first_key_is_tab() {
171+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
172+
let base = &km.layers[0];
173+
assert!(matches!(&base.keys[0], Key::Kp(k) if k == "TAB"));
174+
}
175+
176+
#[test]
177+
fn zmk_base_layer_has_caps_word() {
178+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
179+
let base = &km.layers[0];
180+
// Row 1, col 0
181+
assert!(matches!(&base.keys[12], Key::CapsWord));
182+
}
183+
184+
#[test]
185+
fn zmk_base_layer_has_home_row_mods() {
186+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
187+
let base = &km.layers[0];
188+
// Row 2: mt LALT Z, mt LCTRL X
189+
assert!(matches!(&base.keys[25], Key::Mt(m, k) if m == "LALT" && k == "Z"));
190+
assert!(matches!(&base.keys[26], Key::Mt(m, k) if m == "LCTRL" && k == "X"));
191+
}
192+
193+
#[test]
194+
fn zmk_base_layer_has_mo_keys() {
195+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
196+
let base = &km.layers[0];
197+
assert!(matches!(&base.keys[40], Key::Mo(1)), "col 4 of bottom row should be mo 1");
198+
assert!(matches!(&base.keys[43], Key::Mo(2)), "col 7 of bottom row should be mo 2");
199+
}
200+
201+
#[test]
202+
fn zmk_lower_layer_has_symbols() {
203+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
204+
let lower = &km.layers[1];
205+
assert!(matches!(&lower.keys[0], Key::Kp(k) if k == "TILDE"));
206+
assert!(matches!(&lower.keys[1], Key::Kp(k) if k == "EXCL"));
207+
assert!(matches!(&lower.keys[2], Key::Kp(k) if k == "AT"));
208+
assert!(matches!(&lower.keys[3], Key::Kp(k) if k == "HASH"));
209+
}
210+
211+
#[test]
212+
fn zmk_lower_layer_has_trans_and_nav() {
213+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
214+
let lower = &km.layers[1];
215+
assert!(matches!(&lower.keys[12], Key::Trans));
216+
assert!(matches!(&lower.keys[44], Key::Kp(k) if k == "HOME"));
217+
assert!(matches!(&lower.keys[47], Key::Kp(k) if k == "END"));
218+
}
219+
220+
#[test]
221+
fn zmk_raise_layer_has_numbers() {
222+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
223+
let raise = &km.layers[2];
224+
assert!(matches!(&raise.keys[1], Key::Kp(k) if k == "N1"));
225+
assert!(matches!(&raise.keys[9], Key::Kp(k) if k == "N9"));
226+
assert!(matches!(&raise.keys[10], Key::Kp(k) if k == "N0"));
227+
}
228+
229+
#[test]
230+
fn zmk_adjust_layer_has_bootloader_and_reset() {
231+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
232+
let adjust = &km.layers[3];
233+
assert!(matches!(&adjust.keys[0], Key::Bootloader));
234+
assert!(matches!(&adjust.keys[11], Key::SysReset));
235+
}
236+
237+
#[test]
238+
fn zmk_adjust_layer_has_rgb() {
239+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
240+
let adjust = &km.layers[3];
241+
assert!(matches!(&adjust.keys[44], Key::RgbUg(a) if a == "RGB_TOG"));
242+
assert!(matches!(&adjust.keys[47], Key::RgbUg(a) if a == "RGB_EFF"));
243+
}
244+
245+
#[test]
246+
fn zmk_tri_layer_detected() {
247+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
248+
let tri = km.tri_layer.expect("tri-layer should be detected");
249+
assert_eq!(tri.lower, 1);
250+
assert_eq!(tri.upper, 2);
251+
assert_eq!(tri.tri, 3);
252+
}
253+
254+
// ── ZMK example: rendering to QMK ────────────────────────────────────────────
255+
256+
#[test]
257+
fn zmk_renders_to_valid_qmk_c() {
258+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
259+
let out = qmk2zmk::qmk::render_c(&km, None);
260+
assert!(out.contains("#include QMK_KEYBOARD_H"));
261+
assert!(out.contains("keymaps[][MATRIX_ROWS][MATRIX_COLS]"));
262+
assert!(out.contains("_BASE"));
263+
assert!(out.contains("_LOWER"));
264+
assert!(out.contains("_RAISE"));
265+
assert!(out.contains("_ADJUST"));
266+
assert!(out.contains("KC_TAB"));
267+
assert!(out.contains("CW_TOGG"));
268+
assert!(out.contains("MO(1)"));
269+
assert!(out.contains("MO(2)"));
270+
}
271+
272+
#[test]
273+
fn zmk_home_row_mods_survive_to_qmk() {
274+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
275+
let out = qmk2zmk::qmk::render_c(&km, None);
276+
assert!(out.contains("MT(MOD_LALT,KC_Z)"));
277+
assert!(out.contains("MT(MOD_LCTL,KC_X)"));
278+
}
279+
280+
#[test]
281+
fn zmk_rgb_survives_to_qmk() {
282+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
283+
let out = qmk2zmk::qmk::render_c(&km, None);
284+
assert!(out.contains("RGB_TOG"));
285+
}
286+
287+
// ── Round-trip tests ──────────────────────────────────────────────────────────
288+
289+
/// ZMK → render ZMK → parse → render ZMK again: the two renders must be equal.
290+
/// This verifies our ZMK renderer is stable (idempotent after the first parse).
291+
#[test]
292+
fn zmk_to_zmk_round_trip() {
293+
let km = zmk::parse::parse(ZMK_KEYMAP).unwrap();
294+
let pass1 = zmk::render(&km, None);
295+
let km2 = zmk::parse::parse(&pass1).unwrap();
296+
let pass2 = zmk::render(&km2, None);
297+
assert_eq!(pass1, pass2, "ZMK render must be stable after re-parse");
298+
}
299+
300+
/// QMK C (no unknown keys) → render ZMK → re-parse → render again: the two ZMK
301+
/// renders must be equal. Uses an inline fixture so there are no Unknown keys
302+
/// that would become ZMK block comments and disappear on re-parse.
303+
#[test]
304+
fn qmk_c_to_zmk_to_zmk_round_trip() {
305+
const SIMPLE_QMK_C: &str = r"
306+
enum layers { _BASE, _FN };
307+
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
308+
[_BASE] = LAYOUT(
309+
KC_TAB, KC_Q, KC_W, KC_E, KC_R, KC_T,
310+
CW_TOGG, KC_A, KC_S, KC_D, KC_F, KC_G,
311+
KC_LSFT, MT(MOD_LALT,KC_Z), MT(MOD_LCTL,KC_X), KC_C, KC_V, KC_B,
312+
KC_LCTL, KC_LGUI, KC_LALT, KC_LCTL, MO(1), KC_SPC
313+
),
314+
[_FN] = LAYOUT(
315+
KC_TRNS, KC_1, KC_2, KC_3, KC_4, KC_5,
316+
KC_TRNS, KC_NO, KC_NO, KC_NO, KC_NO, KC_NO,
317+
KC_TRNS, KC_NO, KC_NO, KC_NO, KC_NO, KC_NO,
318+
KC_TRNS, KC_TRNS, KC_TRNS, KC_TRNS, KC_TRNS, KC_TRNS
319+
),
320+
};
321+
";
322+
let km = qmk_c::parse(SIMPLE_QMK_C).unwrap();
323+
let pass1 = zmk::render(&km, None);
324+
let km2 = zmk::parse::parse(&pass1).unwrap();
325+
let pass2 = zmk::render(&km2, None);
326+
assert_eq!(pass1, pass2, "ZMK render from QMK source must stabilize after one re-parse");
327+
}
328+
329+
/// QMK C → ZMK → parse: key bindings must be preserved for every layer that
330+
/// contains no Unknown keys. Unknown keys render as ZMK block comments which
331+
/// the re-parser strips, so layers with unknowns are explicitly skipped here.
332+
#[test]
333+
fn qmk_c_to_zmk_preserves_all_keys() {
334+
let km1 = qmk_c::parse(KEYMAP_C).unwrap();
335+
let zmk_str = zmk::render(&km1, None);
336+
let km2 = zmk::parse::parse(&zmk_str).unwrap();
337+
338+
assert_eq!(km1.layers.len(), km2.layers.len(), "layer count must survive QMK→ZMK");
339+
for (l1, l2) in km1.layers.iter().zip(km2.layers.iter()) {
340+
if l1.keys.iter().any(|k| matches!(k, Key::Unknown(_))) {
341+
continue; // unknown keys become block comments that vanish on re-parse
342+
}
343+
assert_eq!(
344+
l1.keys, l2.keys,
345+
"layer {} keys must survive QMK→ZMK round-trip", l1.name
346+
);
347+
}
348+
}

0 commit comments

Comments
 (0)