Skip to content

ADR 0134: Vim keys you can move

Status

Accepted

Context

ADR 0113 made every chord data — key.<chord> = <command> — and said plainly that Vim's own keys were not part of it: dd and gg go through a stateful Normal-mode dispatcher, not the chord table.

So the bindings a Vim user would most want to move are exactly the ones that could not be moved. An editor whose headline is keybindings as data should not have hjkl nailed down, least of all for someone on Colemak or Dvorak.

Decision

vim.normal.<key> = <keys>, which is vim's own nnoremap.

The right-hand side is a key sequence, not a command name and not a single key. That covers both things people want:

vim.normal.n = j      # a layout remap
vim.normal.Y = y$     # the classic nnoremap

vim.visual.<key> is Visual only, and an unqualified vim.<key> applies in both. The mode-qualified form wins, which is how key. has always resolved.

Not a data-driven dispatcher

The obvious reading of "vim sequences as data" is a table mapping every Vim command to a name. That cannot work: d2w is an operator, a count and a motion composed at the keystroke, not an entry in a table. A table would have to enumerate the product of three open sets.

A remap sidesteps that entirely by standing in for the key, before the state machine sees it. A count already typed and an operator already pending then compose with it for free, because nothing downstream knows a substitution happened — 3n and dn work the moment vim.normal.n = j exists, with no changes to counts or operators.

Replayed, not re-entered

The right-hand side is fed back through the dispatcher one key at a time — the same path . repeat and macros already use, which is why this needed no new replay machinery.

Replaying happens with remapping off. That is the "nore" in nnoremap, and without it

vim.normal.x = dd
vim.normal.d = x

is an infinite loop rather than two swapped keys.

An argument is not a command

The key after f, r, `, ", q, @, i/a and g is an argument. Remapping it would make f unable to find a character you had rebound, and "ayy would yank into whatever register a maps to.

VimPending::expectsArgument() names those states and the remap steps aside for them. A pending operator is deliberately not one of them: after d the next key is a motion, and a remapped motion composing with a pending operator is the point.

Consequences

Thirteen tests, of which seven fail without the hook and six are the negative cases that must pass either way — argument keys untouched, Insert mode untouched, an unmapped key unaffected.

The 220 vim conformance cases still pass unchanged, which is the check that matters most: this sits on the hot path of every Normal-mode key and must be invisible when nothing is configured.

Measured on that path: 4.49µs per Normal-mode key with the lookup, 4.27µs without — about 220ns, or 5%. Left alone rather than gated behind a "has any remaps" flag. That gate was worth it for ADR 0125, where the waste was eight times the lookup it guarded; here it is a twentieth of one keypress, and a Normal-mode key is a discrete act rather than a burst.

A malformed remap is reported rather than ignored: vim.normal.abc = j names no single key and would match nothing quietly, which is the failure keys::problems() exists to prevent. Adding that check found that the function returned early when a config had no key. settings at all — so a config with only vim. ones went entirely unchecked.

What a key cannot be: =, because the config splits on it. Nothing else in the format escapes, and inventing an escape for one key was worse than the limitation.