diff --git a/e2e/tests/04_config.bats b/e2e/tests/04_config.bats index af03958..7fa87d8 100644 --- a/e2e/tests/04_config.bats +++ b/e2e/tests/04_config.bats @@ -156,11 +156,14 @@ YAML assert_equal "$stderr" '~/.config/wk/bindings.yaml: expected a list of bindings' } -@test "global and local bindings are concatenated with global first" { +@test "local bindings override global ones with the same key" { write_bindings <<'YAML' - key: l type: command buffer: from-global +- key: g + type: command + buffer: global-only YAML write_local_bindings <<'YAML' - key: l @@ -171,16 +174,52 @@ YAML buffer: local-only YAML - # Both files contribute, and the global definition of a shared key wins. + # A shared key resolves to the local definition... wk_run --inputs 'l' assert_equal "$status" 0 - assert_equal "$output" $'\t\tfrom-global' + assert_equal "$output" $'\t\tfrom-local' + + # ...but an untouched global key still contributes... + wk_run --inputs 'g' + assert_equal "$status" 0 + assert_equal "$output" $'\t\tglobal-only' + # ...alongside a local-only one. wk_run --inputs 'x' assert_equal "$status" 0 assert_equal "$output" $'\t\tlocal-only' } +@test "a local group replaces a global group of the same key entirely" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git (global) + bindings: + - key: p + type: command + buffer: git push +YAML + write_local_bindings <<'YAML' +- key: g + type: bindings + desc: Git (local) + bindings: + - key: c + type: command + buffer: git commit +YAML + + # The whole group is swapped, not merged: the global group's own + # sub-binding is gone rather than sitting alongside the local one. + wk_run --inputs 'g p' + assert_equal "$status" 5 + + wk_run --inputs 'g c' + assert_equal "$status" 0 + assert_equal "$output" $'\t\tgit commit' +} + @test "local bindings alone are enough" { write_local_bindings <<'YAML' - key: x diff --git a/e2e/tests/07_render.bats b/e2e/tests/07_render.bats index 389fe93..5b6d2c1 100644 --- a/e2e/tests/07_render.bats +++ b/e2e/tests/07_render.bats @@ -13,8 +13,12 @@ teardown() { stop_zsh_session } -@test "each row is the key, the separator and the description, in binding order" { +@test "rows are ordered by key regardless of declaration order or global/local origin" { write_bindings <<'YAML' +- key: l + type: command + desc: List + buffer: ls -la - key: g type: bindings desc: Git @@ -23,18 +27,156 @@ teardown() { type: command desc: Push buffer: git push -- key: l +YAML + write_local_bindings <<'YAML' +- key: i type: command - desc: List - buffer: ls -la + desc: Info + buffer: info YAML start_wk_session wait_for_screen 'List' run capture_screen - # A group is marked with symbols.group in front of its description. + # Declared l-then-g, and i comes from a separate (local) source, but + # sorting interleaves all three by key: g, i, l. assert_line --index 1 ' g ➜ +Git' - assert_line --index 2 ' l ➜ List' + assert_line --index 2 ' i ➜ Info' + assert_line --index 3 ' l ➜ List' +} + +@test "a duplicated key renders as a single row" { + write_bindings <<'YAML' +- key: l + type: command + desc: First + buffer: first +- key: l + type: command + desc: Second + buffer: second +YAML + start_wk_session + wait_for_screen 'First' + + run capture_screen + # The row a keypress can never reach (main.ts's `find` always resolves to + # the first definition) is dropped before rendering, not just shown twice. + assert_line --index 1 ' l ➜ First' + refute_line --partial 'Second' +} + +@test "a duplicated key inside a nested group renders as a single row" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git + bindings: + - key: p + type: command + desc: First + buffer: first + - key: p + type: command + desc: Second + buffer: second +YAML + start_wk_session --inputs 'g' + wait_for_screen 'First' + + run capture_screen + # Deduping isn't only a top-level, pre-merge concern: it applies at every + # nesting level, same as sorting. + assert_line --index 1 ' p ➜ First' + refute_line --partial 'Second' +} + +@test "keys that collate as equal still sort deterministically" { + write_bindings <<'YAML' +- key: "1" + type: command + desc: One + buffer: one +- key: "01" + type: command + desc: ZeroOne + buffer: zero-one +YAML + start_wk_session + wait_for_screen 'One' + + run capture_screen + # `numeric` collation treats "01" and "1" as the same value; an exact + # string tie-break is what keeps their order from being merge-order noise. + assert_line --index 1 ' 01 ➜ ZeroOne' + assert_line --index 2 ' 1 ➜ One' +} + +@test "keys sort naturally, not lexicographically" { + write_bindings <<'YAML' +- key: f10 + type: command + desc: Ten + buffer: ten +- key: f2 + type: command + desc: Two + buffer: two +- key: f1 + type: command + desc: One + buffer: one +YAML + start_wk_session + wait_for_screen 'Ten' + + run capture_screen + assert_line --index 1 " 󱊫 ➜ One" + assert_line --index 2 " 󱊬 ➜ Two" + assert_line --index 3 " 󱊴 ➜ Ten" +} + +@test "an uppercase key sorts before its lowercase counterpart" { + write_bindings <<'YAML' +- key: g + type: command + desc: Lower + buffer: lower +- key: G + type: command + desc: Upper + buffer: upper +YAML + start_wk_session + wait_for_screen 'Upper' + + run capture_screen + assert_line --index 1 ' G ➜ Upper' + assert_line --index 2 ' g ➜ Lower' +} + +@test "sorting is applied recursively to nested groups" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git + bindings: + - key: p + type: command + desc: Push + buffer: git push + - key: c + type: command + desc: Commit + buffer: git commit +YAML + start_wk_session --inputs 'g' + wait_for_screen 'Commit' + + run capture_screen + # Declared p-then-c, but c sorts first inside the group too. + assert_line --index 1 ' c ➜ Commit' + assert_line --index 2 ' p ➜ Push' } @test "a command without a description falls back to its buffer" { diff --git a/src/run.ts b/src/run.ts index c51a117..ee2ad65 100644 --- a/src/run.ts +++ b/src/run.ts @@ -66,6 +66,41 @@ function unescapeAnsi(given: string): string { return given.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) } +// `main.ts`'s `find` always resolves a duplicated key to its first match, so +// keeping the first occurrence here (rather than e.g. the last) is what +// keeps a merge or a sort from disagreeing with that. +function firstByKey(bindings: Binding[]): Map { + const byKey = new Map() + for (const binding of bindings) { + if (!byKey.has(binding.key)) byKey.set(binding.key, binding) + } + return byKey +} + +// Local bindings shadow global ones sharing the same key, whole-entry — a +// group and a command never partially merge, and a group's own nested +// `bindings` never cross the boundary either. `local` goes first so +// `firstByKey` keeps its entry over global's for a shared key. +function mergeBindings(global: Binding[], local: Binding[]): Binding[] { + return [...firstByKey([...local, ...global]).values()] +} + +// A fixed locale rather than the ambient one, so key order doesn't shift +// with the user's `LANG`. `numeric` compares a digit run by value (f2 +// before f10 — ICU chunks past 254 significant digits, well past any real +// key name), and `caseFirst: 'upper'` keeps `G` before `g`. +const keyCollator = new Intl.Collator('en', { numeric: true, caseFirst: 'upper' }) + +// Applied at every nesting level, not just the merged top level. Two +// distinct keys can collate as equal (e.g. `f2` and `f02` under `numeric`), +// and `toSorted` is stable, so an exact-string tie-break keeps their order +// from depending on where each one came from. +function sortBindings(bindings: Binding[]): Binding[] { + return [...firstByKey(bindings).values()] + .toSorted((a, b) => keyCollator.compare(a.key, b.key) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .map((binding) => binding.type === 'bindings' ? { ...binding, bindings: sortBindings(binding.bindings) } : binding) +} + export const runCommand = new Command() .description('Run.') .type('boolOrAuto', new EnumType(['true', 'false', 'auto'])) @@ -90,7 +125,7 @@ For example, this simulates pressing "g", "p", and "f".`, const empty: Binding[] = [] const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), empty, parseBindings) const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), empty, parseBindings) - return [ctx, globalBindings.concat(localBindings)] as const + return [ctx, sortBindings(mergeBindings(globalBindings, localBindings))] as const } const tty = await Deno.open('/dev/tty', { read: true, write: true })