Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions e2e/tests/04_config.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
154 changes: 148 additions & 6 deletions e2e/tests/07_render.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" {
Expand Down
37 changes: 36 additions & 1 deletion src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Binding> {
const byKey = new Map<string, Binding>()
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' })
Comment thread
844196 marked this conversation as resolved.
Comment thread
844196 marked this conversation as resolved.

// 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']))
Expand All @@ -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 })
Expand Down