fix(cmdatom): <Cmd> mappings #41347

Problem:
`<cmd>` mappings do not emit `CmdAtom.text`.
`<cmd>` and Lua-callback mappings that edit the buffer apply only at the
primary cursor, not cascaded (multicursor).

Solution:
Capture the `<cmd>` command in getcmdkeycmd().
Add kKeyOpaque ("no capturable keys"); narrow kKeySynthetic ("not
a keystroke") to K_EVENT/K_IGNORE, so an opaque mapping's edit still
sets `map_edit` and cascades via LHS-replay.
This commit is contained in:
Justin M. Keyes
2026-08-16 18:00:55 -04:00
committed by GitHub
parent 0e436350a5
commit 581ce0b3da
5 changed files with 55 additions and 19 deletions
+2 -1
View File
@@ -471,7 +471,7 @@ CmdAtom After a user action (an input "atom"): any
|nvim_feedkeys()| (mode "n") to replay. Use
|keytrans()| to key-notation. Empty for
a mapping whose commands have no replayable
keys (|<Cmd>|/Lua commands).
keys (Lua callbacks).
- lhs: LHS (user input). Raw bytes, like `keys`.
- motionforce |forced-motion|: "v", "V", or
"<C-V>" (|key-notation|).
@@ -486,6 +486,7 @@ CmdAtom After a user action (an input "atom"): any
Literal text, not key encoding. Examples:
- typed "iab<Esc>" → text="ab"
- typed ":cnext<CR>" → text="cnext"
- typed "<Cmd>cnext<CR>" → text="cnext"
- typed "iab<Left>c<Esc>" → text="c"
- type: "command", "ex", "insert", "jump",
"mapping", "motion", "mouse", "operator",
+3
View File
@@ -3430,6 +3430,9 @@ char *getcmdkeycmd(int promptc, void *cookie, int indent, bool do_concat)
if (aborted) {
ga_clear(&line_ga);
} else if (promptc == ':') {
// Executing (do_cmdline()), not discarding (vim.on_key()): capture the payload.
atom_cmdline_set(K_COMMAND, line_ga.ga_data, (size_t)line_ga.ga_len);
}
return line_ga.ga_data;
+12 -9
View File
@@ -190,7 +190,8 @@ static CmdAtom atom_from_spec(CmdAtomType type, CmdSpec spec)
static CmdAtom atom_from_cmdline(CmdAtomType type, cmdarg_T *ca, const char *cmdline)
{
StringBuilder sb = KV_INITIAL_VALUE;
if (type != kAEx && ca->count0 != 0) {
if (ca->cmdchar != ':' && ca->count0 != 0) {
// Not for ":", its count already prefilled (":.,.+1"). But "<Cmd>" needs count in the keys.
kv_printf(sb, "%d", ca->count0);
}
sb_add_char(&sb, ca->cmdchar);
@@ -525,9 +526,10 @@ unsigned atom_key_class(int cmd, int arg)
switch (cmd) {
case K_EVENT:
case K_IGNORE:
return kKeyOpaque | kKeySynthetic;
case K_COMMAND:
case K_LUA:
return kKeySynthetic;
return kKeyOpaque;
case '/':
case '?':
case ':':
@@ -591,12 +593,12 @@ unsigned atom_key_class(int cmd, int arg)
}
}
/// Captures an accepted ":" cmdline payload.
/// Captures an accepted ":" or "<Cmd>" cmdline payload.
void atom_cmdline_set(int firstc, const char *line, size_t len)
{
// Not for nested cmdlines opened by a command's own execution (":normal", macros): they would
// overwrite the user command's payload, e.g. `:exe "normal! :echo 1\r"`.
if (!atom_is_user_cmd() || firstc != ':') {
if (!atom_is_user_cmd() || (firstc != ':' && firstc != K_COMMAND)) {
return;
}
xfree(curcmd.cmdline);
@@ -977,11 +979,12 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel
// so its operator can prep a redo: ":normal! vjd" is dot-repeatable.
const bool user = atom_is_user_cmd();
const unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar);
// Synthetic commands (timers, RPC, plugin callbacks) are not user-input: one that changes
// nothing is invisible; one that changes the buffer/selection voids the pending visual atom.
// Opaque cmd that changed nothing is invisible; one that changed the buffer/selection voids the
// pending visual atom (see `kKeyOpaque`).
//
// XXX: This "state diff" is ad hoc: a synthetic change to unobserved state (e.g. only w_curswant)
// counts as no-op. Extend this (or atom_key_class()) when such a case is reported...
bool opaque = (keycls & kKeyOpaque) != 0;
bool synthetic = (keycls & kKeySynthetic) != 0;
bool unchanged = curbuf == old->buf
&& equalpos(old->pos, curwin->w_cursor)
@@ -990,7 +993,7 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel
&& (!Visual.active
|| (equalpos(old->visual.start, Visual.start)
&& old->visual.mode == Visual.mode));
if (synthetic && unchanged) {
if (opaque && unchanged) {
return;
}
bool ins_cascaded = user && curcmd.ins_cascaded;
@@ -1074,8 +1077,8 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel
CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf);
atom.changed = changed;
atom_push(false, atom);
} else if (curcmd.cmdline != NULL && ca->cmdchar == ':') {
// Same for ":cnext<CR>".
} else if (curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) {
// Same for ":cnext<CR>" or "<Cmd>cnext<CR>".
CmdAtom atom = atom_from_cmdline(kAEx, ca, curcmd.cmdline);
atom.changed = changed;
atom_push(false, atom);
+11 -8
View File
@@ -66,17 +66,20 @@ struct CmdAtom {
/// Key classes (atom_key_class()).
/// Flags, bc same char can mean different things per mode (CTRL-T: tag-jump vs i_CTRL-T indent).
enum {
kKeySynthetic = 1 << 0, ///< Not a user keystroke (K_EVENT, K_IGNORE, K_COMMAND, K_LUA).
kKeyPayload = 1 << 1, ///< Interactively-typed payload (/, ?, :, !).
kKeyScrollMove = 1 << 2, ///< Scroll may move cursor (C-D/…): viewport-dependent, unreplayable.
kKeyScrollView = 1 << 3, ///< Viewport-only scroll (C-Y,wheel): cursor stays, unless 'scrolloff'.
kKeyJump = 1 << 4, ///< Moves to absolute pos from primary cursor's shared nav state
kKeyOpaque = 1 << 0, ///< Uncapturable keys (<Cmd>, K_LUA, plus kKeySynthetic): its only
///< trace is its effect.
kKeySynthetic = 1 << 1, ///< Not a user keystroke (K_EVENT, K_IGNORE): unlike <Cmd>/K_LUA, never
///< reaches us from a mapping.
kKeyPayload = 1 << 2, ///< Interactively-typed payload (/, ?, :, !).
kKeyScrollMove = 1 << 3, ///< Scroll may move cursor (C-D/…): viewport-dependent, unreplayable.
kKeyScrollView = 1 << 4, ///< Viewport-only scroll (C-Y,wheel): cursor stays, unless 'scrolloff'.
kKeyJump = 1 << 5, ///< Moves to absolute pos from primary cursor's shared nav state
///< (jumplist C-O/I, CTRL-T, "g;"): not followable.
kKeyMotion = 1 << 5, ///< Replayable special-key motion (arrows, <Home>, …).
kKeyInsFlush = 1 << 6, ///< Insert-mode cmd a literal preview cannot represent:
kKeyMotion = 1 << 6, ///< Replayable special-key motion (arrows, <Home>, …).
kKeyInsFlush = 1 << 7, ///< Insert-mode cmd a literal preview cannot represent:
///< - deletions/indent-shifts (<Del>, CTRL-W, …) may edit text
///< outside the tracked region by per-cursor amounts;
///< - cursor-moves (start_arrow()) move the insertion point itself.
kKeyMouse = 1 << 7, ///< Mouse button press (<LeftMouse>, …). Drag/release/move are the
kKeyMouse = 1 << 8, ///< Mouse button press (<LeftMouse>, …). Drag/release/move are the
///< press's continuation: no class, invisible to capture.
};
+27 -1
View File
@@ -105,7 +105,7 @@ describe('CmdAtom', function()
pick(atom_last(), 'type', 'lhs', 'keys', 'changed')
)
-- An empty-keys mapping that DOES edit still reports it: `changed` is
-- the only informative payload of a <Cmd>/Lua-callback edit.
-- the only informative payload of a Lua-callback edit.
n.exec_lua([[
vim.keymap.set('n', ',e', function()
vim.api.nvim_buf_set_lines(0, 0, 0, false, { 'NEW' })
@@ -113,6 +113,32 @@ describe('CmdAtom', function()
]])
feed(',e')
eq({ keys = '', changed = true }, pick(atom_last(), 'keys', 'changed'))
-- "<Cmd>" is opaque too, but unlike a Lua callback its command is text (like a ":" mapping).
command('nnoremap ,c <Cmd>call setline(1, "N" . v:count)<CR>')
feed('3,c')
local cmdev = atom_last()
eq({
type = 'ex',
lhs = ',c',
keys = k('3<Cmd>call setline(1, "N" . v:count)<NL>'),
text = 'call setline(1, "N" . v:count)',
count = 3,
changed = true,
}, pick(cmdev, 'type', 'lhs', 'keys', 'text', 'count', 'changed'))
-- Those keys replay: the count must survive, since "<Cmd>" reads v:count.
eq('N3', fn.getline(1))
fn.setline(1, 'reset')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(cmdev.keys))
eq('N3', fn.getline(1))
-- Opaque key that changes nothing is invisible: mid-selection it must not void the pending
-- visual atom, which would void its per-cursor extents.
command('vnoremap ,n <Cmd>call execute("")<CR>')
fn.setline(1, { 'aaa bbb' })
feed('gg0viw,nd')
eq(' bbb', fn.getline(1))
eq({ type = 'visual', keys = 'viwd' }, pick(atom_last(), 'type', 'keys'))
end)
it('motions, search, Ex emit without an edit', function()