From 581ce0b3dad3d8dcb7fdfd872f2d447f6c75dc1c Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sun, 16 Aug 2026 18:00:55 -0400 Subject: [PATCH] fix(cmdatom): mappings #41347 Problem: `` mappings do not emit `CmdAtom.text`. `` and Lua-callback mappings that edit the buffer apply only at the primary cursor, not cascaded (multicursor). Solution: Capture the `` 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. --- runtime/doc/autocmd.txt | 3 ++- src/nvim/input.c | 3 +++ src/nvim/input_cmdatom.c | 21 +++++++++++-------- src/nvim/input_cmdatom_defs.h | 19 ++++++++++------- test/functional/editor/cmdatom_spec.lua | 28 ++++++++++++++++++++++++- 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 2a2d94c336..326ce1a118 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -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 (||/Lua commands). + keys (Lua callbacks). - lhs: LHS (user input). Raw bytes, like `keys`. - motionforce |forced-motion|: "v", "V", or "" (|key-notation|). @@ -486,6 +486,7 @@ CmdAtom After a user action (an input "atom"): any Literal text, not key encoding. Examples: - typed "iab" → text="ab" - typed ":cnext" → text="cnext" + - typed "cnext" → text="cnext" - typed "iabc" → text="c" - type: "command", "ex", "insert", "jump", "mapping", "motion", "mouse", "operator", diff --git a/src/nvim/input.c b/src/nvim/input.c index 16b8438e1e..39e1fcb7b2 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -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; diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 9972a07410..e61dddd947 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -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 "" 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 "" 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". + } else if (curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) { + // Same for ":cnext" or "cnext". CmdAtom atom = atom_from_cmdline(kAEx, ca, curcmd.cmdline); atom.changed = changed; atom_push(false, atom); diff --git a/src/nvim/input_cmdatom_defs.h b/src/nvim/input_cmdatom_defs.h index 77414e0b8c..b6b58708ff 100644 --- a/src/nvim/input_cmdatom_defs.h +++ b/src/nvim/input_cmdatom_defs.h @@ -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 (, K_LUA, plus kKeySynthetic): its only + ///< trace is its effect. + kKeySynthetic = 1 << 1, ///< Not a user keystroke (K_EVENT, K_IGNORE): unlike /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, , …). - kKeyInsFlush = 1 << 6, ///< Insert-mode cmd a literal preview cannot represent: + kKeyMotion = 1 << 6, ///< Replayable special-key motion (arrows, , …). + kKeyInsFlush = 1 << 7, ///< Insert-mode cmd a literal preview cannot represent: ///< - deletions/indent-shifts (, 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 (, …). Drag/release/move are the + kKeyMouse = 1 << 8, ///< Mouse button press (, …). Drag/release/move are the ///< press's continuation: no class, invisible to capture. }; diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index 0823d2c783..e8717d8109 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -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 /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')) + + -- "" is opaque too, but unlike a Lua callback its command is text (like a ":" mapping). + command('nnoremap ,c call setline(1, "N" . v:count)') + feed('3,c') + local cmdev = atom_last() + eq({ + type = 'ex', + lhs = ',c', + keys = k('3call setline(1, "N" . v:count)'), + 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 "" 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 call execute("")') + 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()